fix(providers): a stream that delivered nothing is sent again, not called a partial document - #481
Conversation
…lled a partial document A user's conversion failed with "bedrock: the response stream ended without completing (0 chars received, no message_stop and no stop_reason)". Two things were wrong with that, and they are the same mistake: an empty response was being handled as a truncated one. The message described a partial document to someone who had received no characters at all, pointing an operator at a truncation that did not happen. And the call was not sent again, on the reasoning — written down above `BedrockProvider` — that a mid-stream retry would have to discard streamed output or resume mid-document. At zero characters neither is true: there is nothing to discard, nothing that can ship short, and nothing about the request the upstream objected to. So the empty case gets its own type. `EmptyStreamError` is raised only when not one character arrived, and both adapters now retry it — Bedrock once, OpenRouter within the retry budget it already had, where `isTransientNetworkError` could never have recognized a failure that arrives as a clean 200. A stalled attempt is still a `StalledStreamError`, checked first, so the retry cannot lengthen a wedged session. The retry is not free and the accounting says so: the Anthropic stream reports the prompt's counts in `message_start`, so an attempt that got that far and closed was billed, and `Attempt.billed` carries those counts into the surviving attempt's report rather than letting a call that paid for two prompts be logged as having paid for one. `addUsage` moves to types.ts, since two adapters now depend on adding the same way. On the demo, the sentence the user quoted ended "...the source never had.. You can try again." — a period joined onto a message that already had one, which a screen reader reads as a pause and a new sentence. `failureMessage` adds it only when the message does not already end one. Co-authored-by: bbertucc <46652+bbertucc@users.noreply.github.com>
|
This PR was opened by the scheduled To get the automated review: |
There was a problem hiding this comment.
All six checks in the summary pass. No workflow files touched. I verified the two things the PR asks to be checked hardest, and both hold:
EmptyStreamErroris raised only with an emptytext.src/providers/bedrock.ts:1167-1181sits afterif (expired && !sawStop) throw stalled(expired)(1166) and after the mid-stream catch (1148-1151), which rethrows the raw error — and structured output arrives as text on this adapter (bedrock.ts:112), so there is no path that reaches it with content in hand. Same shape atopenrouter.ts:358-370.- The billing fold is right.
usage,textandsawStopare per-send locals insidestream,attempt.billedis the only cross-send carrier, andaddUsage(attempt.billed, usage)is recomputed rather than accumulated inmergeUsage(bedrock.ts:1059), so repeated usage events within one attempt cannot double it.onUsageis last-wins in the router (src/providers/index.ts:137-139) andresult.usage ?? usage(:203) reads the same total on the surviving path. The comment atbedrock.ts:687also checks out:billedis only ever set from ausagethat already setspent, so the ceiling-loweringsecondattempt can never start having silently dropped a paid-for prompt.
Non-blocking notes
1. docs/design-notes.md:2765-2770 now states the opposite of the code. "Bedrock has no retry loop on purpose: the AWS SDK already applies its standard strategy … Adding a loop around it would give Bedrock 9 attempts to OpenRouter's 3." sendRetryingEmptyStream (src/providers/bedrock.ts:498-534) is that loop. I have no objection to the loop, but that paragraph is the rationale the next person to touch this file reads, and it now reads as a prohibition on what the file does. The arithmetic it warns about moved too: one Attempt can now issue two client.send calls, each carrying the SDK's own 3 wire attempts, so the worst case is 6 wire attempts per attempt (12 across the ceiling retry) instead of 3 — reachable only when an empty stream is followed by a throttle or 5xx, but it is the exact multiplication that paragraph exists to prevent.
2. public/demo.html:319 does not treat … (U+2026) as a finished sentence, and the test that says it does cannot fail. /[.!?]["'`)\]]?$/ has no … in the class, so failureMessage('the upstream gave up…') returns …gave up…. You can try again. — the stray double stop #480 was filed about, in the case the test names. test/demo-error-sentence.test.ts:49-62 ("an ellipsis, a question and a quoted ending are all already finished") asserts only !failureMessage(why).includes(".."), and a one-character … followed by . never contains .., so that assertion passes on the broken input. Three ASCII dots are fine (the last . matches), so no message Iris writes for itself reaches this today — the reachable input is an upstream string forwarded verbatim, openrouter: stream error: ${parsed.error.message} (src/providers/openrouter.ts:278), ending in an ellipsis character. Adding … to the class fixes the behaviour; asserting the exact string instead of includes("..") fixes the test.
3. The failure a screen-reader user now hears is 97 words / 595 characters and ends by saying the same thing twice. Rendered through the new failureMessage, EmptyStreamError announces: "Conversion failed: bedrock: the response stream ended without completing on us.anthropic.claude-sonnet-4-6, having delivered nothing at all — no content, no message_stop and no stop_reason. Sent 2 times in all, since … Sending the document again is the remedy. You can try again." The old message for the same failure was ~35 words. This is the one string a failed document explains itself with, it goes into a live region (setError), and this PR's own rationale for touching it is what a screen reader does with it — so it is worth noting that the fix for one stray period ships with a 2.7× longer announcement whose actionable half is a duplicated pair at the end (Sending the document again is the remedy. / You can try again.). EmptyStreamError's middle two sentences ("Nothing partial arrived, so no part of a document is at risk of shipping short: what failed is the upstream closing a 200 response before saying anything…") are operator prose, and an operator reads it in the run log where it is already present.
Accessibility impact: none on the converted HTML — a retried empty stream recovers a page that previously became a hole in the document, and the only user-facing string that changes is the demo's failure announcement, which loses a stray double stop but gains ~60 words of protocol detail in a live region.
…st is stated from the logs Answers the three review notes on this branch, and corrects one claim the production logs disprove. The message. The demo reads it aloud in a live region and then adds its own "You can try again." It had grown to 97 words and ended by saying "try again" twice. It now says what happened and how many sends it took, in about 30 words. It keeps "ended without completing", which is what people already search run logs for. A test pins one "again" and at most 40 words. The ellipsis. `failureMessage` did not treat `…` as the end of a sentence, and the test that said it did could not fail, because `….` never contains `..`. `…` is in the pattern now and the test asserts the exact string. It fails on the old pattern. design-notes.md said Bedrock has no retry loop on purpose. It has one narrow loop now, so the paragraph says so and gives the worst case: 6 wire attempts instead of 3. The claim. The code said an empty stream closes "quickly". On the UIC deployment all three empty streams came from us.openai.gpt-5.6-luna, after 42, 82 and 83 seconds. The comment now says so, and that a retry can add up to 120 s to a page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Iris Maintainer Agent here. I'm taking this PR over. Pushed Is #480 real? Yes. The UIC deployment's session table has two sessions that failed this way, both on 2026-09-15, both on the first page call, on two different documents. It isn't one bad file. It's one model. Page-agent calls on that deployment from 2026-09-01 to 09-24:
The empty streams took 42, 82 and 83 seconds. So "closed cleanly and, being empty, quickly" was wrong. The comment now gives the real numbers, and says a retry can add up to 120 s to a page. The retry is still worth it. Luna answered 295 of 308 calls, so a second send will usually work. The three review notes:
Not in this PR: the 10 stalls are the same silent model seen from the other end. Our 120 s timer fires before Bedrock hangs up. They aren't retried. That's a wider change to what a stall means, so it gets its own issue. Checks: typecheck clean, 1724 pass / 0 fail. 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Approving in substance (posted as a comment — GitHub will not let this identity approve its own PR).
All six checks in the summary pass; no workflow files touched. All three notes from my review of bc10c93 are fixed, and I re-verified only what this push changed.
- Note 1 (stale
docs/design-notes.mdrationale) — fixed.docs/design-notes.md:2770-2775now names the exception, and the arithmetic matches the code: 6 wire attempts per attempt, 12 across the ceiling retry. - Note 2 (
…not treated as a finished sentence, and a test that could not fail) — fixed.public/demo.html:322adds…to the class, andtest/demo-error-sentence.test.ts:66asserts the exact string instead ofincludes(".."), so the case it names is now actually pinned. - Note 3 (97-word announcement ending in two ways of saying "try again") — fixed.
src/providers/types.ts:508-512is three short sentences, andtest/demo-error-sentence.test.ts:71-85pins both properties (oneagain, ≤40 words) against the realEmptyStreamError, not a literal. No stale reference to the old wording survives anywhere insrc,testordocs.
Non-blocking notes
src/providers/bedrock.ts:819 — "it stays inside MAX_TOTAL_MS" names a bound that is not enforced across the retry. The total-time backstop is armed per send, inside stream(): const totalTimer = setTimeout(() => { expired = "total"; controller.abort(); }, this.maxTotalMs) (src/providers/bedrock.ts:1043-1047). sendRetryingEmptyStream calls this.send twice (:839, :855), so the second send starts a fresh 15-minute budget rather than continuing the first one's. The worst case for one complete() is therefore the empty first send (bounded by firstOutputTimeoutMs, ≤120 s) + 400 ms + a full MAX_TOTAL_MS on the retry — about 17 minutes of one concurrency slot where the comment says 15.
Latent, and it changes no delivered document: the pre-existing output-ceiling retry re-enters stream() the same way, so the per-send budget is not new here, and MAX_TOTAL_MS exists to bound the pathological case rather than normal slow work (:62-66). It would become a real timeout regression only if a deployment lowered maxTotalMs to near a page's actual duration and relied on it as a per-call cap. I raise it because that sentence is the safety argument for the retry, and the number it cites is the one a reader would check.
Accessibility impact: none on the converted HTML — the only user-facing string that changes is the demo's failure announcement, which now says "try again" once in one short sentence set, and an upstream message ending in … no longer gains a stray second stop in the live region.
Each send arms its own total timer, so the retry gets a fresh 15 minutes. The comment said the retry stays inside MAX_TOTAL_MS; it now says what the worst case is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Iris Maintainer Agent here. Fixed the non-blocking note on
🤖 Generated with Claude Code |
There was a problem hiding this comment.
Approving in substance (posted as a comment — GitHub will not let this identity approve its own PR).
All six checks in the summary pass; no workflow files touched.
This push (ec94b08 → d8073f3) is a 4-line comment change in src/providers/bedrock.ts and nothing else. It fixes the one note from my review of ec94b08:
src/providers/bedrock.ts:819-822— fixed. The comment no longer claims the retry "stays inside MAX_TOTAL_MS". It states the mechanism correctly now:stream()arms the total timer per send (:1050,this.maxTotalMs),sendRetryingEmptyStreamcallsthis.sendtwice (:839,:855), so the retry gets a fresh budget, and the worst case is the empty send +EMPTY_STREAM_RETRY_MS(400 ms,:73) + a fullMAX_TOTAL_MS. Pointing at the output-ceiling retry working the same way is right too, and it is what makes this a documented pre-existing property rather than something the empty-stream retry introduced.
Nothing else changed, so nothing else to re-verify, and I found nothing new. The three notes from bc10c93 remain fixed as of ec94b08.
Accessibility impact: none — this push changes only a comment.
Closes #480
Reported by @bbertucc
Summary
A user lost a whole conversion to
bedrock: the response stream ended without completing (0 chars received, no message_stop and no stop_reason). Two things were wrong with that, and they are the same mistake: an empty response was being handled as a truncated one.BedrockProvider, that a mid-stream retry would have to discard streamed output or resume mid-document. At zero characters neither objection holds. There is nothing to discard, nothing that can ship short, and nothing about the request the upstream objected to.What changed:
EmptyStreamError(src/providers/types.ts) — its own type, raised only when not one character arrived. Its message says what actually happened and how many times the request was sent.sendRetryingEmptyStream). A stalled attempt is aStalledStreamError, andexpiredis checked before the completeness check that raises this — so the attempt a retry follows is always one that closed cleanly and, being empty, quickly. The retry cannot lengthen a wedged session. Aconsole.warnon each occurrence, because the frequency is the whole question for an operator.isTransientNetworkErrorwas never going to recognize it because no socket error is raised. Fixing one adapter would leave the two disagreeing about whether an empty response is fatal.message_start, so an attempt that got that far and then closed was billed.Attempt.billedcarries those counts into the surviving attempt's report, or a call that paid for two prompts would be logged as having paid for one — invisibly, sincetokens.calls_reportedwould still call it fully accounted for.addUsagemoves fromopenrouter.tstotypes.tsnow that both adapters depend on adding the same way....the source never had.. You can try again.was a period joined onto a message that already had one. A screen reader reads a stray double stop as a pause and a new sentence, on the one string that explains a failed document.failureMessageadds the period only when the message does not already end a sentence.The partial case is untouched and pinned as such: text in hand still fails, still on the first send, still naming the characters that arrived.
No new dependencies, runtime or dev.
Testing
npm run typecheck./test/e2e.shIf the demo changed, it still passes axe-core with 0 violations
npm run typecheck— clean.npm test— 1723 pass, 0 fail (baseline onmainwas 1708). 15 new:test/empty-stream-retry.test.ts(10) andtest/demo-error-sentence.test.ts(5)../test/e2e.sh—ALL ENDPOINTS PASSED ✅.The demo's axe check is scripted, not manual:
test/demo-a11y.test.tsrunsrunAxeoverpublic/demo.htmland it reports 0 violations inside the run above. The change to that file is inline script only — no markup moved — so there was nothing for axe to see, but the check ran rather than being asserted from reasoning.The new tests pin the retry and, as hard, the two ways a retry does damage instead of good: a stalled call must not become a retried one, and the abandoned attempt's tokens must survive into the report. Both have their own test.
test/bedrock-converse.test.tsgrew a comment and a warning-swallow: its empty-script test now sends twice before failing, which is the same command built the same way.Why this issue
#480 is the only candidate above docs. Baseline
mainwas green, so nothing outranked it; there was no accessibility barrier open in Iris's output. That put this in tier 3 — a correctness bug that loses a real user's document. The other two open issues were passed over deliberately: #445 (the 13.9 ms upload-test flake) asks for exactly one thing, a second occurrence with the assertion surviving, and says outright that nothing insrc/should be touched meanwhile — a PR against it would be guessing at a mechanism the issue declines to guess at. #85 ("Looking to contribute?") is a standing invitation, not a defect. No injection attempt in any issue body or comment; nothing asked for a CI, permission or dependency change.The issue said "resolve this if it is truly an issue". It is: the failure loses a document, and the code's own note said this case was left alone "before the logs show it happening". It has now happened.
What a reviewer should look at hardest
The two lines that decide whether a retry is safe, in this order:
src/providers/bedrock.ts— theif (!text)inside the!sawStop && !stopReasonbranch. Everything else rests on this being the only way anEmptyStreamErroris raised. If a path could raise it with text in hand, the retry would deliver a passage twice.src/providers/bedrock.ts—attempt.billed = addUsage(attempt.billed, usage)at that same throw site, and the two reads ofattempt.billedinmergeUsageand the finalreturn. This is the one place I would expect a mistake to be invisible: an off-by-one in the fold undercounts the bill and nothing downstream notices.One judgment call worth disagreeing with if you see it differently: the retry is not guarded on
attempt.spent. On the invoke dialectmessage_startusually arrives before the stream dies, so a!spentguard would refuse to retry exactly the case #480 reports — which is why the guard is "nothing was generated", matching OpenRouter's existing rule, and why the prompt's cost is carried forward instead of avoided. It buys one re-read of a prompt against losing a document whose other pages were already paid for.Two things deliberately not done: a mid-stream
streamExceptionwith 0 chars is still not retried (that needs a judgment about which upstream exceptions are transient, which is a wider change), and no newProviderNotekind was added, so a successful retry reaches the run log through the warning and through the token counts rather than through amodel_callfield of its own.Opened by the scheduled
issue-to-prworkflow.