fix(llm): back off + retry mid-stream provider failures; typed transient errors (ENG-673)#246
Conversation
…path (ENG-673) Self-review of the 3-PR stack surfaced four issues; fixing them here (anton side): - #1 (was a real regression): truncation detection raised whenever a stream ended with no finish_reason/stop_reason — but many OpenAI-compatible endpoints simply don't report one, so a COMPLETE, good answer was being discarded and turned into an error (and would fail every turn for such a provider). Now only the truly-empty case (no content AND no tool_calls) is treated as truncated; a content-bearing stream without a terminal marker is logged and passed through. - #3: user-stop DURING backoff re-raised the TransientProviderError, surfacing a provider-error card instead of a clean cancellation. Now it breaks cleanly (like a normal stop). - #4: ProviderOverloadedError always named the planning model even when the CODING model was the one that failed. TransientProviderError now carries the in-flight `model` (threaded through classify_transient + both providers' raise sites); the card names the actual failing model, falling back to planning. Tests updated for the new clean-cancel semantics + 2 new (model propagation, failing-model-not-planning). Full suite green (bar the 2 pre-existing environmental scratchpad failures). (#2 — an overstated "graceful degradation" claim — corrected in the PR #246 description, no code change.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial self-review of the 3-PR stack (anton #246 · cowork-server #186 · cowork #388)Reviewed the three branches together, across the contract boundaries. Findings ranked; fixed ones addressed in 🔴 #1 — Truncation heuristic regressed no- 🟡 #2 — "graceful degradation" claim overstated. FIXED (PR body). 🟡 #3 — Cancel during backoff surfaced a provider-error card. FIXED. 🟡 #4 — Card named the planning model even when the coding model failed. FIXED. 🟢 Remaining (LOW — not fixed; flagging for reviewers, follow-up if wanted):
Full suite green after the fixes (bar the 2 pre-existing environmental |
|
Update: #6 also fixed ( Remaining open (all LOW, follow-up candidates): #5 multimodal Retry disabled (cowork), #7 stringly-typed cross-repo code + overloaded |
…ent errors (ENG-673) A mid-stream overload arrives inside an HTTP-200 stream (the SDK raises APIStatusError with status_code=200, real reason in .body), so anton's status-only classifier surfaced the nonsensical "Server returned 200 — the LLM endpoint may be temporarily unavailable" and the session loop retried it instantly with zero backoff — burning all attempts within seconds of a minutes-long incident (BUG-CM-001, Anthropic incident 2026-07-08). - New `TransientProviderError` / `ProviderOverloadedError` + a shared `classify_transient` in provider.py. Classify by BODY, not status: overloaded/api_error, 5xx, plain-429, connection drops, truncated streams. - anthropic.py: refactor the two byte-identical status-only blocks into a shared `_raise_for_status_error` mirroring the ENG-598 openai mapper; openai.py: extend that mapper with the transient branch (covers all four paths). - session.py: budget-bounded backoff-and-retry (30s/turn, cancellation-aware, jittered ~2/10/18s) for the mid-stream case that had NO prior retry; on exhaustion raise ProviderOverloadedError (carries model+provider) for the cowork-server/cowork `provider_overloaded` card. Completed tool_results are never re-executed on retry (idempotency) — only dangling tool_use is sealed. - Split by prior-retry: request-time 5xx/429/connection errors (already SDK-retried) and truncated streams carry session_backoff=False — honest typed message, but fail fast instead of stacking another 30s. - Log the (scrubbed, via ENG-583 scrub_credentials) error body on every transient occurrence. Tests: tests/test_transient_retry.py (classifier, both mappers, backoff helpers, turn-level recovery / budget-exhaustion / cancel / no-replay). Updated the two ENG-598 mapper tests (429/500 now typed-transient) and the two e2e error-handling tests (honest message, fast fail). Full suite green except the 2 pre-existing environmental scratchpad-subprocess failures. Part 1 of 3 for ENG-673 (cowork-server + cowork companions to follow). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…path (ENG-673) Self-review of the 3-PR stack surfaced four issues; fixing them here (anton side): - #1 (was a real regression): truncation detection raised whenever a stream ended with no finish_reason/stop_reason — but many OpenAI-compatible endpoints simply don't report one, so a COMPLETE, good answer was being discarded and turned into an error (and would fail every turn for such a provider). Now only the truly-empty case (no content AND no tool_calls) is treated as truncated; a content-bearing stream without a terminal marker is logged and passed through. - #3: user-stop DURING backoff re-raised the TransientProviderError, surfacing a provider-error card instead of a clean cancellation. Now it breaks cleanly (like a normal stop). - #4: ProviderOverloadedError always named the planning model even when the CODING model was the one that failed. TransientProviderError now carries the in-flight `model` (threaded through classify_transient + both providers' raise sites); the card names the actual failing model, falling back to planning. Tests updated for the new clean-cancel semantics + 2 new (model propagation, failing-model-not-planning). Full suite green (bar the 2 pre-existing environmental scratchpad failures). (#2 — an overstated "graceful degradation" claim — corrected in the PR #246 description, no code change.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-finish_reason passes through, empty stream truncates (ENG-673) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…vider blip (ENG-673 #6) A request-time TransientProviderError (5xx / rate-limit / dropped connection) reaching the count-based retry path was injected as "An error interrupted execution… adjust your approach to avoid the same error" — but that's a service hiccup, not the model's fault, so the note misattributes the failure and can degrade the next attempt mid-incident. Transient errors now get a neutral note ("a transient service issue, not a problem with your approach — continue as planned"); genuine errors keep the original recovery guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185aebf to
9def3a7
Compare
Review — ENG-673 (anton side)Nicely scoped and the mechanics are sound: 🔴 Main point — the
|
Part 1 of 3 for ENG-673 (cowork-server + cowork companions to follow — this is the upstream that raises the typed error). Fixes the anton-side of BUG-CM-001.
Problem
A mid-stream provider overload arrives inside an HTTP-200 stream — the status was already sent, so the error is smuggled into the body as an SSE
errorevent, and the SDK raisesAPIStatusError(status_code=200, body=…). anton's status-only classifier turned that into the nonsensical "Server returned 200 — the LLM endpoint may be temporarily unavailable", and the session loop retried it instantly with zero backoff — burning all attempts within seconds of a minutes-long incident (Anthropic "elevated errors", 2026-07-08). BYOK/direct users hit this; MindsHub-routed users are shielded by the router's failover.What changed
provider.py): newTransientProviderError/ProviderOverloadedError, andclassify_transient()that reads the body, not the status —overloaded_error/api_error, 5xx, plain-429, connection drops, truncated streams.anthropic.py: the two byte-identical status-only blocks refactored into a shared_raise_for_status_error(mirrors the ENG-598 openai mapper).openai.py: that mapper extended with the transient branch (covers all four call paths).session.py: budget-bounded backoff-and-retry (30s/turn, cancellation-aware, jittered ~2/10/18s) for the mid-stream case; on exhaustion raiseProviderOverloadedError(carriesmodel+provider) for the downstreamprovider_overloadedcard. Completedtool_results are never re-executed on retry — only danglingtool_useis sealed.scrub_credentials).Design decisions (aligned on the ticket)
session_backoff=False: honest typed message, but fail fast instead of stacking another 30s. This is what keeps a persistently-broken endpoint from hanging 30s (and the e2e error tests fast).Tests
tests/test_transient_retry.py(new, 30 cases): the classifier matrix, both provider mappers (incl. the mid-stream-200 → not "Server returned 200"), thesession_backoffsplit, backoff cadence +retry_after+ cancel-awareness, and turn-level behavior — recovers after N transient retries, exhausts the budget toProviderOverloadedError, cancels on stop, and does not inject a recovery note (no-replay). Updated the two ENG-598 mapper tests (429/500 are now typed-transient) and the two e2e error-handling tests (honest message + fast fail).Full suite green except the 2 pre-existing environmental
test_chat_scratchpadfailures (scratchpad subprocess can't spawn in the sandbox — documented in the ENG-655 PR, unrelated here).Security pass (convention #8)
scrub_credentials(ENG-583) before hitting logs — nomdb_/sk-/AIzakeys leak via a traceback/body echo.status/body.error.type.ProviderOverloadedErrorcarries onlymodel+provider(no keys/PII).Self-review notes (for the reviewer)
stop_reason is Noneafter a clean stream iteration) is new and conservative — it raises transient withsession_backoff=False(fail-fast), so a false-positive costs a quick retry, not a 30s loop. The_stream_via_responsespath is intentionally not truncation-checked (itsstatussemantics are less clear); flagging in case we want it later.session_backoffis a one-liner.Merge order
Land this before / with cowork-server #186 (or together). The core fix — surviving the incident via backoff-retry — works standalone. But on budget exhaustion the honest message only reaches the user once cowork-server maps the code: with an old cowork-server,
ProviderOverloadedErroris unrecognized and degrades to the generic "An unexpected error occurred" (still not misleading, and never the old "Server returned 200" — but NOT the curated provider text until #186 lands). Full sequence: anton (this) → cowork-server #186 (mapprovider_overloaded→ code + extras) → cowork #388 (the card). Each degrades gracefully; no branch stacking.🤖 Generated with Claude Code