feat: opt-in passthrough fallback when a policy modification causes an upstream 4xx - #797
feat: opt-in passthrough fallback when a policy modification causes an upstream 4xx#797scottwofford wants to merge 2 commits into
Conversation
…icy-caused upstream failures
|
CHANGELOG reminder — This PR has no changelog fragment. Add a file to |
Review Status: No Changes to ReviewThis draft PR currently contains only the objective-setting empty commit ( Happy to review once implementation lands. A few things I'll be watching for when the code arrives, given the objective ("opt-in passthrough fallback when a policy modification causes an upstream 4xx"): Correctness / semantics
Opt-in surface
Security
Tests (per CLAUDE.md "Test Requirements")
Docs / changelog
Ping me once there's a diff and I'll do a real review. |
…n upstream 4xx Implements https://trello.com/c/kRPRjGUx (COE audit follow-up to PR #204). Design principle: the proxy should never make things worse than direct API access. Trigger (all must hold): - PASSTHROUGH_FALLBACK_ENABLED is on (default off) - upstream AnthropicStatusError with status in {400, 404, 413, 422} - the request sent differs from the pristine pre-policy snapshot - (streaming) zero backend events received yet The fallback lives at the backend-call site (_AnthropicPolicyIO), so intentional policy blocks — which are response rewrites or policy-raised errors, never upstream errors — structurally cannot be overridden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code Review: Passthrough FallbackOverall this is a clean, focused PR that lands a well-scoped follow-up to the PR #204 audit. The trigger predicate is conservative in the right ways, the deep-copy snapshot is the right shape for the in-place-mutation case, and the observability + test coverage are solid. Findings below are minor. Correctness observations1.
Not blocking — but worth being explicit about for downstream observability. 2. Pre-existing in-place-mutation risk on 3. Streaming: async for mse in _iterate(final_request):
events_yielded += 1
yield mseIf a downstream consumer of Nits
Test coverageComprehensive. The parametrized One suggestion: add a test asserting the Design principleFalling back inside Style / conventions
LGTM with the observability note above worth considering before merge. Nothing here is blocking. |
|
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: merge, after #799 is closed (see the comment there). Verified in the diff: |
Implements Trello: COE audit passthrough fallback (P1 follow-up from the 2026-03-25 COE audit of PR #204).
Summary
Opt-in passthrough fallback: when a policy-modified request is rejected upstream with a request-shaped 4xx, the gateway retries once with the original unmodified request. Design principle from the card: the proxy should never make things worse than direct API access.
This is deliberately the narrow slice of PR #204 that survived its review. #204 bundled sanitization and auto-fix layers that imposed unrequested changes to the client-server interaction and was closed. Passthrough fallback is the opposite move: on failure it removes the proxy's own modifications and sends exactly what the client sent, rather than adding new ones. No sanitization, no pattern-matched auto-fixes, no error rewriting.
Trigger condition (all must hold)
PASSTHROUGH_FALLBACK_ENABLEDis on (default: off, see tradeoff below)AnthropicStatusErrorwith status in{400, 404, 413, 422}(request-shaped failures). Excluded: 401/403 (credential-scoped, a different body won't change the outcome), 429 (retry amplifies load under throttling), 5xx/529 (SDK already retries server errors)Exactly one retry. If the original request also fails, that error propagates: the client sees exactly what direct API access would have returned.
Why intentional blocks cannot be overridden
Checked before choosing the trigger: policies in this codebase block by rewriting responses (
ToolCallJudgePolicyreplacestool_useblocks with block-message text), emitting synthetic events, or raising from a hook (fail-secure judge pattern). None of these surface as an upstreamAnthropicStatusErrorfrom the backend call. The fallback lives inside_AnthropicPolicyIO.complete()/stream()at the backend-call site and triggers only on upstream status errors, so it is structurally unable to fire on a policy block. Two unit tests pin this down (test_intentional_response_block_is_untouched,test_policy_raised_error_is_not_a_fallback_trigger).Observability
Fallback is recorded BEFORE the retry is attempted, so a policy failure is never silently masked:
pipeline.passthrough_fallbackevent (status code, error message, session/user id) via the emitter, visible in the activity monitorluthien.passthrough_fallback = trueattribute on thesend_upstreamspanpipeline.backend_request, so the event trail shows both attemptsLoad-bearing tradeoff: default off
Falling back means sending the request WITHOUT the policy's modifications. For a policy that rewrites requests for safety (e.g. redaction), that is fail-open: a bug in the policy would leak exactly what the policy exists to remove. For an AI-control proxy, fail-closed (surface the error) is the safer default, so the flag ships off and an operator opts in per deployment (
PASSTHROUGH_FALLBACK_ENABLED=true, or live via the config dashboard / admin API, no restart needed). Flipping the default later is a one-character change inconfig_fields.py.Related judgment call, flagged for review: the "original request" snapshot is taken as the request enters the policy, i.e. after the
INJECT_POLICY_CONTEXTsystem-prompt injection. Fallback removes what the active policy changed, not the injection. Strictest reading of "never worse than direct API access" would fall back to the raw client body; that would also silently disable policy-context injection and is left out of scope (the card scopes this to policy modifications).When disabled the request path is unchanged (the snapshot deepcopy is only taken when enabled), so no-op stays no-op.
Files changed
src/luthien_proxy/pipeline/anthropic_processor.py_AnthropicPolicyIO.complete()/stream(), trigger predicate, observabilitysrc/luthien_proxy/config_fields.pypassthrough_fallback_enabledfield (db-settable, no restart)src/luthien_proxy/settings.py,.env.exampletests/luthien_proxy/unit_tests/pipeline/test_anthropic_processor.pyTestPassthroughFallback)tests/luthien_proxy/e2e_tests/test_mock_passthrough_fallback.pychangelog.d/passthrough-fallback.mdTest plan
test_anthropic_processor.py(16 new: fallback fires on 400 with modified request; disabled by default; unmodified request never retries; 401/403/429/500/529 never fall back; retry failure propagates; in-place policy mutation detected via deepcopy snapshot; streaming connect-failure fallback; mid-stream never falls back; intentional blocks untouched)./scripts/dev_checks.shpasses (format, lint, pyright, unit tests, complexity)🤖 Generated with Claude Code