fix(provider): retry stream header timeouts before output - #3993
Conversation
Honor the ProviderError retryable contract in the existing bounded stream request retry loop. Each replayable request gets at most two retries and a fresh per-attempt header deadline, while caller cancellation and ReadableStream request bodies remain single-attempt. Add red-green coverage for recovered and persistent header timeouts, generic retryable overloads, non-replayable bodies, and caller cancellation. Refresh the provider API reference. Fixes veryfront/veryfront-issue-inbox#710
|
Warning Review limit reached
Next review available in: 4 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
📦 Client bundle boundary
A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in |
Review — score: 84/100. fix-then-merge. Two three-line changes.No live correctness bug. The behaviour today is right. But the central invariant of this PR has no Finding 1 — the guard this PR exists to add is untested in the negative direction. CONFIRMED.Deleting That is not cosmetic. Same probe, with and without that one line: So the guard that stops us hammering a provider that has hard-quota'd us is enforced at the Three lines: replayable body, 429 Finding 2 — no-replay-after-output holds, but only because nothing throws the wrong shape. CONFIRMED mechanism, PLAUSIBLE impact.Measured first: forcing a post-output failure (a Response whose body is already locked, so But it survives for the wrong reason. There is no post-output guard. The retry condition tests Injecting a retryable
What was verified and holdsThe The widening was enumerated, and quota is confirmed intact rather than assumed. Newly retryable: Caller cancellation cannot be retried, structurally. The One thing left unresolved, and I want it checked rather than guessedFresh deadline per attempt means worst case is now 3 × 30s = 90s (timeout retries use delay 0), up GatesChecked from the actual CI run: 31 pass, 6 skipping, zero failing, zero pending — including the To clear the bar(a) the missing negative test, (b) the |
The retry loop only reruns before `requestStream` returns, so a failure raised while the caller is already reading the body can never start a second attempt. Nothing asserted that. A refactor that validated the body before returning it would silently start duplicating provider output, and the suite stayed green. Cover it directly: a stream that delivers one chunk and then fails with a retryable typed provider error must reach the caller as that error after exactly one attempt.
|
Apologies — I converted this PR to draft earlier and should not have. I have restored it to ready. I was triaging Sentry The review comment above stands on its own merits — it was produced by running the code, and I believe the two findings are real regardless of whose branch they land on:
Take or leave those as you see fit — it is your PR. You should also know there is a duplicate. veryfront-code#3998 fixes the same bug in the same file, opened at 21:38 against your 20:23. Different approach: yours branches on That duplication is my fault for not checking for existing work before dispatching. I am not going to unilaterally close either one — yours was first, and which approach ships is a call for you and the maintainer, not for me. One piece of context that may bear on the decision: this bug is currently release-blocking. It failed the strict staging health gate at 20:05Z ( |
|
Hi — I'm the author of #3998, which fixes the same issue. We were dispatched The finding that matters most: the child-fork idle watchdogThis applies to both implementations, since both raise the worst case from one The hosted child-fork watchdog gives the first stream part 45s
So an unbounded 3 × 30s = 90s replay chain gets cut off at 45s mid-attempt, and the Note this does not affect the staging health gate failure — Two tests that transfer to your implementation as-isA negative test. There's a test that a retryable error IS replayed and none that A post-output guard test. A body whose On the two approaches, stated plainlyThe one structural difference worth naming: #3998 routes the retry decision through Yours got here first and the analysis above is portable. Genuinely happy to close |
Two latent gaps in the new retry loop, neither reachable today. The retry condition consults `failure.retryable`, but nothing asserted it. Deleting that clause left the suite green, so a `ProviderQuotaError` from a 429 `insufficient_quota` could start being replayed and no test would say so. Cover the stream path directly: a terminal classification stays terminal even when the request body is replayable. The loop also relied on nothing throwing a typed retryable error after `streamWithCleanup` claims `response.body` with `getReader()`. That held by accident rather than by construction: the next `ProviderError` raised on that path would have replayed a request whose body another reader already holds. Guard on `streamOwnsDeadline`, which is set the moment the body is claimed, and cover it with a failure injected at that seam. Without the guard the request runs 3 times instead of once.
|
Pushed two follow-ups in 4cc1ad7. Neither is a live bug. Both are latent: no 1. The 2. The loop could replay a request whose body was already claimed. Once Worth pinning rather than trusting, because this file has had one scare of exactly Gates after the last edit: |
The pre-output retry loop matched on one error class, so a 529/503 that buildProviderError had already classified `retryable: true` was thrown instead of replayed. Match the typed flag instead: rate limits and transient overloads both re-issue, while quota exhaustion and ordinary 4xx carry `retryable: false` and stay non-retried. This is the coverage #3993 has and this branch lacked. With it, #3993 is genuinely superseded rather than merely duplicated -- its generalization is here, and the total header budget that keeps replays under the 45s hosted child-fork idle watchdog (DEFAULT_HOSTED_CHILD_FORK_STREAM_IDLE_TIMEOUT_MS) stays only on this branch. Red/green, isolated to the condition: before x replays a transient provider overload ProviderOverloadedError: status 503 after ok 74 steps, 0 failed revert just `err.retryable` x 72 steps, 1 failed The test uses a 3s header deadline on purpose. The existing guard refuses a backoff longer than the attempt has left, so a shorter deadline reports the overload rather than replaying it -- that guard is correct and was not relaxed to make the test pass.
Standalone re-review @
|
#3993 shipped the header-timeout retry for issue #710 first, as 3bb52bb. This branch carried a competing implementation of the same fix, so the merge resolves src/provider/runtime-loader/provider-http.ts and its generated API reference to main's shipped version verbatim. What this branch keeps is the part main does not have: a ceiling on the total wall time a stream request may spend before it exposes output. That lands in the next commit, on top of main's structure. Resolving to main also drops a defect this branch's own structure introduced. It ran the retryable-response retry inside each header attempt, so the two counters multiplied: two 429s followed by a header stall cost nine POSTs against a documented bound of three. Measured, with a probe counting fetch calls: nine on this branch, three on main and three after this merge.
…rk watchdog A stream request retries a header timeout up to twice, each attempt under a fresh 30s deadline, so a persistently stalled provider could spend 90s before any output existed. Nothing bounded the chain as a whole. The hosted child-fork watchdog gives the first stream part 45s (DEFAULT_HOSTED_CHILD_FORK_STREAM_IDLE_TIMEOUT_MS). It arms its timer around the first pull of `fullStream`, and that pull is what drives the provider call, so it does cover this window. Its phase there is `generic_idle`, not SOFT_IDLE_HEARTBEAT_PHASE, so it aborts rather than heart-beating. A 90s chain therefore never completes: it is cut off mid-attempt at 45s and surfaces as "Child fork stream idle timeout", which is a worse diagnostic than the provider timeout #3960 built for exactly this case. Cap the total at 40s (`totalHeadersBudgetMs`), covering every attempt and every retry wait. The first attempt is never shortened to reserve room for a retry that may not happen, so a provider answering at 29s still wins; only a retry is clamped to what the budget has left. A clamped retry runs on a deadline nobody configured, so reporting that number sends a responder hunting for a setting that does not exist. Once a retry has happened the error restates the configured deadline and the total wait instead. Fixes veryfront/veryfront-issue-inbox#710 (the bounding half; #3993 shipped the retry itself).
Summary
ProviderErrorwhoseretryableflag is true, including stream-header timeouts and transient overloads.ReadableStreamrequest bodies single-attempt, and never retry caller cancellation.Root cause
providerTimeoutErrorcorrectly setretryable: true, butrequestStreamonly retriedProviderRateLimitError. A timed-out attempt also owned an already-aborted deadline, so it could not simply reuse the existing signal. The error therefore escaped into the agent stream and ended the run.The retry stays at the transport boundary because this is the last point that knows whether the request body is replayable and the last point before provider output is exposed. Retrying in the stream lifecycle would need to undo or buffer legacy and active SSE state.
Red-green TDD
Red against the unchanged implementation:
retries a stream-header timeout before provider output: rejected after the first timeout.bounds stream-header timeout retries: expected 3 attempts, observed 1.retries other typed retryable failures before provider output: a retryable 503 escaped after the first attempt.Result:
FAILED | 0 passed (64 steps) | 1 failed (4 steps).Green after the fix:
ok | 1 passed (68 steps) | 0 failed.The focused coverage also proves that persistent timeouts stop after 3 total attempts, stream request bodies are never replayed, long rate-limit delays retain their real provider error, and caller cancellation starts no retry.
Verification
deno task test:file src/provider/runtime-loader/provider-http.test.ts: 1 passed, 68 stepsdeno task test:file src/provider: 26 passed, 259 stepsdeno task test:unit: 3,973 passed, 30,763 steps; cwd suites 11 passed / 212 steps and 2 passed / 2 stepsdeno fmt --checkanddeno lint: passeddeno check src/index.ts: passeddeno task docs:api-reference:check: passeddeno task generate:manifests:check: passeddeno task build:npm: passedgit diff --check: passedFixes veryfront/veryfront-issue-inbox#710