Skip to content

fix(providers): a stream that delivered nothing is sent again, not called a partial document - #481

Merged
bbertucc merged 3 commits into
mainfrom
iris-auto/issue-480
Sep 24, 2026
Merged

bbertucc merged 3 commits into
mainfrom
iris-auto/issue-480

Conversation

@claude

@claude claude Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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.

  • The message described a partial document to someone who had received no characters. An operator reading "treating a partial document as a whole one" about a zero-character response is being pointed at a truncation that did not happen.
  • 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 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.
  • Bedrock retries it once (sendRetryingEmptyStream). A stalled attempt is a StalledStreamError, and expired is 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. A console.warn on each occurrence, because the frequency is the whole question for an operator.
  • OpenRouter retries it too, inside the retry budget it already had. [bug]: Bedrock Fail #480 was reported on Bedrock but nothing about it is Bedrock's: a 200 whose body says nothing is the same transient event as the connection reset that loop already retried, and isTransientNetworkError was 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.
  • 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 then closed was billed. Attempt.billed carries 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, since tokens.calls_reported would still call it fully accounted for. addUsage moves from openrouter.ts to types.ts now that both adapters depend on adding the same way.
  • The demo sentence the user quoted. ...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. failureMessage adds 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.sh

  • If the demo changed, it still passes axe-core with 0 violations

  • npm run typecheck — clean.

  • npm test — 1723 pass, 0 fail (baseline on main was 1708). 15 new: test/empty-stream-retry.test.ts (10) and test/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.ts runs runAxe over public/demo.html and 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.ts grew 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 main was 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 in src/ 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:

  1. src/providers/bedrock.ts — the if (!text) inside the !sawStop && !stopReason branch. Everything else rests on this being the only way an EmptyStreamError is raised. If a path could raise it with text in hand, the retry would deliver a passage twice.
  2. src/providers/bedrock.ts — attempt.billed = addUsage(attempt.billed, usage) at that same throw site, and the two reads of attempt.billed in mergeUsage and the final return. 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 dialect message_start usually arrives before the stream dies, so a !spent guard 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 streamException with 0 chars is still not retried (that needs a judgment about which upstream exceptions are transient, which is a wider change), and no new ProviderNote kind was added, so a successful retry reaches the run log through the warning and through the token counts rather than through a model_call field of its own.


Opened by the scheduled issue-to-pr workflow.

…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>
@claude
claude Bot requested a review from bbertucc as a code owner September 17, 2026 00:43
@github-actions

Copy link
Copy Markdown
Contributor

This PR was opened by the scheduled issue-to-pr workflow using GITHUB_TOKEN. GitHub does not start workflow runs for events raised by that token, so code-review.yml has not run on it.

To get the automated review:

gh workflow run code-review.yml -f pr_number=481

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • EmptyStreamError is raised only with an empty text. src/providers/bedrock.ts:1167-1181 sits after if (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 at openrouter.ts:358-370.
  • The billing fold is right. usage, text and sawStop are per-send locals inside stream, attempt.billed is the only cross-send carrier, and addUsage(attempt.billed, usage) is recomputed rather than accumulated in mergeUsage (bedrock.ts:1059), so repeated usage events within one attempt cannot double it. onUsage is last-wins in the router (src/providers/index.ts:137-139) and result.usage ?? usage (:203) reads the same total on the surviving path. The comment at bedrock.ts:687 also checks out: billed is only ever set from a usage that already set spent, so the ceiling-lowering second attempt 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>
@bbertucc

Copy link
Copy Markdown
Member

Iris Maintainer Agent here.

I'm taking this PR over. Pushed ec94b08.

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:

model ok empty stream 120 s stall
us.openai.gpt-5.6-luna 295 3 10
us.anthropic.claude-sonnet-4-6 324 0 0
moonshotai.kimi-k2.5 218 0 0

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:

  1. docs/design-notes.md now describes the narrow loop and its worst case (6 wire attempts instead of 3).
  2. … counts as the end of a sentence. The test asserts the exact string, and it fails on the old pattern.
  3. The message is about 30 words, not 97. It says "try again" once, because the demo adds that. A test pins both.

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md rationale) — fixed. docs/design-notes.md:2770-2775 now 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:322 adds … to the class, and test/demo-error-sentence.test.ts:66 asserts the exact string instead of includes(".."), 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-512 is three short sentences, and test/demo-error-sentence.test.ts:71-85 pins both properties (one again, ≤40 words) against the real EmptyStreamError, not a literal. No stale reference to the old wording survives anywhere in src, test or docs.

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>
@bbertucc

Copy link
Copy Markdown
Member

Iris Maintainer Agent here.

Fixed the non-blocking note on ec94b08.

src/providers/bedrock.ts:818-822 no longer says the retry stays inside MAX_TOTAL_MS. Each send arms its own total timer, so the comment now gives the real worst case: the empty send, then EMPTY_STREAM_RETRY_MS, then a full MAX_TOTAL_MS. It also notes that the output-ceiling retry already works this way. The change is comment-only, and no behaviour changed.

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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), sendRetryingEmptyStream calls this.send twice (: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 full MAX_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.

@bbertucc
bbertucc merged commit 51a79cc into main Sep 24, 2026
1 check passed
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.

[bug]: Bedrock Fail

1 participant