Skip to content

fix(resilience): stop counting client aborts as provider failures (#7907) - #7934

Closed
Long-Feeds wants to merge 2 commits into
cheaper-inference:release/v3.8.49from
Long-Feeds:fix/7907-client-abort-resilience
Closed

Long-Feeds wants to merge 2 commits into
cheaper-inference:release/v3.8.49from
Long-Feeds:fix/7907-client-abort-resilience

Conversation

@Long-Feeds

@Long-Feeds Long-Feeds commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #7907 — client-initiated aborts were counted as provider failures, cooling down healthy accounts and accruing whole-provider circuit-breaker failures.

Root cause

abort(reason) rejects the in-flight upstream fetch with the raw abort reason. Only error.name === "AbortError" was mapped to 499 in the chatCore failure path. A string abort reason — the stream controller's "request_signal_aborted" default, or "Client disconnected: ..." variants — carries no name and no status, so it fell through to the HTTP_STATUS.BAD_GATEWAY (502) default. A 502:

  • passes the shouldSkipConnDisable guard (fix(combo): reject known context overflow without exhausting providers #7177, which only matches status 499 / client_disconnected), so markAccountUnavailable cools down the healthy serving connection, and
  • is in PROVIDER_BREAKER_FAILURE_STATUSES, so the whole-provider breaker accrues failures for what is purely a client lifecycle event (combo race losers, model switches, tab close, user interrupt).

Fix

Tests

tests/unit/client-abort-not-provider-failure-7907.test.ts — written first and verified failing (missing classifier → string aborts became 502s), then passing after the fix:

  • classifier flags AbortError shapes, undici codes, and string abort reasons
  • classifier never flags genuine upstream failures (502 message, timeout, error with upstream status)
  • breaker stays CLOSED after 10 client aborts past a threshold of 3 with the combined predicate
  • breaker still OPENs after 3 genuine 502s with the combined predicate

Verification run:

Follow-up (review)

Added an integration-level test in tests/unit/chatcore-translation-paths.test.ts that drives handleChatCore with a responseFactory throwing the bare string "request_signal_aborted" — the exact leak shape — asserting 499 / "Request aborted". Verified failing (actual 502) against the pre-fix classification, passing after.

…eaper-inference#7907)

abort(reason) rejects the in-flight upstream fetch with the raw abort
reason. A string reason (streamHandler's "request_signal_aborted"
default, "Client disconnected: ..." variants) carries no error name and
no status, so the chatCore failure path fell through to the HTTP 502
default - cooling down the healthy serving connection via
markAccountUnavailable and accruing whole-provider circuit-breaker
failures for what is purely a client lifecycle event (combo race
losers, model switches, tab close, user interrupt).

Fix:
- add isClientAbortError classifier next to isLocalStreamLifecycleError
  (cheaper-inference#4602): recognizes AbortError names, undici UND_ERR_ABORTED /
  ABORT_ERR codes, and string-or-message abort reasons, while treating
  any error carrying a real upstream HTTP status as NOT a client abort
- chatCore catch path now classifies all client-abort shapes as 499,
  so shouldSkipConnDisable (cheaper-inference#7177) and the provider breaker's
  PROVIDER_BREAKER_FAILURE_STATUSES gate both see them correctly
- exclude client aborts from the provider breaker isFailure predicate
  in chat.ts / chatHelpers.ts, matching the existing cheaper-inference#4602 exclusion

Test: tests/unit/client-abort-not-provider-failure-7907.test.ts
(failed before the fix - missing classifier meant string aborts were
502s; passes after, and the breaker still opens on genuine 502s).

Fixes cheaper-inference#7907

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Long-Feeds
Long-Feeds requested a review from diegosouzapw as a code owner July 21, 2026 03:55
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks for tracking down #7907 — the root cause is exactly right: abort(reason) can reject the upstream fetch with a bare string that carries no name/status, so it fell through to the 502 default and got charged to the healthy connection and the whole-provider breaker for what is purely a client-side disconnect. The fix is well targeted: isClientAbortError covers AbortError names, undici's UND_ERR_ABORTED/ABORT_ERR codes, and string/message abort reasons, while any error carrying a genuine numeric upstream status is correctly excluded. I checked and chat.ts/chatHelpers.ts are the only two call sites in the repo that set the breaker's isFailure predicate, and both are updated here — no other wiring gap. All 5 new tests plus 68 neighboring circuit-breaker/abort tests pass, typecheck and lint are clean on the changed files, and the existing chatCore integration test for the AbortError-name path still passes.

One thing has to be cleaned up before this lands: the commit carries a Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> trailer and the PR description has a '🤖 Generated with [Claude Code]' footer. We don't credit AI tools in commit/PR metadata here (project hard rule) — this will be stripped from the commit message during merge, no action needed from you beyond being aware for future PRs. As an optional follow-up (not blocking): an integration-level test through handleChatCore that throws a raw string abort reason directly (rather than just unit-testing the classifier) would close the last bit of coverage on the exact bug scenario, but this is a nice-to-have, not a requirement.

…aper-inference#7907)

Integration-level companion to the cheaper-inference#7907 unit tests: drive handleChatCore
with a responseFactory that throws the bare string
"request_signal_aborted" (the exact leak shape) and assert the failure
maps to 499 / "Request aborted" instead of the 502 provider-failure
default. Verified failing (actual 502) against the pre-fix
classification, passing after.
@Long-Feeds

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! Noted on the metadata rule — I've removed the footer from the PR description and the new commit follows the rule; will keep it in mind for future PRs.

Took the optional follow-up as well: 7facca04a adds an integration-level test in tests/unit/chatcore-translation-paths.test.ts that drives handleChatCore with a responseFactory throwing the bare string "request_signal_aborted" (the exact leak shape) and asserts 499 / "Request aborted". I verified it fails with actual 502 against the pre-fix classification and passes after; the full file (62 tests) stays green.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Obrigado por atacar #7907, @Long-Feeds — o problema é real e a correção está agora em release/v3.8.49 via #7908 (@insoln), que tratava do mesmo issue em paralelo.

Por que #7908 e não este, em detalhe para não soar como dispensa:

  1. O tip já trazia a infraestrutura de fix(resilience): client aborts are counted as provider failures, cooling down healthy accounts #7907 — isLocalStreamLifecycleError foi mergeada na v3.8.37, com isFailure: (e) => !isLocalStreamLifecycleError(e) já aplicado no breaker. A parte que faltava era o caminho single-model do breaker que bypassa o isFailure option (ele só roda dentro de breaker.execute()).

  2. fix(resilience): don't cool down accounts or trip the breaker on client aborts #7908 fecha exatamente esse gap: adiciona shouldTripProviderBreakerForResult — um predicado puro e testável — para o caminho single-model, e espelha a política [BUG] codexTransport=websocket: /v1/responses fails instantly (~300ms) with "Invalid state: Controller is already closed" (502); SSE/HTTP path works #4602 no caminho combo (shouldRecordProviderBreakerFailure). Reusa o classifier isLocalStreamLifecycleError já existente, sem duplicar.

  3. Este PR introduzia um classifier novo em paralelo (isClientAbortError) que faz essencialmente o que o isLocalStreamLifecycleError já existente faz, e trazia um import de opencodeReasoningSanitizer (stripBooleanReasoning) fora do escopo de fix(resilience): client aborts are counted as provider failures, cooling down healthy accounts #7907.

A ideia central — client abort não é falha de provider — era a mesma nos dois, e você chegou nela de forma independente. Se topar, um PR focado numa parte que #7908 não cobriu é muito bem-vindo. Valeu pela contribuição.

@Long-Feeds

Copy link
Copy Markdown
Contributor Author

Totally fair — #7908 landing first and reusing the existing plumbing makes sense, and shouldTripProviderBreakerForResult covering the execute()-bypass path closes the accounting side cleanly. Congrats to @insoln on the parallel find.

One small note for the record: this PR's diff didn't touch opencodeReasoningSanitizer / stripBooleanReasoning (point 3) — that might be from another branch.

Since #7908 intentionally keeps the surfaced status as-is, a client's own abort still logs and returns FAILED 502 / Bad Gateway from the chatCore failure path. If normalizing that to 499 / "Request aborted" is welcome as a focused follow-up (pure status/telemetry correctness — resilience is already handled), happy to send a small PR.

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.

fix(resilience): client aborts are counted as provider failures, cooling down healthy accounts

2 participants