fix(utils): clamp retry delays at the timer boundary - #3704
Conversation
Normalize caller-provided retry delays immediately before both scheduler boundaries and cap them at the signed 32-bit timer ceiling. Successor to Yeachan-Heo#3672; preserves the original contributor work and maintainer-requested overflow coverage. Co-authored-by: Oreochococukie <102867217+Oreochococukie@users.noreply.github.com> Co-authored-by: Gajae Code Contributor <contributor@users.noreply.github.com>
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Exact-head red-team review for ccad8ed.
Verdict: MERGE_READY (code)
Change summary
packages/utils/src/fetch-retry.ts introduces normalizeRetryDelay(delayMs, maxDelayMs) which caps every scheduled retry delay at min(delayMs, maxDelayMs, MAX_TIMER_DELAY_MS) and returns 0 for negative/NaN/Infinity-after-cap values. This prevents large exponential backoffs or server Retry-After hints from overflowing the signed 32-bit timer ceiling (2^31-1 ms), which Node/Bun coerce to 1ms — producing an immediate retry instead of the intended long delay.
Red-team
- MAX_TIMER_DELAY_MS = 2_147_483_647 (2^31-1): correct for signed 32-bit. Bun and Node both use this boundary.
- normalizeRetryDelay clamps to [0, cap]: NaN → 0, negative → 0, +Infinity → maxDelayMs (typically 60_000). Correct.
- resolveDefaultDelay no longer applies maxDelayMs internally — capping is centralized in normalizeRetryDelay. Clean separation.
- All three code paths (default delay, server hint, network error) now route through normalizeRetryDelay. Complete.
- 20 new test cases covering negative, NaN, Infinity, timer-ceiling overflow, server hints, and both response/network error paths. Thorough.
Verification
- bun test fetch-retry.test.ts — 39 pass, 110 assertions
- biome check — clean
- tsc --noEmit — no errors
- git merge-tree vs dev — 0 conflict markers (auto-merges cleanly; DIRTY is stale base)
- CI — 16 pass, 5 skip
Mergeability note: GitHub reports CONFLICTING/DIRTY (stale base b40bc27). Confirmed auto-merges cleanly against current dev 57577ac. Rebase to refresh.
Review receipt: gajae.pr-review-verdict.v1 merge-ready sha256:ccad8ed1f82ccab0b5675aaebd26444895e1c095 reviewer:Yeachan-Heo evidence:terminal-exact-head-red-team
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Exact-head GPT-heavy adversarial batch review for ccad8ed1f82ccab0b5675aaebd26444895e1c095.
Recorded PR base: b40bc271502a0802e212e5538c5d0e8436643029. Current dev: 732856b3ccb3fade6e9fbc17908a4fbca5a7682f (78 commits ahead).
Terminal verdict: REQUEST_CHANGES
Code review result
I found no timer-arithmetic or cancellation defect in the exact source change:
- Both scheduler boundaries normalize through
Math.min(delayMs, maxDelayMs, 2_147_483_647)and map negative/NaN post-cap values to zero. - Huge finite values,
+Infinity, response delays, network-error delays, and server hints are capped beforescheduler.wait. - Server-hint fail-fast ordering, retry counts, fire-and-forget response-body cancellation, returned-body ownership, scheduler rejection, and abort-signal propagation remain intact.
- Local exact-head test:
bun test packages/utils/test/fetch-retry.test.ts— 39 pass, 0 fail. - Real Bun probes for a
2^31configured delay and a2^31Retry-After hint stayed on attempt 1 untilAbortErrorat ~26 ms rather than overflowing into an immediate retry. An implicit exponential schedule reached tail delays[1048576000,2097152000,2147483647].
Non-blocking coverage gap: the committed regression matrix does not exercise the implicit defaultDelayMs === undefined exponential overflow branch; it only supplies explicit oversized values/hints.
Blocking current-base state
GitHub currently reports mergeable=false, mergeable_state=dirty, and rebaseable=false. Current dev has not changed fetch-retry.ts or its test since the recorded base, but it has changed packages/utils/CHANGELOG.md; the current-base merge has a real overlap there. This exact head therefore cannot merge.
The Aug 1 automation is green for the stale head/base (16 passing and 5 skipped checks, including the targeted fetch-retry test), and the prior owner approval is attached to this exact head, but that review assessed an older dev (57577acd...). Neither is evidence for a resolved merge result on current dev 732856b3.... Resolve/rebase the changelog against current dev; the resulting new head needs current-base CI and a new exact-head terminal review.
Automated feedback: no issue comments, inline review comments, or review threads; the only submitted review before this one was the prior exact-head owner approval.
Signed terminal receipt: gajae.pr-review-verdict.v1 request-changes sha256:ccad8ed1f82ccab0b5675aaebd26444895e1c095 reviewer:Yeachan-Heo evidence:gpt-heavy-adversarial-batch-2026-08-05-current-dev-conflict
|
Triage note (maintainer batch, 2026-08-05): the clamp is code-verified (both prior red-team reviews found no timer-arithmetic or cancellation defect) and all checks are green on head |
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Verdict: Request changes — the code change is sound, but the PR cannot merge into current dev and its changelog entry would land in a released section.
Blocker: changelog entry + stale base (merge-blocking).
- GitHub computes this PR as
mergeable: false / mergeable_state: "dirty"against currentdev(0fdbb5e80). The base branch has advanced well past the PR'sb40bc2715base:devreleased0.12.8(2026-08-02) and0.12.10/0.12.11(2026-08-03), which folded the PR's anchor line (the #3696 macOS entry) into the released## [0.12.8]section. - Simulated 3-way merge of
ccad8edinto currentdev: thefetchWithRetrychangelog entry lands inside the released## [0.12.8] - 2026-08-02→### Fixedblock (after the released macOS and postmortem entries), leaving the current empty## [Unreleased]section untouched. Per the repo changelog contract, entries must go under## [Unreleased]and released sections are never edited; this would also misattribute the fix to 0.12.8 in the release notes. - Required fix: merge/rebase current
devinto the PR branch and move the entry to the top## [Unreleased]section, then re-push so the dirty state clears. No code changes are needed.
Verified solid (no changes requested there):
- The fix is correct:
resolveDefaultDelayreturns raw values andnormalizeRetryDelaycaps every scheduled delay atmin(delay, maxDelayMs, 2_147_483_647)at both scheduler boundaries, mapping negative/non-finite to0. The premise is real —Bun.sleep/node:timers/promiseswait coerces delays above the signed 32-bit ceiling to ~1ms (retry storm); the cap removes that. - Behavior preserved for all well-formed inputs (finite, non-negative, ≤ cap); only previously-pathological values (negative/NaN/±Infinity/overflow) change, to a sane
0or capped delay. The fail-fasthint > maxDelayMscontract is unchanged. - Tests: 39/39 pass at head (24 pre-existing + 15 new); the new cases genuinely catch the bug — 21 assertions fail against the old implementation (verified) and pass with the new one.
- CI at head is green: 21/21 check runs (success or path-based skip), including the targeted
fetch-retry.test.tsjob and gjc-state-gates. - No generated artifacts touched; private helpers only; no new dependencies; no security/privacy surface change (capping hostile
Retry-Aftersleeps is a hardening).
What
2_147_483_647ms so Node/Bun timers cannot overflow into an immediate retry.Why
This is the current-
devsuccessor to #3672 and the corrected successor to #3612. #3672's product checks were green, but it was closed on a stale base and inherited integration guard. The owner explicitly requested a current-dev successor preserving the timer ceiling and deterministic overflow/server-hint coverage.The original contributor attribution is preserved in the commit.
Testing
bun test packages/utils/test/fetch-retry.test.ts— 39 pass, 110 assertionsbun --cwd=packages/utils run checkgit diff --check upstream/dev...HEADGJC verdict
devbun checkpasses