实现一个带重试与退避的 HTTP 客户端

调用第三方接口,网络抖一下很正常。但"失败就重试"如果写得随意,反而会把对方打挂、把自己的线程占满。一个可靠的重试客户端要同时满足:会退让、有边界、可取消。 最容易踩的坑 for i := 0; i < 3; i++ { resp,

调用第三方接口,网络抖一下很正常。但”失败就重试”如果写得随意,反而会把对方打挂、把自己的线程占满。一个可靠的重试客户端要同时满足:会退让、有边界、可取消。

最容易踩的坑

for i := 0; i < 3; i++ {
    resp, err := client.Do(req)
    if err == nil {
        return resp
    }
    time.Sleep(time.Second)   // 错误:固定间隔 + 不区分错误类型
}

三个问题:固定间隔会造成同步重试风暴;不判断错误类型,4xx 也重试纯属浪费;没有超时控制,一个慢接口能拖垮整个服务。

判断哪些错误值得重试

| 状态码 | 是否重试 | 原因 | |—|—|—| | 408 超时 | 是 | 请求未被处理 | | 429 限流 | 是 | 需读 Retry-After | | 500 / 502 / 503 / 504 | 是 | 服务端临时故障 | | 4xx(除 408/429) | 否 | 请求本身有问题,重试还是失败 |

网络层错误(连接被拒、超时)可以重试,但DNS 解析失败通常是配置问题,重试无意义。

指数退避加抖动

func backoff(attempt int, base, max time.Duration) time.Duration {
    d := base << attempt                 // 100ms, 200ms, 400ms
    if d > max || d <= 0 {
        d = max
    }
    // 加 ±25% 抖动,避免多实例同时重试
    jitter := time.Duration(rand.Int63n(int64(d) / 2))
    return d/2 + jitter
}

抖动是必须的。没有抖动时,一批客户端会在同一时刻一起重试,形成二次洪峰。

完整结构

type RetryClient struct {
    http    *http.Client
    retries int
}

func (c *RetryClient) Do(req *http.Request) (*http.Response, error) {
    ctx := req.Context()
    var lastErr error
    for i := 0; i <= c.retries; i++ {
        if i > 0 {
            select {
            case <-ctx.Done():
                return nil, ctx.Err()      // 上游已取消,立刻退出
            case <-time.After(backoff(i-1, 100*time.Millisecond, 2*time.Second)):
            }
        }
        resp, err := c.http.Do(req)
        if err == nil && !shouldRetry(resp.StatusCode) {
            return resp, nil
        }
        if resp != nil {
            resp.Body.Close()               // 必须关,否则连接泄漏
            lastErr = fmt.Errorf("status %d", resp.StatusCode)
            continue
        }
        lastErr = err
    }
    return nil, fmt.Errorf("重试 %d 次后仍失败: %w", c.retries, lastErr)
}

注意 resp.Body.Close()——忘记关 body 是连接泄漏的头号原因,表现为跑几小时后端口耗尽。

与超时配合

http.Client.Timeout 是单次请求的总时限,不含重试等待。三次重试加两轮退避,最坏情况要按 6 倍估算。如果调用方有整体 deadline,用 context.WithTimeout 包住整个 Do,上面的 ctx.Done() 分支会保证及时退出。

小结

  • 只重试幂等请求和临时性错误
  • 指数退避 + 抖动,别用固定间隔
  • 必须支持 context 取消
  • 每次失败都要 Body.Close()