你不是被 OpenAI API「偶尔报错」打败的;你是被并发放大后的重试风暴打败的。

先说结论:429/5xx 不是“多试几次”这么简单

在 Go 服务里,429 和 5xx 一旦叠加:

  • 上游限流(429)
  • 下游抖动(5xx)
  • 你自己的超时过短 + 无抖动重试

就会形成「请求雪崩 → 重试放大 → 更大雪崩」的闭环。

最稳妥的一套组合拳是:

  1. 入口令牌桶限速(先控流)
  2. 指数退避 + 抖动重试(只对可恢复错误)
  3. 熔断器(失败窗口触发,保护上游和自己)
  4. 幂等键 + 请求预算(防重复与失控)
  5. 可观测性(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)
  • 命中则直接返回上次结果

生产排障清单(建议直接复制)

  1. 查看过去 15 分钟:429_rate, 5xx_rate, retry_attempt_avg
  2. 429_rate > 5%:先降发令牌桶速率 20%
  3. 5xx_rate > 10%:开启熔断保护,暂停非核心流量
  4. 检查是否无抖动重试(最常见)
  5. 校验 Retry-After 是否被忽略
  6. 检查批处理任务是否抢占在线配额

最小可用中间件骨架

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 服务才算真正进了生产态。