feat: actionable error messages and retry-with-fix for fixable 400s - #799
feat: actionable error messages and retry-with-fix for fixable 400s#799scottwofford wants to merge 3 commits into
Conversation
Users previously saw raw Anthropic API JSON when errors occurred, and fixable 400s (e.g. an unrecognized extra field) failed outright. - New pipeline/error_advice.py: maps known upstream error shapes to short actionable suggestions, appended after the raw message (never replacing it) in non-streaming error responses and mid-stream SSE error events. - New pipeline/request_repair.py: locates the field named in an 'Extra inputs are not permitted' 400 and strips it from a deep copy of the request (protected fields: model, messages, max_tokens, stream). - _AnthropicPolicyIO.complete()/stream() retry once with the repaired request; streaming retries only before the first event is yielded. Every repair emits a pipeline.retry_with_fix event and a warning log. Trello: https://trello.com/c/82RqF5DO Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code Review — PR #799 (actionable errors + retry-with-fix)Nice PR overall. The design is tight (single choke point for advice, pure repair module, one-retry cap, explicit streaming safety rail), tests are focused, and the invariants called out in the description are actually enforced in code. Below are concrete observations, ordered by importance. Correctness
Minor / suggestions
Testing
Security / privacy
Performance
Quality of the designThe two design invariants — "raw message always preserved" and "one retry max, observable" — are enforced by structure (single Nits
Overall: 👍 ship. The follow-ups above are minor. Reviewed by Claude (Opus 4.7) |
Code review — PR #799Focused pass on the three new modules + touch-ups in Strengths
Concerns / suggestions (all minor)1. On retry, 2. The leading boundary character class accepts whitespace, quotes, backtick, or open-paren before the field path. It does not include 3.
4. Advising "Retry once" for internal errors is fine for transient issues but may mislead users when the error is a persistent code bug. Consider "the issue may be transient — retry once" or similar to hedge. Style/UX only. 5. Deep copy is unconditional ( The deepcopy runs before path resolution, so an unresolvable-path return-None case still pays the copy cost. Only fires on 400s so effectively free — style-only. Style / conventions
Not seen in this PR (out of scope, but worth tracking)
Nice work. LGTM once #1 is either fixed or explicitly documented. 🤖 Generated with Claude Code |
- Harden append_advice double-append check to match the exact join
string, so an upstream message containing the bare word 'Suggestion:'
still gets advice appended.
- Accept quote/backtick-wrapped field paths in the extra-field 400
pattern ('"banana_mode": Extra inputs are not permitted').
- New tests: empty-message advice invariant, Suggestion-in-raw-message,
JSON-quoted field path, and both backend attempts appearing in the
pipeline.backend_request audit trail on retry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code reviewOverall this is a solid, well-scoped PR. The invariants are stated up-front and the tests enforce them; the two modules ( A handful of things worth considering before merging — none blocking, one worth thinking about. Potential issue:
|
|
Post-review note (adversarial verification pass, Jul 7): this PR and its sibling both add a one-shot upstream retry to the same two methods in |
|
Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations. Recommendation: close; salvage the error-advice half separately if wanted. The retry-with-fix half is always-on, ungated client-request mutation: any 400 matching "Extra inputs are not permitted" gets the offending field stripped from the client's own request and retried (logged and evented, but not configurable). That conflicts with three settled positions at once: the transparency contract pinned in #798 (whose The first half ( |
Implements Trello: COE audit: Replace raw API JSON errors with actionable user-facing messages and implement retry-with-fix for fixable 400 errors.
What changed
1. Actionable error messages (every error path in the Anthropic pipeline)
New
pipeline/error_advice.pymaps known upstream error shapes (status code + message pattern) to a short actionable suggestion, appended to the client-facing message as aSuggestion:line. Wired into:_handle_anthropic_error(non-streaming errors ->BackendAPIError-> JSON error response)_build_error_event(mid-stream SSEerrorevents)Design invariants:
error.type/error.messageprogrammatically keep working and no information is destroyed. The Anthropic error response shape ({"type": "error", "error": {"type", "message"}}) is unchanged.append_adviceis idempotent (no double-appending if an error is formatted twice).VERBOSE_CLIENT_ERRORS=false, internal exception details are still withheld; only the generic message + advice go out.2. Retry-with-fix for a known fixable 400 pattern
New
pipeline/request_repair.pyhandles the "invalid extra field" pattern: when the upstream API rejects a request with"<field.path>: Extra inputs are not permitted", the pipeline locates that field in the payload, strips it from a deep copy, and retries the backend call exactly once (_AnthropicPolicyIO.complete()and.stream()).Safety rails:
pipeline.retry_with_fixevent (original error, removed field, session/user id) and logs a warning; the repaired backend request is also recorded via the normalpipeline.backend_requestevent, so the audit trail shows both attempts.model,messages,max_tokens,streamare never auto-removed.Tests
tests/.../pipeline/test_error_advice.py(20 tests): advice lookup per status/pattern, raw-message preservation, idempotence.tests/.../pipeline/test_request_repair.py(8 tests): top-level and nested field stripping, protected fields, unresolvable paths, no mutation, field path embedded in longer SDK message.tests/.../pipeline/test_anthropic_processor.py(+5 tests inTestRetryWithFix): non-streaming retry succeeds with field stripped +pipeline.retry_with_fixemitted; retry capped at one attempt; unfixable 400 not retried; streaming pre-first-event retry; streaming post-event 400 NOT retried. Existing error-path tests extended to assertSuggestion:presence and that internal details stay sanitized.Results:
./scripts/dev_checks.shfully green (ruff, pyright 0 errors, 2873 unit tests passed)../scripts/run_e2e.sh sqliteand./scripts/run_e2e.sh mockboth pass.COE context (card source: COE audit 2026-03-25, PR #204)
append_advice), andTestRetryWithFixpins the retry contract (one attempt, observable event, streaming guard).Follow-ups (out of scope, separate concern)
scripts/run_e2e.shbug on macOS:mktemp /tmp/mock-gateway-XXXXXX.jsondoes not expand the template on BSD mktemp (suffix afterXXXXXXis unsupported), creating a literalmock-gateway-XXXXXX.jsonfile; the next run fails with "File exists". Deserves its own small fix PR.request_repair.pyas they are observed (the module is pure and pattern-driven).🤖 Generated with Claude Code