Skip to content

fix(ai): gateway error handling — relabel, sanitize the body, and retry transient 5xx - #45

Merged
ndemianc merged 3 commits into
developfrom
fix/gateway-error-label
Jul 25, 2026
Merged

fix(ai): gateway error handling — relabel, sanitize the body, and retry transient 5xx#45
ndemianc merged 3 commits into
developfrom
fix/gateway-error-label

Conversation

@ndemianc

@ndemianc ndemianc commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

The bug

A Gateway run on Opus 5 that hit an upstream 502 surfaced this, verbatim, in the chat — and killed the run:

OpenAI API 502: <html> <head><title>502 Bad Gateway</title></head> <body> <center><h1>502 Bad Gateway</h1></center> </body> </html>

Three things wrong, fixed in three reviewable commits.

1 — Wrong provider (d… relabel)

The failure was an Anthropic model over LevelCode Cloud, yet it read OpenAI. The gateway resolves to providerId: 'openai' to reuse the OpenAI-compatible adapter, so streamAgentTurn stamped the error with the OpenAI provider row's label. prepProviderRequest already computes the correct label — 'LevelCode Cloud' — it just never got threaded past the provider lookup. Now it flows through runAgentturnOpts → the router, which prefers o.label || p.label. BYOK is unchanged (falls back to the provider row, so OpenRouter still reads "OpenRouter").

2 — Raw HTML in the transcript (sanitize)

All three throw sites appended the raw response body, and a proxy 5xx is an nginx HTML page. Two pure helpers in openaiCompat.js: extractApiError() returns a provider's JSON {error:{message}} when present, '' for an HTML page, a hard-capped string otherwise; httpError() composes "<label> API <status>: <detail>", falling back to the status reason, and sets .status.

The same failure now reports as: LevelCode Cloud API 502: Bad Gateway

3 — It failed the run instead of recovering (retry)

A 502/503/504 is a transient upstream blip — the proxy briefly couldn't reach a healthy backend. New postChat() centralizes POST /chat/completions for all three OpenAI-shaped entry points and retries once on 502/503/504.

Safe by construction: it runs before any SSE line is read, so on a transient status the response carries no model output — nothing has streamed or been metered, and a retry cannot duplicate output or double-bill the UI. Deliberately not retried: 429 (needs Retry-After), 500 (usually a real error), other 4xx, and thrown network/abort errors. A 401 still throws straight through to the existing token-refresh path. The backoff wakes early on abort so Stop stays instant, and the agent shows a visible upstream busy (502) — retrying… status (new onRetry hook) instead of a mystery pause.

The run in the screenshot would now survive.

Verification

  • Mutation proof on the exact body: OLD → OpenAI API 502: <html>…, NEW → LevelCode Cloud API 502: Bad Gateway.
  • providers.test.js 12 → 28: extractApiError / httpError (incl. the literal nginx page), and postChat retry (recover / exhaust / no-retry-on-4xx / onRetry / clean-200 / abort-not-retried). Async cases use an awaited runner so a rejected assertion can't false-green.
  • End-to-end with a stubbed fetch through the real index.js → openaiCompat.js chain: (a) label threads for the gateway shape and BYOK falls back; (b) an agent turn that gets a 502 then a good SSE stream retries once and streams the text, onRetry carrying 502, stop_reason: end_turn.
  • Full gate: 24 suites, 0 failures.

Still out of scope

  • anthropic.js parity — the native Anthropic adapter has the same raw-body-append pattern (already correctly labeled; Anthropic returns JSON, so low HTML risk). Sharing httpError/postChat there is a clean follow-up.

🤖 Generated with Claude Code

…HTML into chat

A gateway run that hit an upstream 502 surfaced, verbatim:

    OpenAI API 502: <html> ... <center><h1>502 Bad Gateway</h1></center> ... </html>

Two defects in one line:

1. Mislabeled. The gateway resolves to providerId 'openai' to reuse the OpenAI-
   compatible adapter, so streamAgentTurn stamped the error with the OpenAI provider
   row's label — even for Opus 5 over LevelCode Cloud. prepProviderRequest already
   computes the right label ('LevelCode Cloud'); it was just never threaded past the
   provider lookup. Thread req.label through runAgent/turnOpts, doStream, and compact,
   and have the router prefer o.label over p.label. BYOK still falls back to the
   provider's own label, so an OpenRouter failure still reads "OpenRouter".

2. Raw HTML dumped. The three throw sites appended the raw response body; a proxy 5xx
   is an HTML page, not JSON, so the whole nginx document landed in the transcript.
   Two pure helpers in openaiCompat: extractApiError() returns a provider's JSON
   {error:{message}} when present, '' for an HTML page, and a hard-capped string
   otherwise; httpError() composes "<label> API <status>: <detail>", falling back to
   the status reason ("Bad Gateway") when there's no usable message, and sets e.status.

The 502 itself is a transient upstream blip we can't fix — this is about surfacing it
honestly. The same failure now reads:

    LevelCode Cloud API 502: Bad Gateway

providers.test.js +10 (extractApiError / httpError, incl. the exact nginx body).
Verified end-to-end with a stubbed fetch: the label threads through the real
index.js -> openaiCompat.js chain and BYOK's p.label fallback is intact.
Full gate: 24 suites, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 21:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes misleading provider attribution for gateway-routed requests and prevents raw HTML proxy error pages from being surfaced in chat/agent error messages, improving error clarity in the extensions/levelcode-ai multi-provider layer.

Changes:

  • Thread a route label override (req.label) through chat, agent turns, and memory compaction so gateway errors are attributed to the route (e.g., “LevelCode Cloud”) instead of the underlying adapter/provider row.
  • Add extractApiError() and httpError() helpers to sanitize/cap HTTP error bodies (dropping HTML pages) and improve fallback to canonical reason phrases when statusText is empty.
  • Expand provider-layer unit tests to cover the new helpers and the reported 502/HTML regression.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
extensions/levelcode-ai/test/providers.test.js Adds unit tests for error extraction/sanitization and route label attribution.
extensions/levelcode-ai/providers/openaiCompat.js Introduces extractApiError/httpError and uses them at HTTP failure sites to avoid dumping HTML into transcripts.
extensions/levelcode-ai/providers/index.js Prefers an override label (`o.label
extensions/levelcode-ai/extension.js Threads label through chat streaming and agent memory compaction, and includes it in agent context.
extensions/levelcode-ai/agent.js Passes ctx.label into per-turn provider dispatch so agent-turn errors attribute correctly.
Comments suppressed due to low confidence (2)

extensions/levelcode-ai/providers/index.js:180

  • complete now forwards o.label but the JSDoc type for o doesn’t declare label. Under // @ts-check this can cause a type error. Add label?: string to the @param object type.
		baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,

extensions/levelcode-ai/providers/index.js:212

  • streamAgentTurn now forwards o.label, but the JSDoc type for o doesn’t include label. With // @ts-check, this is a type-check violation. Update the @param type to include label?: string.
		baseURL: o.baseURL || p.baseURL, apiKey: o.apiKey, headers: p.headers, label: o.label || p.label,

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread extensions/levelcode-ai/providers/index.js
…s, so the run survives

The 502 that killed the run in the screenshot was a transient upstream blip — the proxy
in front of the model briefly couldn't reach a healthy backend. Reporting it cleanly (the
previous commit) is good; recovering from it is better.

New postChat() centralizes POST /chat/completions for all three OpenAI-shaped entry points
(stream, complete, agent turn) and retries ONCE on 502/503/504. It is safe because it runs
BEFORE any SSE line is read: on a transient status the response carries no model output, so
nothing has streamed or been metered and a retry cannot duplicate output or double-bill the
UI. Deliberately NOT retried: 429 (needs Retry-After), 500 (usually a real error), other
4xx, and thrown network/abort errors. A 401 still throws straight through to the agent's
existing token-refresh path.

The backoff wakes early on abort so Stop stays instant. The agent surfaces a visible
"upstream busy (502) — retrying…" status via a new onRetry hook (threaded through the
router) rather than a mystery pause; chat/complete retry silently.

providers.test.js +6 (recover / exhaust / no-retry-on-4xx / onRetry / clean-200 /
abort-not-retried). Verified end-to-end with a stubbed fetch: an agent turn that gets a 502
then a good SSE stream retries once and streams the text, onRetry carrying 502. Full gate:
24 suites, 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 21:25
@ndemianc ndemianc changed the title fix(ai): stop mislabeling gateway errors as "OpenAI" and dumping raw HTML into chat fix(ai): gateway error handling — relabel, sanitize the body, and retry transient 5xx Jul 25, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread extensions/levelcode-ai/providers/openaiCompat.js
#45 review)

streamChat/complete/streamAgentTurn read o.label and o.onRetry, and pass onRetry as an
object literal into the openaiCompat adapters — but the @param typedefs for both didn't
list them. Under // @ts-check that is 9 real diagnostics: TS2339 (property does not exist)
on each o.label / o.onRetry read, plus TS2353 (excess property) on each adapter literal.

Added label?:string and onRetry?:(info:{attempt,retries,status})=>void to the three
router typedefs, and onRetry? to the three adapter typedefs (they already carried label?).
The reviewer flagged label; onRetry has the same defect (added by the retry commit) and is
fixed in the same pass.

Verified with a real tsc 5.6 checkJs run — and not the vacuous kind: a synthetic probe
first confirmed tsc actually enforces TS2339/TS2353 here (an earlier npx form was silently
not running tsc at all), then stashing the fix gave BEFORE=9 label/onRetry errors, restoring
it gave AFTER=0. JSDoc-only; runtime unchanged (full gate 24 suites, 28 provider tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 21:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@ndemianc
ndemianc merged commit a0850f4 into develop Jul 25, 2026
2 checks passed
@ndemianc
ndemianc deleted the fix/gateway-error-label branch July 25, 2026 21:48
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.

2 participants