Golang中Context超时错误未返回deadline的问题

Golang中Context超时错误未返回deadline的问题 此问题位于 client.go 文件的 setRequestCancel 第 375 行。 我设置了 client.Timeout 为 3 秒,并调用 client.Do 发送 HTTP/2 请求。根据 net/http 包中 client.go 的源代码,虽然调用了带截止时间的上下文,但超时并未发生,请求进入了使用 FQDN 作为主机的往返传输(这只是我们代码中的问题)。 每次都是在 4 秒后返回错误,然后才调用取消。 根据我的观察,上下文在 3 秒后已完成,但并未采取任何操作来使用 rt.CancelRequest 退出正在进行的发送请求处理。 有人能帮忙理解为什么在 HTTP/2 请求中设置无效主机时,这个超时不会发生吗?


更多关于Golang中Context超时错误未返回deadline的问题的实战教程也可以访问 https://www.itying.com/category-94-b0.html

5 回复

你需要提供一些示例代码。

更多关于Golang中Context超时错误未返回deadline的问题的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


这个问题有解决方案吗

上述代码可以运行,但在我的应用程序中,client.Do 存在延迟。 上述代码返回以下错误:

时间 2.001242093s 2024-04-08 10:18:27.139158269 +0000 UTC m=+2.001778206 Post “http://udr-subs.:3000/”: dial tcp: lookup udr-subs.: i/o timeout (Client.Timeout exceeded while awaiting headers)

我的应用程序在 4 秒后返回此错误: Post “http://udr-subs.:3000/”: dial tcp: lookup udr-subs. on 10.21.0.10:53(这可能是 DNS,问题可能出在这里): server misbehaving (Client.Timeout exceeded while awaiting headers)

以下是示例代码……nfDomain1.namespace1 在 Kubernetes 集群中并不存在……尽管我设置了 2 秒的客户端超时,但 client.Do 却花费了 4 秒……

url := http://nfDomain1.namespace1/initial
data := "some data"
postReq, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(data))
if err != nil {
return nil
}
client := &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
return net.DialTimeout(network, addr, (2000)*time.Millisecond)
},
PingTimeout:     2000 * time.Millisecond,
ReadIdleTimeout: 2000 * time.Millisecond,
},
Timeout: 2000 * time.Millisecond,
}
// 我已将客户端超时设置为 2 秒,但每次错误都在 4 秒后才收到
resp, err := client.Do(postReq)
if err != nil {
return nil
}

这是一个典型的HTTP/2连接复用场景下的上下文超时问题。在HTTP/2中,由于连接复用机制,client.Timeout设置的超时可能不会按预期工作,特别是当请求被阻塞在连接池等待可用连接时。

问题的核心在于http.TransportdialConnroundTrip方法中,对于HTTP/2请求,上下文超时的处理方式与HTTP/1.x不同。当使用无效主机名时,DNS解析或连接建立可能会被阻塞,而TransportCancelRequest方法对HTTP/2请求的支持有限。

以下是问题重现的示例代码:

package main

import (
    "context"
    "fmt"
    "net/http"
    "time"
)

func main() {
    // 创建自定义Transport,启用HTTP/2
    tr := &http.Transport{
        MaxIdleConns:        10,
        IdleConnTimeout:     30 * time.Second,
        DisableCompression:  true,
        ForceAttemptHTTP2:   true,
    }
    
    client := &http.Client{
        Transport: tr,
        Timeout:   3 * time.Second, // 设置3秒超时
    }
    
    // 使用无效的主机名
    req, err := http.NewRequest("GET", "https://invalid-hostname-that-does-not-exist.example.com", nil)
    if err != nil {
        panic(err)
    }
    
    start := time.Now()
    _, err = client.Do(req)
    elapsed := time.Since(start)
    
    fmt.Printf("请求耗时: %v\n", elapsed)
    fmt.Printf("错误: %v\n", err)
    
    // 验证上下文是否超时
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    
    select {
    case <-time.After(4 * time.Second):
        fmt.Println("4秒后超时")
    case <-ctx.Done():
        fmt.Println("上下文在3秒后完成")
    }
}

问题的根本原因在于http.TransportroundTrip方法中,对于HTTP/2请求,当连接池中没有可用连接时,会尝试创建新连接。在这个过程中,DNS解析可能会被阻塞,而client.Timeout的超时机制可能无法正确中断这个阻塞操作。

client.gosetRequestCancel函数中,虽然创建了带截止时间的上下文,但Transport.CancelRequest对于HTTP/2请求的支持不完整。查看transport.go中的cancelRequest方法:

func (t *Transport) cancelRequest(key cancelKey) {
    t.reqMu.Lock()
    defer t.reqMu.Unlock()
    c := t.reqCanceler[key]
    if c != nil {
        c()
        delete(t.reqCanceler, key)
    }
}

对于HTTP/2,取消请求是通过流级别的取消实现的,但这需要连接已经建立。如果连接尚未建立(比如在DNS解析阶段),取消可能无法正确传播。

解决方案是使用带有超时的上下文,并确保在请求的所有阶段都能正确传播取消信号:

func makeRequestWithContext() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    
    req, err := http.NewRequestWithContext(ctx, "GET", 
        "https://invalid-hostname.example.com", nil)
    if err != nil {
        panic(err)
    }
    
    // 使用没有Timeout设置的Client,依赖上下文超时
    client := &http.Client{
        Transport: &http.Transport{
            ForceAttemptHTTP2: true,
        },
    }
    
    start := time.Now()
    _, err = client.Do(req)
    elapsed := time.Since(start)
    
    fmt.Printf("使用上下文的请求耗时: %v\n", elapsed)
    fmt.Printf("错误: %v\n", err)
    
    if ctx.Err() == context.DeadlineExceeded {
        fmt.Println("上下文超时生效")
    }
}

另外,可以自定义TransportDialContext函数,在DNS解析阶段就支持上下文取消:

func makeRequestWithCustomDialer() {
    dialer := &net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
    }
    
    tr := &http.Transport{
        DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
            // 在DNS解析前检查上下文是否已取消
            select {
            case <-ctx.Done():
                return nil, ctx.Err()
            default:
            }
            return dialer.DialContext(ctx, network, addr)
        },
        ForceAttemptHTTP2: true,
        MaxIdleConns:      10,
        IdleConnTimeout:   30 * time.Second,
    }
    
    client := &http.Client{
        Transport: tr,
        Timeout:   3 * time.Second,
    }
    
    req, _ := http.NewRequest("GET", "https://invalid-hostname.example.com", nil)
    start := time.Now()
    _, err := client.Do(req)
    elapsed := time.Since(start)
    
    fmt.Printf("自定义Dialer请求耗时: %v\n", elapsed)
    fmt.Printf("错误: %v\n", err)
}

在HTTP/2场景下,当使用无效主机名时,超时机制失效的主要原因是:

  1. DNS解析阶段可能被阻塞,而标准超时机制无法正确中断
  2. HTTP/2的连接复用机制使得请求取消更加复杂
  3. Transport.CancelRequest对HTTP/2的支持有限

建议使用http.NewRequestWithContext创建请求,并确保在自定义传输层中正确处理上下文取消,特别是在DNS解析和连接建立阶段。

回到顶部