一个Go语言HTTP包Client使用的坑

时间:2021-9-18     作者:smarteng     分类: Go语言


一句话总结

Go 的 http 包默认不指定请求超时,允许外部服务劫持你的 goroutine
连接到外部服务时,始终需要自定义 http.Client ,至少要自定义超时时间。

示例问题

假设开发者想通过 JSON REST API 与 spacely-sprockets.com 进行通信并查看Sprocket的列表。 开发者可能会编写以下代码:

//error checking omitted for brevity
var sprockets SprocketsResponse
response, _ := http.Get("spacely-sprockets.com/api/sprockets")
buf, _ := ioutil.ReadAll(response.Body)
json.Unmarshal(buf, &sprockets)

这些代码编译和运行一切正常。开发者通过上面的程序将 API 包引入到 Web 应用程序中。Web应用程序的一个页面通过调用 API 向用户显示 Spacely Sprockets 库存列表。
一切运行得都很顺利,直到有一天应用停止响应。开发者查看日志,但没有任何迹象表明存在问题。然后开发者使用监控工具查看CPU、内存和 I/O,同样没什么问题。最后开发者启动了一个沙盒,它似乎工作正常。所以导致应用停止响应的原因是什么?
开发者查看 Twitter 并注意到 Spacely Sprockets 开发团队的一条推文,称他们经历了短暂的中断,但现在一切都恢复正常了。开发者检查Spacely Sprockets的 API 状态页面,并看到Spacely Sprockets的中断比自己的Web应用程序的中断要早几分钟。这似乎是一个不太可能的巧合,但开发者无法弄清楚这两个中断是如何关联的,因为Web应用程序中的 API 代码会优雅地处理错误。现在还没有找到这个问题的原因。为了找到这个问题的真实原因,我们前往Go的HTTP包中寻找答案。

Go的HTTP包

Go 的 HTTP 包使用一个名为 Client 的结构来管理通过 HTTP(S) 进行通信的内部结构。 客户端是并发安全对象,包含配置、管理 TCP 状态、处理 cookie 等。当开发者使用 http.Get(url) 时,使用的是 http.DefaultClient ,这是一个定义客户端默认配置的包变量。 对此的声明是
var DefaultClient:=&Client{} 关于 Client 的相关信息,可以查看:https://pkg.go.dev/net/http#Client
除其他事项外,http.Client 配置了一个使长时间运行的连接短路的超时。 此值的默认值为 0,这被解释为“无超时”。 这对于包来说可能是一个合理的默认值,但它是一个令人讨厌的陷阱,也是我们的应用程序在上面的例子中失败的原因。 事实证明,Spacely Sprockets 的 API 中断导致连接尝试挂起(这并不总是发生,但在我们的示例中确实发生)。 只要发生故障的服务器决定等待,它们就会继续挂起。 因为 API 调用是为服务用户请求而进行的,这导致服务用户请求的 goroutines 也挂起。 一旦有足够多的用户访问 sprockets 页面,应用就会崩溃,这很可能是因为达到了资源限制。
下面这部分代码显示了这个问题:

package  main
​
import  (
"fmt"
"net/http"
"net/http/httptest"
"time"
)
​
func  main() {
svr  :=  httptest.NewServer(http.HandlerFunc(func(w  http.ResponseWriter,  r  *http.Request) {
time.Sleep(time.Hour)
}))
defer  svr.Close()
fmt.Println("making request")
http.Get(svr.URL)
fmt.Println("finished request")
}

运行时,该程序将向将休眠一小时的服务器发出请求。 因此,程序将等待一小时然后退出。

解决方法

此问题的解决方案是始终为用例定义一个具有合理超时的 http.Client
下面是一个例子:

var  netClient  =  &http.Client{
Timeout:  time.Second  *  10,
}
response,  _  :=  netClient.Get(url)

上面的代码将发出的请求超时时限设置为10 秒。如果 API 服务器超过10秒, Get() 将返回如下错误:

&httpError{
err:        err.Error()  +  " (Client.Timeout exceeded while awaiting headers)",
timeout:  true,
}

如果需要对请求生命周期进行更细粒度的控制,可以另外指定自定义 net.Transportnet.Dialer
Transport 是客户端用来管理底层 TCP 连接的结构,它的 Dialer 是管理连接建立的结构。 Go 的 net 包也有一个默认的 TransportDialer 。这是使用自定义的示例:

fmt.Println("finished request")
var netTransport = &http.Transport{
  Dial: (&net.Dialer{
  Timeout: 5 * time.Second,
}).Dial,
TLSHandshakeTimeout: 5 * time.Second,
}
var netClient = &http.Client{
  Timeout: time.Second * 10,
  Transport: netTransport,
}
response, _ := netClient.Get(url)

上面的代码将限制 TCP 连接和 TLS 握手超时,以及建立端到端请求超时。

结论

Go 的 nethttp 包是一个经过深思熟虑的、方便的 HTTP(S) 通信基础。 然而,请求缺少默认的超时时间是一个可能引发问题的漏洞,因为该包提供了像 http.Get(url) 这样的便捷方法。 一些语言(例如 Java)有同样的问题,其他语言(例如 Ruby 有一个默认的 60 秒读取超时)没有。 联系远程服务时不设置请求超时会使应用程序受该服务的支配。 出现故障或恶意的服务可能会永远挂在应用程序的连接上,可能会使应用程序崩溃。

标签: http