Skip to content

GO-POST请求避坑指北

824字约3分钟

避坑指北GO-HTTP

2021-10-19

每天上一当, 当当不一样

背景

某次服务上线, 观察线上日志发现一些报错,报错信息类似于 : 发送请求返回Post http://xxxxx.com(服务地址):http: ContentLength=1278 with Body length 0 , 立即回滚, 然后发现这个问题N久之前就存在, 差点吓尿.

问题定位

报错信息很明确 : 请求某一个接口时, Content-Length 是正常的, 但是 请求体中没有数据 , 查看发送请求的代码, 大概长这样:

httpClient := &http.Client{Timeout: 5 * time.Second}
// 演示代码, 忽略对异常的处理
req, _ := http.NewRequest("POST", url, bytes.NewReader(data))

//这里是请求其他系统,诉求是请求失败后可以重试
for requestCnt := 0; requestCnt < maxRetry; requestCnt++ {
  resp, err := httpClient.Do(req)
  if nil == err {
    break
  }
}

寥寥几行代码, 看起来正常无比, 会有啥问题?

问题产生位置 : 在for循环外实例化了client, 一次请求就成功,没有问题, 一旦进入重试逻辑, 必定会出现开头的错误.

查看源代码

阅读内置库Do函数的逻辑(net/http/client.go), 发现这段代码

.....
if resp, didTimeout, err = c.send(req, deadline); err != nil {
  // c.send() always closes req.Body
  reqBodyClosed = true
  if !deadline.IsZero() && didTimeout() {
    err = &httpError{
      err:     err.Error() + " (Client.Timeout exceeded while awaiting headers)",
      timeout: true,
    }
  }
  return nil, uerr(err)
}
....

发现请求出现异常时, 使用 uerr 处理错误信息, uerr 是函数内部定义的 匿名函数 , 其逻辑如下 :

uerr := func(err error) error {
  // the body may have been closed already by c.send()
  if !reqBodyClosed {
    req.closeBody()
  }
  var urlStr string
  if resp != nil && resp.Request != nil {
    urlStr = stripPassword(resp.Request.URL)
  } else {
    urlStr = stripPassword(req.URL)
  }
  return &url.Error{
    Op:  urlErrorOp(reqs[0].Method),
    URL: urlStr,
    Err: err,
  }
}

req.closeBody() 可以看出, 请求出现异常后, request body(io.ReadCloser) 已经被关闭, 这也解释了, 为什么重试请求之后, 请求体内没有数据. 关于 io.ReadCloser 这里不展开详细解释.

其实除了请求超时, 很多异常出现后, 都会使用 uerr 处理异常, 请求成功后, 也会关闭request body, 具体逻辑可自行查看源码

代码修正

问题定位后,修复代码逻辑可以说是最简单的一件事了, 如下 :

httpClient := &http.Client{Timeout: 5 * time.Second}

//这里是请求其他系统,诉求是请求失败后可以重试
for requestCnt := 0; requestCnt < maxRetry; requestCnt++ {
  // 每次请创建新的请求实例
  req, _ := http.NewRequest("POST", url, bytes.NewReader(data))

  resp, err := httpClient.Do(req)
  if nil == err {
    break
  }
}

en...... 一行代码的酸爽

扩展

go http请求还有另一种写法

func Post(url string, contentType string, body []byte, maxRetry int) (string, error) {
  for requestCnt := 0; requestCnt < maxRetry; requestCnt++ {
    res, err := http.Post(url, contentType, strings.NewReader(string(body)))
    if err != nil {
        return "", err
    }
    defer res.Body.Close()
    content, err := ioutil.ReadAll(res.Body)
    if err != nil {
        return "", err
    }
    return string(content), nil
  }
}

如果采用这种写法, 因为每次都是新的 io.ReadCloser实例, 就不会出现 重复读取已被读过且已经关闭的 io.ReadCloser 的情况了. 但是这个方法有一个缺陷是, 无法控制client的各种参数, 最常见的就是 超时时间 的控制.

没有绝对完美的方案,只有开发过程中自行取舍, 涉及 io.ReadCloser 多留意上下文逻辑