opencodex 上游请求重试与错误重构:共享 retry 层设计解析
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
导读
本文围绕 opencodex 中一次关键的"共享上游重试/错误重构"(shared upstream retry/error refactor,即 PR #98)展开,剖析该代理在向上游提供商(ChatGPT、Claude、Gemini 等)发起 fetch 时,如何在不破坏幂等语义的前提下安全地处理连接重置与瞬时 5xx。读完本文,你将掌握 opencodex 的重试边界划分、发送预算(send budget)机制、Retry-After处理策略,以及这些设计在源码与测试中的落地形态。
背景:为什么需要一个"共享"的重试层
在 opencodex 中,代理进程会向上游托管服务发起大量模型请求。重构前的痛点主要有两个:
- Bun fetch 连接池复用半关闭 socket:
chatgpt.com(Cloudflare 托管)会在服务端主动关闭空闲的 keep-alive 连接,而 Bun 的 fetch 连接池可能复用到这条半关闭连接,导致请求在响应头到达前就失败(The socket connection was closed unexpectedly/ECONNRESET)。 - 重试策略散落各处、边界模糊:不同适配器各自实现重试,容易出现"请求已经被上游执行,却因为传输层错误被再次发送"的重复计费问题——模型 POST 请求天然非幂等。
PR #98 的价值在于把重试/错误处理收敛为一个共享的 leaf 模块(src/lib/upstream-retry.ts),并配套共享的 HTTP 错误归一化工具(src/adapters/upstream-http-error.ts)。该模块被kiro-retry等适配器层复用,因此被要求MUST stay a leaf module:不导入server.ts或任何适配器,保证依赖方向单向、无循环。
重试边界:什么可以重试,什么绝不能重试
upstream-retry.ts的开篇注释给出了整个模块的哲学:"响应头到达前的拒绝,并不能证明上游没有处理该请求"。因此模块刻意收窄重试范围:
- 可重试:明确可安全重放(
replaySafe)的操作上的连接重置(ECONNRESET/EPIPE),以及"瞬时 5xx"状态码; - 不可重试:超时(
TimeoutError)、主动中止(AbortError)、ECONNREFUSED、DNS/TLS 失败,以及以Response形式返回的 HTTP 错误状态(它们从不被 throw); - 响应头已到达之后的流中断不在本模块范围内,因为响应已经 resolve,这一阶段由 Responses 传输层通过共享的 resend gate 处理。
连接重置的识别集中在isConnectionResetError(src/lib/upstream-retry.ts):
export function isConnectionResetError(err: unknown): boolean { if (!(err instanceof Error)) return false; // Aborts and timeouts are caller decisions / honest failures — never retryable. if (err.name === "AbortError" || err.name === "TimeoutError") return false; const code = (err as { code?: unknown }).code; if (code === "ECONNRESET" || code === "EPIPE") return true; const msg = err.message.toLowerCase(); return msg.includes("socket connection was closed unexpectedly") || msg.includes("connection reset by peer"); }注意一个细节:即使错误带有ECONNRESET的code,只要其name是TimeoutError/AbortError,也会被判定为不可重试——重试决策以错误语义为准,而非以错误码为准。这一行为在 tests/lib/upstream-retry.test.ts 中有专门的测试用例覆盖。
不可重放响应标记体系:拒绝"自动补发"
对于"上游可能已经执行"的模糊重置,模块提供了一套不可重放标记机制,贯穿进程内与跨层重包装两个场景:
- 进程内标记:
nonReplayableResponses与replayRefusalResponses两个WeakSet<Response>,分别回答"绝不能再次发送"与"上游从未返回过这个响应"两个问题。后者的存在是为了防止配额记录器把合成的 429 误当成真实限流、或客户端误读Retry-After。 - 跨层标记:三个结构化错误码,能在响应体被重包装(如 combo 失败消费重新解析 JSON)后继续存活:
export const UPSTREAM_NO_RESPONSE_CODE = "upstream_no_response"; export const UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE = "upstream_closed_before_response"; export const UPSTREAM_RESET_REPLAY_REFUSED_CODE = "upstream_reset_replay_refused";当代理拒绝重放模糊重置时,返回429状态并附加x-should-retry: false头(REPLAY_REFUSAL_STATUS = 429,src/lib/upstream-retry.ts)。选择 429 而非 5xx 是刻意的:Codex 客户端配置为retry_429: false、retry_5xx: true(四轮尝试),若返回 5xx 会放大重复发送。而x-should-retry: false是 Stainless 生成的客户端(openai/anthropic的 Python 与 Node 版本)在读取状态码表之前就会校验的信号,即便 429 不带Retry-After,客户端也不会自动补发。
applyReplayRefusalClientHeaders把"删除retry-after"与"设置x-should-retry: false"捆绑为一个原子操作——两者必须同时成立,否则客户端仍可能把"可能已执行"的 turn 再次发出。
发送预算:把重试次数变成一个真实的上游发送上限
多层重试最容易出的问题就是相乘爆炸:外层 transient 重试 3 轮、每轮内部又独立重试 3 次连接重置,一次逻辑请求最多发出 9 个物理发送。重构的核心修复是:
attempts是"总发送预算"而非"每层重试次数":fetchWithTransientRetry把remaining()(预算减去已发送数)转发给内部的fetchWithResetRetry,两层共用同一个计数;- 发送前计数:
countedFetch在await之前sent += 1,因为"发送但被拒绝"依然消耗预算——只统计成功会让重置风暴无限循环; - 零就是零:
normalizeSendAttempts移除了旧的Math.max(1, ...)下限,预算耗尽直接抛SendBudgetExhaustedError,而不是再"免费送"一次发送; - 单次上报:内部的 reset 层不再转发
onSendsConsumed,避免同一物理发送被上报两次导致四发送上限变成两发送上限。
预算跨调用帧共享:TransientSendBudget是可变持有者({ used: number }),因为 combo 父级会为每个目标发起独立的子 turn,若计数器局限在单帧内,一个逻辑请求就能对每个目标发出三次上游请求(对应 issue #4546)。
此外,claimAmbiguousResend回调让逻辑请求持有一次操作者授权的"模糊重置替换额度",由重置层与 post-header 协议 gate 共用同一计数器——三处(rotation 腿、refresh 腿、同目标 429 腿)持有同一 turn,就不能各自消耗一次替换额度。
瞬时 5xx 重试层:状态表、慢速失败与 Retry-After
fetchWithTransientRetry处理"响应头已收到但状态为瞬时错误"的场景(仅 pre-stream)。瞬时状态表定义在isTransientUpstreamStatus(src/lib/upstream-retry.ts):
500, 502, 503, 504, 520, 521, 522其中 500 遵循 OpenAI SDK 默认(>=500 自动重试);507 虽然在 48 小时日志中观测到,但被刻意排除(存储类错误而非网关瞬时错误)。
三个关键决策值得注意:
- 慢速失败直接返回:
TRANSIENT_RETRY_SLOW_ATTEMPT_MS = 15_000——一次尝试慢于 15 秒("slow 502"事故形态,观测到 191 秒)时不再重试,避免在客户端超时之后重复压榨上游; Retry-After是下界而非可压缩值:retryAfterIsLowerBound: true时,指令等待时间原样返回(Math.max(retryAfter, jittered)),本地指数退避上限无权缩短提供商的指令;- 等待截止时间是调用方决策:
retryAfterCeilingMs默认RETRY_AFTER_CEILING_MS = 60_000(与同目标 429 上限一致)。它是"截止时间"而非"钳位器":指令超过截止时间就带着完整的Retry-After结束调用,绝不提前发送——因为提前发送是"提供商已经说过会拒绝的请求"。
退避计算在retryBackoffDelayMs(src/lib/upstream-retry.ts):指数退避baseDelayMs * 2^attempt加上 0.8–1.2 的抖动(jitter),优先遵循响应头中的Retry-After(支持秒数与 HTTP-date 两种格式),未提供时才回退到本地指数退避。
连接重置腿的退避参数为:初始 150ms、上限 1s、最多 3 次尝试(RESET_RETRY_MAX_ATTEMPTS = 3,注释说明连接池可能持有不止一条陈旧 socket);transient 层为初始 400ms、上限 5s、最多 3 次总发送(TRANSIENT_RETRY_MAX_ATTEMPTS = 3)。
保留上游证据:UpstreamRetryEvidenceError
当重试耗尽且此前尝试已产生"凭证可见的证据"(transient 5xx 响应,或请求已被读取后的连接重置),终端拒绝会被包装为UpstreamRetryEvidenceError(源自 PR #966,作者 Yuxin-Qiao,见 src/lib/upstream-retry.ts):
- 原始拒绝保留在
cause中,错误码与消息仍可检查; - 混合 5xx/重置导致拒绝时,不得降级为账号无关的 pre-connection 类错误(issue #914 评审结论),因为证据表明主机与凭证路径已被触达;
SendBudgetExhaustedError是例外:预算拒绝不是上游证据,必须保持可识别,不能被错误归类。
共享的 HTTP 错误归一化与脱敏
与重试层配套的共享错误工具在 src/adapters/upstream-http-error.ts:
normalizeUpstreamHttpErrorResponse:重建错误响应时剥离content-encoding与content-length(避免已编码/长度不符的载荷误导下游),但保留retry-after、x-provider-error等安全头部;sanitizeUpstreamErrorText:对错误文本做脱敏,抹掉Authorization: Bearer ...这类凭证与绝对路径(macOS/Users/...与 WindowsC:\...风格均覆盖),防止上游错误细节泄入客户端日志或 UI。
对应测试在 tests/adapters/upstream-http-error.test.ts,覆盖脱敏与头部剥离行为。
测试与落地验证
- 共享重试模块的测试在 tests/lib/upstream-retry.test.ts(647 行),覆盖:
isConnectionResetError对 Bun 重置错误形态(含消息匹配、code属性缺失、非 Error 输入)的分类;sleepWithHeartbeats对非正数/NaN 心跳间隔的钳位(防止零步长死循环);预算耗尽与不可重放标记的断言; - HTTP 错误归一化测试在 tests/adapters/upstream-http-error.test.ts。
落地记录可参考 devlog/_fin/260712_pr_batch_landing/000_plan.md:PR #98(commit6bb3aecf)在 #96、#97 之后以 merge-only 方式合入 main(merge SHA576d8c45),评审结论为 MERGE-WITH-NITS(行为等价,nit 为适配器级回归测试,延期处理)。合入时验证命令为bun test tests/upstream-retry.test.ts tests/upstream-http-error.test.ts(当前仓库中对应文件位于tests/lib/与tests/adapters/),随后跑完整bun test,验收标准为全量套件 0 失败。
总结
opencodex 的共享上游重试/错误重构提供了一套可复用的范式:以幂等语义为第一原则划定重试边界,用发送预算把多层重试压缩为真实发送上限,用标记体系阻止对"可能已执行"请求的自动补发。任何需要直连上游 fetch 的适配器(如kiro-retry)都可以直接复用 src/lib/upstream-retry.ts,而无需各自重新实现退避、Retry-After与证据保留逻辑。
【免费下载链接】opencodexUniversal provider proxy for OpenAI Codex & Claude Code — use any LLM (Claude, Gemini, Grok, DeepSeek, Ollama…) with Codex CLI, App, SDK, and Claude Code项目地址: https://gitcode.com/gh_mirrors/ope/opencodex
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考