Skip to content

fix(web-ui): humanize provider errors across chat surfaces (#403)#472

Merged
Weegy merged 7 commits into
mainfrom
feat/issue-403-quota-message
Jul 9, 2026
Merged

fix(web-ui): humanize provider errors across chat surfaces (#403)#472
Weegy merged 7 commits into
mainfrom
feat/issue-403-quota-message

Conversation

@Weegy

@Weegy Weegy commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Problem

When a provider rejected a chat turn (quota exceeded, rate limit, billing), the chat surfaces rendered the raw error string verbatim — leading HTTP status and JSON response envelope included. Users saw transport noise like 400 {"type":"error","error":{"message":"Your credit balance is too low..."}} instead of the one actionable sentence.

Change

Introduces web-ui/app/_lib/providerErrorMessage.ts with extractProviderErrorMessage(raw) and the humanizeProviderError(raw, fallback) wrapper, wired into every surface that renders a provider failure: the main chat bubble, the builder chat pane, the preview chat pane, and the default simple builder intake. Each shows the provider's human-readable sentence, falling back to a translated generic notice (new en.json / de.json keys) when none can be surfaced.

On the primary path the orchestrator failure arrives as an in-band error event on an already-streaming 200 response, so res.ok is true and nothing throws. StreamRunner used to finish that turn as done, which meant the background stream toast reported success for a failed turn. It now finishes as a failure carrying the humanized sentence. Covered by StreamRunner.test.tsx.

Extractor semantics

The discriminator is a single question: after stripping any leading HTTP status prefix, does the whole remaining string parse as a JSON object?

  • Empty / whitespace input → null.
  • If it is a JSON object, it is a provider envelope: mine error.message, then a top-level message; return the first non-empty one, else null so the caller shows the generic fallback — never raw JSON.
  • Otherwise it is an application message and is returned unchanged (minus any stripped status), braces or not.

Earlier iterations hunted for a brace substring between the first { and the last }, and keyed the give-up on a leading HTTP status. Both were wrong. A status-less rate-limit envelope — {"type":"error","error":{"type":"rate_limit_error"}}, which middleware/test/orchestrator/streamingRetry.test.ts documents on a real path — leaked raw JSON to users, and an application message merely embedding a JSON fragment (Agent stopped: {"message":"waiting"}) could be reduced to just that field. Parsing the whole string closes both.

Tests

providerErrorMessage.test.ts covers status-prefixed and status-less envelopes, plain-text quota errors, application messages with JSON and non-JSON braces, embedded-JSON application messages, brace-free messages, and empty input. StreamRunner.test.tsx drives the in-band error path and asserts the store outcome, not the rendered string; reverting only the conditional store.finish() makes exactly those two tests fail.

Gate green: tsc --noEmit clean, vitest run 206/206, i18n:check OK (2735 keys, en + de).

How this branch was produced

Authored and reviewed by the interactive issue loop in scripts/issue-{cluster,implement}.workflow.mjs (#469), over six implement→review rounds. Every round ran an adversarial diff reviewer and, after it approved, an independent cross-vendor reviewer (codex, gpt-5.5). The first reviewer approved this branch in rounds 2, 3, 4 and 5; the cross-vendor reviewer rejected it each time, finding — in order — a missed default chat surface, a state bug where the toast reported success on failure, an extractor regression that destroyed clean messages containing braces, and a raw-JSON leak on the status-less rate-limit path. A human verified every finding against the branch before it was fed back. Both reviewers approve the final state.

Known non-blocking notes for the reviewer:

  • On the OpenAI quota path the surfaced sentence is the provider's own, which still ends in a developer-docs URL. That is a real improvement over the HTTP status plus JSON envelope, but it is not bespoke non-technical copy. Whether that satisfies this issue is a maintainer call.
  • The branch is named feat/… while the issue is labelled bug; the commits are all fix(web-ui): and squash-merge uses the PR title.
  • /^\d{3}\s+/ strips a leading three-digit token from any message, so a hypothetical 404 errors were found would lose its prefix. No provider path produces that shape.

Closes #403

Weegy added 6 commits July 9, 2026 10:54
The prior commit humanized provider errors on the main chat, BuilderChatPane,
and PreviewChatPane, but left SimpleIntakePane — the default (simple) builder
intake surface rendered on server load — showing the raw `${code}: ${message}`
string. A quota/rate-limit hit there surfaced the exact technical 429 that #403
was filed to fix, on the most common builder surface.

Route its stream error through humanizeProviderError with a builder-simple-scoped
generic fallback key (builder.simple.intake.errorProviderGeneric, added to en.json
and de.json with distinct wording), and log the raw error to the console for
debugging — matching the pattern of the three already-fixed surfaces.
The chat bubble was humanized but StreamRunner passed the raw provider
error string to store.finish() at both terminal call sites (HTTP-error and
catch). streamStore keeps that as record.error, which StreamToasts renders
verbatim — so the floating stream toast still showed the raw '429 ...'
string. Humanize the value before it reaches store.finish() at both sites,
using the same helper and t('errorProviderGeneric') fallback already in
scope; the 'aborted' path is left untouched. Also append the required
docs/CHANGELOG.md Unreleased entry for this bugfix.
The primary #403 path delivers the orchestrator failure as an in-band NDJSON
error event on an already-streaming 200 response. StreamRunner humanized it for
the chat bubble but then finished the stream record as 'done', so the background
stream toast reported success for a failed turn.

runOneTurn now captures the humanized message of an in-band error event seen
while draining the stream (loop or trailing tail parse) and finishes the record
as 'error' with that message; a clean turn still finishes as 'done'. Adds a
store-outcome regression test that fails on the previous behaviour, and corrects
the CHANGELOG entry to describe the toast fix.
extractProviderErrorMessage returned null for any string containing a
brace pair when no JSON message could be mined, so every stream error is
now routed through humanizeProviderError, a clean application message that
merely contains braces was replaced by the translated generic notice.

Gate the give-up on hadStatus: a JSON envelope with no surfaceable
message stays null only when a status prefix marks it as a wrapped
provider error; a status-less application message is returned unchanged,
braces or not. Update the docstring to match and add regression tests.
The extractor keyed its give-up on a leading HTTP status and hunted for a
brace substring. A status-less rate-limit envelope
({"type":"error","error":{"type":"rate_limit_error"}}) — a real path in
streamingRetry — therefore leaked raw JSON to the chat surfaces.

Discriminate instead on whether the whole string (after stripping any
status prefix) parses as a JSON object: if so, mine error.message then a
top-level message, else fall back to the generic notice; otherwise return
the application message unchanged, braces or not. This also stops a
status-less message embedding a JSON fragment from being reduced to that
fragment's message field.
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.

Show user-friendly message when chat quota is exceeded

1 participant