你不是被 OpenAI API「偶尔报错」打败的;你是被并发放大后的重试风暴打败的。
先说结论:429/5xx 不是“多试几次”这么简单
在 Go 服务里,429 和 5xx 一旦叠加:
- 上游限流(429)
- 下游抖动(5xx)
- 你自己的超时过短 + 无抖动重试
就会形成「请求雪崩 → 重试放大 → 更大雪崩」的闭环。
最稳妥的一套组合拳是:
- 入口令牌桶限速(先控流)
- 指数退避 + 抖动重试(只对可恢复错误)
- 熔断器(失败窗口触发,保护上游和自己)
- 幂等键 + 请求预算(防重复与失控)
- 可观测性(429 比例、重试层级、熔断状态必须可看)
常见错误架构(反例)
反例 1:无限并发 + 全量重试
- worker pool 没上限,QPS 随流量线性暴涨
- 所有错误都重试 3 次
- 重试无退避,立刻重放
结果:本来 1000 req/min,失败后放大到 3000+,把上游直接打穿。
反例 2:超时预算错配
http.Client.Timeout=10s- 业务整体 SLA 也只给 10s
- 还加了 3 次重试
结果:每次重试都在“透支时间”,最终超时率更高。
可落地的 Go 基线实现
1)入口令牌桶:先把并发打下来
import "golang.org/x/time/rate"
var limiter = rate.NewLimiter(rate.Limit(8), 16) // 8 rps, burst 16
func allow(ctx context.Context) error {
return limiter.Wait(ctx)
}
建议:
- 在线请求:按用户级 + 全局双层限流
- 批处理:独立队列,别和在线流量抢额度
2)指数退避 + 抖动:只重试“该重试”的错误
func backoff(attempt int) time.Duration {
base := 200 * time.Millisecond
max := 5 * time.Second
d := base * time.Duration(1<<attempt) // 200ms, 400ms, 800ms...
if d > max { d = max }
jitter := time.Duration(rand.Int63n(int64(d / 2)))
return d/2 + jitter
}
func retryable(status int) bool {
if status == 429 { return true }
if status >= 500 && status <= 599 { return true }
return false
}
关键点:
- 4xx 大多数不要重试(尤其 400/401/403)
- 429 遇到
Retry-After优先遵守 - 单请求最大重试建议 2~3 次,别贪
3)熔断:失败窗口触发,快速失败保命
可以用 sony/gobreaker,核心配置:
- 最近 N 秒失败率 > 阈值(如 50%)触发 Open
- Open 持续 10~30s
- Half-Open 只放少量探测请求
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "openai-responses",
Interval: 30 * time.Second,
Timeout: 20 * time.Second,
ReadyToTrip: func(c gobreaker.Counts) bool {
total := c.Requests
if total < 20 { return false }
failRate := float64(c.TotalFailures) / float64(total)
return failRate >= 0.5
},
})
4)请求预算:避免重试把 SLA 吃光
给每个请求一个总预算,比如 8 秒:
- 首次请求最多 3.5s
- 重试最多 2 次,每次 1.5s
- 留 1.5s 给网络尾延迟和序列化
预算不是建议,是硬约束。
5)幂等键:避免重复扣费/重复写入
对可能重放的请求(尤其工具调用、批处理任务),用幂等键绑定业务主键:
idempotency_key = sha256(user_id + task_id + payload_hash)- 结果写缓存(短 TTL)
- 命中则直接返回上次结果
生产排障清单(建议直接复制)
- 查看过去 15 分钟:
429_rate,5xx_rate,retry_attempt_avg - 若
429_rate > 5%:先降发令牌桶速率 20% - 若
5xx_rate > 10%:开启熔断保护,暂停非核心流量 - 检查是否无抖动重试(最常见)
- 校验
Retry-After是否被忽略 - 检查批处理任务是否抢占在线配额
最小可用中间件骨架
func CallOpenAI(ctx context.Context, req *http.Request) (*http.Response, error) {
if err := allow(ctx); err != nil { return nil, err }
var lastErr error
for attempt := 0; attempt <= 2; attempt++ {
resp, err := cb.Execute(func() (interface{}, error) {
cctx, cancel := context.WithTimeout(ctx, 3500*time.Millisecond)
defer cancel()
return client.Do(req.WithContext(cctx))
})
if err == nil {
r := resp.(*http.Response)
if !retryable(r.StatusCode) { return r, nil }
lastErr = fmt.Errorf("retryable status=%d", r.StatusCode)
} else {
lastErr = err
}
if attempt == 2 { break }
time.Sleep(backoff(attempt))
}
return nil, lastErr
}
总结
遇到 429/5xx 风暴,别先加机器,先修控制面。
控流(令牌桶)→ 退避(指数+抖动)→ 熔断(失败保护)→ 预算(SLA边界),这四件事做对了,你的 Go + OpenAI 服务才算真正进了生产态。