Skip to content

fix: bounded transparent retry for fail-fast transport failures on the main model-request path (#1688) - #1693

Merged
ranxianglei merged 1 commit into
masterfrom
2026-09-29_main-path-transport-retry
Sep 30, 2026
Merged

ranxianglei merged 1 commit into
masterfrom
2026-09-29_main-path-transport-retry

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Closes #1688

What & why

The main forward hop (src/server.ts forward()) was the only request path still doing a single attempt: one millisecond DNS/reset/refused/connect-timeout blip killed the whole round (502 {"error":"acp-proxy failure"}) — while acp-loop re-requests (src/loop/core.ts), compress loops and preflight already replayed under fetchWithRetry (#1263/#189). This makes the main path symmetric with them.

Design note (one refinement over the issue's literal proposal): the issue suggested switching the main hop to the existing fetchWithRetry. It cannot be used directly for two reasons:

  1. fetchWithRetry also retries transient HTTP responses (429/5xx/risk-control 4xx) — the issue explicitly scopes this fix to fail-fast kinds only (pre-response failures, no double-delivery risk).
  2. After its last attempt it throws UpstreamHttpError for any non-ok response. On the main path that would convert today's verbatim upstream 4xx/5xx passthrough into proxy 502s — clients would never see upstream error bodies again. A regression, not a fix.

So this adds a thin sibling, fetchWithTransportRetry in src/fetch-util.ts, implementing exactly the issue-specified semantics:

  • replays ONLY fail-fast pre-response network deaths (isFailFastUpstreamKind: proxy-reset | upstream-reset | connect-refused | connect-timeout | dns);
  • controlled by the EXISTING BILI_REPLAY_RETRY_MAX / BILI_REPLAY_RETRY_BASE_MS knobs (same budget, same exponential backoff as [Enhancement] 代理上游连接异常缺乏区分性诊断(代理 reset vs 超时 vs 客户端 abort)+ 握手层健壮性评估 #1263) — no new config surface;
  • any HTTP verdict (ok/4xx/5xx) passes through untouched; timeout/abort kinds remain non-retryable;
  • returns the full fetchWithTimeout shape (response/clearTimer/stopIdleTimer), so the existing rebuild blocks and finally-cleanup in forward() are unchanged.

Each transparent replay logs a warn line with kind/delay/attempt (e.g. [acp-secondary] upstream dns (pre-response network failure): getaddrinfo ENOTFOUND ...; retrying in 1500ms (attempt 1/3)).

Behavior change (old → new)

old new
Main model request hits a fail-fast transport death (DNS EAI_AGAIN/ENOTFOUND, pre-response reset, TCP refused, connect timeout) 1 attempt → immediate 502 {"error":"acp-proxy failure"} → round dead up to 3 attempts total by default, backoff 1500ms → 3000ms, then the same 502 if all fail
Any HTTP response from upstream (ok or error) verbatim passthrough verbatim passthrough (unchanged, never retried on this path)
Timeout/abort kinds not retried not retried (unchanged)

Zero-drift switch: BILI_REPLAY_RETRY_MAX=1 restores the old single-attempt behavior exactly (used by the two tests that pin the old shape). Intended per issue: users on weak networks get transparent recovery; anyone who wants the legacy behavior sets the env var.

Existing tests that assert the single-attempt failure shape (tests/e2e-relay-5xx-selfheal.test.ts, tests/issue1493-arm-outbound-payload.test.ts) now pin BILI_REPLAY_RETRY_MAX=1 — their assertions (first socket drop → immediate 502 → emergency-shrink arm) are preserved byte-for-byte; without the pin their attempt 2 would succeed and they'd observe a 200 instead of the asserted 502.

Tests

New tests/main-path-transport-retry.test.ts (10 tests):

  • unit (real loopback servers, no mocks): ECONNREFUSED then server starts during backoff → 200 + one retry logged; pre-response socket destroy → replayed; persistent DNS failure (*.invalid) → rejects after exactly 3 attempts with ENOTFOUND in the cause chain, retry details classified dns; 400 verdict returned untouched with exactly one upstream call (guards the UpstreamHttpError-conversion regression); BILI_REPLAY_RETRY_MAX=1 → legacy single attempt; backoff delays are [50, 100] at BASE_MS=50 (exponential); client abort during backoff exits immediately.
  • server-level through real startServer(): destroy first streaming response mid-round → client gets one 200 with full SSE (the issue's exact repro) and the relay saw exactly 2 calls; always-destroy relay → 502 /upstream request failed/ after exactly 3 calls (budget respected, no storm); 400 verdict → verbatim passthrough, 1 call.

Pre-flight on the rebased head (base = current master c0e9e65, v0.1.172):

  • npm run typecheck — clean
  • npm test — 3238 pass / 0 fail / 12 skipped (first run had one unrelated timing flake in tests/fix-437-idle-timeout.test.ts, which imports only fetchWithTimeout directly and passes 3/3 in isolation; second run fully green)
  • npm run build — success, dist-import-annotations OK
  • e2e-codex zero-token preflight (E2E_CHECK=1) — green locally (codex binary + dist present; the sandbox has no reachable Responses-compatible upstream, so the token-costing full run executes in CI, which auto-triggers because this diff touches src/server.ts)

No config surface added. No version bump. No lockfile churn.

@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-29_main-path-transport-retry (a3fcf21)

Option A — Install this exact build (recommended)

npm install -g billion-context@0.0.1679-pr.1693.0f81aa9

This exact version is immutable and reproducible. The rolling pr tag tracks the newest test build across all PRs.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pr1693.tgz
npm install -g package

Install instructions are refreshed on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 独立复核完成(未重做工作:拉取分支逐行审代码 + 本地全量验证 + CI 状态核对)。结论:可以合并(human-gate,等你合并)。

独立验证(全部在本机对 head 61bb12d 执行,base = 当前 master c0e9e65,无落后)

  • npm run typecheck — 干净
  • npm test — 3250 tests: 3238 pass / 0 fail / 12 skipped(与 PR 描述一致;本地一次通过,无 flake)
  • npm run build — 成功,dist-import-annotations OK
  • CI(GitHub API 核对 commit 61bb12d):13/13 check runs 全绿,含 test ubuntu/windows × node22/24、e2e(real codex vs deterministic fake upstream——即 PR 描述中留给 CI 的 token 级 e2e-codex 套件)、hermetic registry e2e(pi/opencode/dsh/codex-home 各 lane)、version-guard、build-artifact。PR 状态 open,mergeable_state=clean(无冲突)。

审查记录(按规范逐项)

① 原 issue 方向(#1688): 根因定位正确(主转发 hop 裸调单次 fetchWithTimeout,循环路径已有 #1263 重试,不对称属实)。PR 对 issue 字面方案(直接换 fetchWithRetry)的修正成立且必要:fetchWithRetry 会重试 HTTP 层瞬态响应(超出 fail-fast-only 范围),且末次失败后对任何非 ok 响应 throw UpstreamHttpError——会把主路径的上游 4xx/5xx 逐字透传变成代理 502。新增薄封装 fetchWithTransportRetry 是正确路径,不是绕弯。

② 回归审查(diff 触碰路径的既有行为清点):

既有行为 结论
主路径上游 HTTP verdict(ok/4xx/5xx)逐字透传 保持——fetchWithTransportRetry 对已 resolve 的响应原样返回(src/fetch-util.ts:398-400);单测钉 hits===1 + e2e 钉 400 逐字透传各 1 次调用
传输故障耗尽后的最终失败形状(catch → upstream request failed → 502) 保持——catch 块未动(src/server.ts:5340-5349);e2e 钉 /acp-proxy failure/ + /upstream request failed/。唯一变化:持续故障下最终 502 前多至多 ~4.5s 退避等待(已披露的有意变更)
timeout/abort 类不可重试 保持——classifyUpstreamFailure 中 externalAborted 优先判 client-abort(src/upstream-fail.ts:65),timeout 类归 upstream-timeout,均不在 isFailFastUpstreamKind 内,立即 rethrow
客户端断连语义 保持——backoff 期间 abort:sleep 提前 resolve → 下一 attempt 经 fetch-util.ts:145 预检 aborted signal 立即失败 → 分类 client-abort 抛出;测试钉 elapsed < 450ms
上游 idle-timer 台账(#411/#552 泄漏类) 保持——fetchWithTimeout 拒绝路径自清理(fetch-util.ts:205-208);新测试 afterEach 断言每次尝试后 _liveUpstreamTimersForTest()===0
recordUpstreamConnection 连接台账 保持——成功/失败各记一次,调用次数与旧行为相同(多次尝试时记的是末次错误,语义更准)
learn-on-failure ladder(#552/#583)、fake-completion 重试、/__bili/upstream/test 探针 未触碰——这些是响应驱动的重写体重发(另一语义类),保持单次是正确的,与本 PR 范围一致
acp-loop/preflight/compress 重试路径(fetchWithRetry) 未触碰——函数本身零改动,纯增量
两个既有测试文件的 BILI_REPLAY_RETRY_MAX=1 钉死 合理——该值是调用时读取(replayMaxAttempts 每次读 env),ESM import hoisting 不影响;其断言形状逐字保留,全量套件本地绿证实无掩盖

③ 代码审查: 新函数是 fetchWithRetry 网络失败分支的最小同构(same classify/isFailFast/backoff/sleep 调用链),无重复发明;返回类型精确镜像 fetchWithTimeout 形状,forward() 的 rebuild/finally 清理零改动;日志用 server 侧 log("warn") 约定、带 session id/kind/delay/attempt,无 console.error;无 as any/@ts-ignore。一处透明性备注(非阻塞): reset 发生在「请求已完整发出但响应未开始」时,上游可能已开始处理该模型请求,重放意味着至多一次向死连接的重复投递——这是 #1263 fail-fast 分类法的固有属性(upstream-fail.ts:85-89 注释即此语义),有界(maxAttempts 封顶),与循环路径一致,接受。

④ 测试有效性: 真 loopback http/net server、零 mock;断言全部有牙——精确尝试计数(retries.length、relay.streamingCalls()===2/3/1)、精确状态码与响应体、错误链文本(ENOTFOUND/ECONNREFUSED)、指数退避 [50,100] + elapsed 下限、timer 泄漏守卫。回归捕获力:去掉重试 → 恢复型测试挂;扩大到 HTTP verdict → hits===1/e2e 400 挂;预算失控 → streamingCalls()===3 挂;零漂移开关失效 → MAX=1 测试挂。端口全走 listen(0),DNS 用 RFC 6761 .invalid TLD,确定性成立(issue 要求的 e2e-codex 套件已由 CI 实跑绿)。

⑤ diff 整洁度: 单 commit、7 文件、432+/6-,每行都服务于本 PR 目的;无空行/重排噪音、无 lockfile churn、无版本号改动、无跑题文件。CONFIGURATION.md 中英两版同步更新(仅澄清现有旋钮的作用域扩展,未新增任何配置字段——符合配置面纪律)。

行为变更确认(old → new,与 PR 描述一致,核实无误)

  • 主模型请求遇 fail-fast 传输死亡(DNS/reset/refused/connect-timeout):1 次尝试 → 立即 502 ⇒ 默认最多 3 次总尝试,退避 1500ms→3000ms,全败后同样的 502。
  • 上游 HTTP 响应(ok/4xx/5xx):逐字透传,不变。timeout/abort 类:不重试,不变。
  • 零漂移开关:BILI_REPLAY_RETRY_MAX=1 精确恢复旧行为。

一句话中文摘要:独立复核了主模型请求路径 fail-fast 传输有界重试(仅重放响应前网络死亡、复用现有两个旋钮、HTTP verdict 一律透传不改写)的代码、回归面与测试,本地 typecheck/3238 项测试/build 全绿且 CI 13 项检查(含真实 codex e2e)全绿,未发现回归,可以合并。

(合并操作按规范由你执行:#1693 )

…e main model-request path (#1688)

The main forward hop was the only request path still doing a single attempt:
one millisecond DNS/reset/refused/connect-timeout blip killed the whole round
(502 'acp-proxy failure') while acp-loop/preflight/compress loops already
replayed under fetchWithRetry (#1263/#189).

- fetch-util: new fetchWithTransportRetry — replays ONLY pre-response
  fail-fast network deaths (isFailFastUpstreamKind) under the existing
  BILI_REPLAY_RETRY_MAX / BILI_REPLAY_RETRY_BASE_MS budget; any HTTP verdict
  (ok/4xx/5xx) passes through untouched. fetchWithRetry itself is unsuitable
  here: it also retries transient HTTP responses and throws UpstreamHttpError
  on non-ok, which would convert verbatim upstream error bodies into proxy
  502s.
- server.ts: main forward hop now uses it; each transparent replay logs a
  warn line with kind/delay/attempt.
- tests: new tests/main-path-transport-retry.test.ts (unit: refused/reset/DNS
  recovery, budget exhaustion, backoff shape, max=1 legacy switch,
  abort-during-backoff; server-level: reset mid-round -> one 200, persistent
  failure -> 502 after exactly 3 attempts, 4xx verbatim passthrough).
- e2e-relay-5xx-selfheal / issue1493-arm-outbound-payload pin
  BILI_REPLAY_RETRY_MAX=1: they assert the single-attempt failure shape.
- CONFIGURATION.md/.zh-CN: document the extended scope of the two knobs.

Behavior change (old -> new): main model-request transport failures of
fail-fast kinds previously failed the round after 1 attempt; now up to 3
attempts total (default) with 1500ms->3000ms backoff before surfacing the
same 502. BILI_REPLAY_RETRY_MAX=1 restores the old behavior exactly. No new
config surface; HTTP-level responses are never retried on this path.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

主模型请求路径缺少 fail-fast 传输重试:一次 DNS 抖动即打断整轮(acp-loop/preflight 已有 #1263 重试,主路径不对称)

1 participant