context.withtimeout 不支持自动重试,必须手动循环并每次新建 context 与 cancel 函数,否则会导致漏重试、goroutine 泄漏或卡死;超时判断应使用 errors.is(err, context.deadlineexceeded),等待需响应 cancel,http 重试须正确注入 ctx 并避免 client.timeout 与 context 超时冲突。

不能靠 context.WithTimeout 自动重试,必须手动循环 + 每次新建 context + 可中断等待;否则不是漏重试,就是 goroutine 泄漏或卡死。
为什么 context.WithTimeout 不重试?
它只给单次执行加“死线”,超时就发 ctx.Done() 并返回 context.DeadlineExceeded,不会多跑第二遍。常见错误是把 context.WithTimeout 写在 for 循环外,导致第二次重试拿到的 ctx 已是 context.Canceled,直接失败。
- 每次重试前必须调用
ctx, cancel := context.WithTimeout(parentCtx, timeoutPerRetry) - 每次重试结束后必须显式调用
cancel(),哪怕成功或出错,否则泄漏 goroutine - 判断超时要用
errors.Is(err, context.DeadlineExceeded),别用==,因为 error 可能被包装过
如何让重试等待可被 context 中断?
time.Sleep 不响应取消信号,但生产中又不能裸写 select + time.After —— 它会阻塞等待,忽略上级 cancel。正确做法是:先检查 ctx.Err(),再决定是否 sleep,且 sleep 本身不参与 select 主逻辑。
- 退避前加判断:
if ctx.Err() != nil { return err } - 用
time.Sleep(backoffDuration)最简洁;若需严格响应 cancel,改用select等待time.After或ctx.Done(),但注意不要漏掉对ctx.Done()的处理 - 指数退避推荐起始 100ms,上限 1s:
backoffDuration = time.Duration(math.Pow(2, float64(attempt))) * time.Millisecond
HTTP 请求重试时,http.Client.Timeout 和 context 超时怎么共存?
二者语义不同、触发时机不同,混用反而容易失控。Client 级超时无法中断 DNS 解析或 TLS 握手,而 context 能覆盖全流程;但 client.Timeout 是兜底,防止 context 忘设。
- 优先用
context.WithTimeout控制请求全生命周期,http.Client.Timeout设为 0 或略大于单次重试 timeout - 务必把 ctx 注入 request:
req = req.WithContext(ctx),否则 http.Client 不感知 cancel - 别设
client.Timeout同时又设短于它的 context 超时——前者会抢先返回net/http: request canceled,掩盖真实原因
用 github.com/cenkalti/backoff/v4 时最容易踩的坑
这个库省去了手算退避和计数,但 context 传播和错误判断仍需你负责。它不会自动捕获 context.Canceled,也不会帮你新建 request 或重置 body。
- 必须用
backoff.WithContext(backoff.NewExponentialBackOff(), parentCtx)包裹策略,否则 cancel 传不进 retry 内部 - 你的 operation 函数里仍要检查
ctx.Err()并返回对应 error(比如return ctx.Err()),否则库不知道该停 - operation 中每次都要新建
http.Request,不能复用上一轮的 req —— body 可能已被读取过
真正难的不是写出来,而是确保每次重试都干净地派生新 context、新 request、新 timeout,并在退出路径上无遗漏地调用 cancel()。漏掉任意一个,上线后都可能表现为 goroutine 暴涨或请求卡死不动。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











