fix(passthrough): record and classify ambiguous transport failures instead of leaking a 500 - #1082
fix(passthrough): record and classify ambiguous transport failures instead of leaking a 500#1082seonghobae wants to merge 1 commit into
Conversation
…stead of leaking a 500 A chat request with tools takes proxy_completion's single-attempt walk. When the first-ranked candidate's socket read times out, proxy_send_once re-raises the bare TimeoutError; the walk's except recognises neither a failover error nor an HTTP/upstream error in it and re-raises it raw; the HTTP handler's generic branch answers 500 internal_error; and _record_failure is never reached, so the same stalled candidate ranks first on the next request. ContextualWisdomLab/.github#1812 (Strix run 33993155419): 83 x 500 over 2.5 h, ~90 s apart, the same route every time. A request without tools takes the orchestrated walk, whose exhaustion always classifies (.github#1661: 0 x 500, 1 x 502) -- the two failure classes are the two request shapes. Source chain on #1045. The fail-closed rule for ambiguous outcomes stays exactly as pinned by test_ambiguous_timeout_is_not_replayed (121aec0): no replay on another candidate. What "fail closed" produces changes: - New _is_ambiguous_passthrough_transport_failure: TimeoutError, ConnectionError, http.client.HTTPException, or a URLError that is neither an HTTPError nor a DNS failure, anywhere in the exception chain. - In the walk, for such a failure: _record_failure (and the group router), then raise classify_provider_failure(..., transport="passthrough") -> ProviderUpstreamError(provider_connection_error, 502, retryable=True). The walk does not advance. _is_passthrough_failover_error and proxy_send_once's raw contract are untouched. - classify_provider_failure also maps http.client.HTTPException (IncompleteRead, BadStatusLine -- a connection dropped mid-read, which provider_error_body's own note already calls a transport failure) to provider_connection_error instead of the opaque api_error default. Tests: test_ambiguous_timeout_is_not_replayed now expects the classified 502 and a breaker observation; five parametrised transport shapes; predicate exclusions (HTTP status, DNS, ValueError, chain limit); group-router observation; taxonomy test for IncompleteRead/BadStatusLine. Negative control: the ambiguous-failure tests fail on main's walk (bare TimeoutError escapes). Gate: 3403 passed, 2 skipped, interrogate 100%; the two coverage tests added afterwards pass and cover the remaining new lines. Refs #1045, #1081, ContextualWisdomLab/.github#1812, ContextualWisdomLab/.github#1950. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthrough패스스루 전송 실패를 예외 원인 체인에서 판별합니다. 모호한 실패는 후보를 재시도하지 않고 회로 차단기와 그룹 라우터에 기록합니다. 해당 실패와 HTTP 클라이언트 예외는 Changes패스스루 전송 실패 처리
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to Ambiguous passthrough transport failures now return a retryable 502 provider connection error and are recorded without replaying the request. The covered behavior and exclusions are implemented and tested, with no remaining concrete merge-blocking risk. Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Post-#1081 confirmation of this PR's class at pin
One residual to weigh with this change, read at the same pin: Generated by Claude Code |
|
Third "before" sample on pin Your two samples both ran at One honest limit on my numbers: I read the job log, not the On merge state. This PR's own gates are green — fuzzing, I am not merging it: it carries your lane-claim ( What I will do the moment it lands: advance Generated by Claude Code |
|
One interaction worth knowing before the pin advance, because it can make this PR's own acceptance evidence unreadable.
Your Acceptance evidence after deploy section reads: once the sidecar pin moves past this fix, Two consequences, offered rather than proposed:
I have no claim on either PR and am not asking for a change to this one; recorded in Generated by Claude Code |
The correction above defers option (a) to a "not-yet-built durable candidate-exclusion/skip mechanism". Reading TaskOrchestrator at pin 414f229 shows a per-agent breaker already exists and still would not exclude a stalled candidate, for two independent reasons: 1. The tool-bearing passthrough re-raises a bare TimeoutError as 500 internal_error before _record_failure (orchestrator.py:8048) runs, so the breaker never counts the failure. Measured: 0 of 21, 0 of 48, 0 of 63 and 0 of 65 passthrough timeouts recorded as circuit_failure, against 9/14 and 10/15 on the no-tools _invoke route-walk. That is #1082's scope. 2. _circuit_open clears state["failures"] to 0.0 once circuit_reset_seconds (30.0) have elapsed since opened_at (orchestrator.py:8036-8038). Against the ~90s attempts these stalls take, a route is re-admitted after 30s and needs three fresh failures to be excluded again. Also records that #911 remains unmerged as of this amendment. The conclusion is unchanged: a fixed wall-clock deadline on the candidate/retry loop is still barred by product-goal-directive section 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BV96rXhqoR3tYZ9AeAVur4
|
Fourth "before" sample at the current pin
Sidecar stderr:
52 timeouts, 5 circuit records. The breaker is told about the HTTP failures and essentially never about the timeouts — your unreached One honest deviation from your two samples: mine is 52 timeouts against 48 five-hundreds, not 1:1. I have not accounted for the four-timeout gap and am not going to guess at it — possibly the final attempt after the walk gave up, possibly attempts whose failure arrived after the response was already committed. Reporting the counts as measured rather than rounding them to your ratio. What is new here beyond one more data point. This same PR head produced three different gateway failures within ninety minutes, which separates request shape from pool state better than any single run:
That is your "the two failure classes are the two request shapes" claim reproduced on a third repository, with the passthrough case at healthy readiness — six ready routes, so it is not a capacity artefact. Separately, and this is a correction rather than support: I earlier described a Still not merging this — it carries your lane-claim. The Generated by Claude Code |
|
Fifth sample, and with it the relationship stops being anecdotal. Across all four runs I can now count at pin
One thing I am not claiming: the small residue.
That is all from me on this thread unless something new turns up — you have enough samples. Pin advance offer unchanged. Generated by Claude Code |
이 PR 자신의
|
Verification of the unestablished link: the premise holds, the causal chain splitsReviewed at head 1. The "never-recorded" premise is structurally confirmed
Those therefore reach the bare 2. Two recording mechanisms, and only one can change routing at the observed cadenceThe new block calls both. They behave very differently:
The circuit path fails on its own timing. The breaker opens and resets before the next request arrives, every time. At that cadence The group-router path has no reset window, so 3. What this leaves open — one checkable conditionThe new code gates the effective half: self._record_failure(candidate.id)
if candidate.group_name: # <- only grouped candidates get the persistent demotion
self._group_router.observe_failure(candidate.id)So the routing effect depends on whether the I could not settle that statically. It is one runtime assertion or one fixture away, and it is the 4. Independent of all the aboveReplacing an unclassified Note on standingI am not recording this as an approval. Sessions here share one GitHub identity, so a formal APPROVE on |
The remaining condition closes against the routing claim — independently reproducedFollowing up on my previous comment, which left one thing open: whether Only two paths ever set a non-empty So in the new block self._record_failure(candidate.id)
if candidate.group_name: # empty for a discovered free-pool candidate
self._group_router.observe_failure(candidate.id)the second call is skipped for exactly the route that produced the incident, and — per my previous Both routing mechanisms are therefore inert on this path. The merge case rests on the A near-miss worth recording, because it would have been a bad recommendationI was about to point at base measured = bool(agent.group_name or requested_model == self.FREE_MODEL)That reads as "the codebase already records group-router evidence for free-model requests even when the if requested_model not in (None, self.GATEWAY_DEFAULT_MODEL, self.AUTO_MODEL, self.FREE_MODEL): # :4319so Separate from this PR, that dead disjunct at Standing, restatedEvidence, not an approval — this account authored the PR, so a formal APPROVE is refused and |
Mutation test on the replay guard: the block is covered, but the property is not separableRan against head Coarse control — deleting the new branchSo the block is genuinely covered, and all five transport classes the predicate names are exercised But this control cannot answer the question it looks like it answers. With the branch removed a bare The finer mutation has no targetThe suggested test was: disable the replay guard and watch 4468 self._record_failure(candidate.id)
4469 if candidate.group_name:
4470 self._group_router.observe_failure(candidate.id)
4471 raise classify_provider_failure( # <- one statement, two effects
4472 exc, agent_id=candidate.id, model=candidate.model, transport="passthrough",
4476 ) from NoneClassification and non-replay are the same statement. Any mutation that permits replay also removes And non-replay is not new behaviourBefore this change the bare What is verified about scopeThe new branch sits inside Net effect on meritThe safety property this test was meant to pin turns out to be structural rather than guarded, so the Combined with the two earlier comments, the merit rests on the classification change |
|
Fresh exact-head gate review at 812bf11 confirms the source/package, fuzz, Semgrep, Trivy, Scorecard, and direct CodeQL checks passed. The remaining required review evidence is not GREEN: Noema exhausted the free gateway pool, Strix reported The valid passthrough transport-classification and failure-recording delta remains intact and is still needed by #1043's observed breaker path. Because exact-head independent review is incomplete, this PR is not merge-ready and will remain alive as Draft rather than being closed or merged on predecessor/source-only evidence. |
|
Exact-head gate update for |
Current exact-head gate — 2026-09-07
812bf11fefcd0f671c2890485cf403fc6df765baSummary
A chat request with tools takes
proxy_completion's single-attempt passthrough walk. When the first-ranked candidate's socket read times out,proxy_send_oncere-raises the bareTimeoutError; the walk'sexceptrecognises neither a failover error nor an HTTP/upstream error in it and re-raises it raw; the HTTP handler's generic branch answers500 internal_error; and_record_failureis never reached, so the same stalled candidate is ranked first on the next request too. This PR keeps the existing fail-closed rule for ambiguous outcomes — no replay on another candidate (test_ambiguous_timeout_is_not_replayed, commit121aec01) — and changes only what "fail closed" produces: the candidate is recorded in the breaker and the caller receives the classified502 provider_connection_errorthatclassify_provider_failurealready defines for these types.Evidence
ContextualWisdomLab/.github#1812, Strix run 33993155419 (lane peer 1): 83 ×500 internal_errorover 2.5 h, each ~90 s after its request (the per-recv timeout), the same first-ranked route every time; the three503s in the same run went through the classified path asapi_error.ContextualWisdomLab/.github#1661, Noema run 33995553859 (DEBUG artifact): sends no tools, takes the orchestrated walk (_invoke→_send_with_retry,attempt=n/3), whose exhaustion always classifies — 0 × 500, 1 × 502. The two failure classes are the two request shapes.414f2297(lane peer 1,ContextualWisdomLab/.github#1930Strix run 34008575120, 05:12–06:47Z): preflight ready 4 of 12; over the 83-minute scan,provider_attempt83,provider_attempt_failed … error_type=TimeoutError transient=True48,request_failed status=500 code=internal_error48 — one 500 per timeout, 1:1. The breaker reacted only to the 14HTTPErrors (circuit_failure6,circuit_opened1,circuit_reset1) and zero times to the 48 timeouts, which is the unreached_record_failurepath this PR closes. fix(orchestrator): stop stacking transport retries under _invoke's failover #1081 (already in that pin) changed nothing on this path, as predicted.ContextualWisdomLab/.github#1916Strix run 34008489633, 05:08–07:24Z, artifact 9984863327): preflight ready 4 of 12;provider_attempt139,provider_attempt_failed … TimeoutError63,request_failed status=500 code=internal_error63 (1:1 again); the 23HTTPErrors (19 transient, 4 permanent) producedcircuit_failure13 /circuit_cleared9 while the 63 timeouts produced none; Strix sawError code: 500×6 and 503 ×1 and the gate failed closed after 7586 s.464da471, identical at2e414d15andmain@414f2297; line numbers from464da471):server.py:7003-7010tool loop →proxy_completion(single_agent=True);orchestrator.py:4366-4369proxy_send_once→_proxy_send(allow_transient_retries=False)→:2673-2674 raise last_errorraw;:4370-4379if not _is_passthrough_failover_error(exc)→ notHTTPError/ProviderUpstreamError→ bareraise,_record_failure(:4388) unreachable;_send_raw:2700-2701reads the body insidewith _open_provider(...), so a read timeout is a bareTimeoutError;server.py:7990-8003has no branch for it beforeexcept Exception → 500. Full write-up on fix(gateway): fail over long orchestrator/free transport 502 with typed attempt evidence #1045.Change
_is_ambiguous_passthrough_transport_failure(exc):TimeoutError(socket.timeouton ≥ 3.10),ConnectionError,http.client.HTTPException(IncompleteRead,RemoteDisconnected), or aurllib.error.URLErrorthat is neither anHTTPErrornor a DNS failure, anywhere in the exception chain (same chain walk and limit as the failover predicate). HTTP statuses and DNS failures are excluded: a status came back, or nothing was sent.raise: for an ambiguous transport failure,_record_failure(candidate.id)(and the group router), thenraise classify_provider_failure(exc, …, transport="passthrough") from None→ProviderUpstreamError(provider_connection_error, 502, retryable=True). The walk does not advance._is_passthrough_failover_error,proxy_send_once's raw contract (the.githubpreflight readsHTTPError.codefrom it), and the HTTP handler are untouched.classify_provider_failureadditionally mapshttp.client.HTTPException(IncompleteRead,BadStatusLine— a connection dropped mid-read, whichprovider_error_body's own note already calls a transport failure) to the same retryableprovider_connection_errorinstead of the opaqueapi_errordefault, so all five transport shapes classify identically (test_truncated_read_classifies_as_provider_connection_error).Tests (
tests/test_passthrough_provider_failover.py)test_ambiguous_timeout_is_not_replayedkeeps its name, its single-call assertion, and its intent; it now expects the classified 502 (provider_connection_error,retryable,transport="passthrough",__cause__ is None) andprimary_agentin_circuit, with the reason in its docstring.test_ambiguous_transport_failure_is_classified_and_recorded× 5 (read timeout, reset,IncompleteRead,RemoteDisconnected,URLError(TimeoutError)built the way urllib builds it): classified 502, single call, breaker recorded, predicate True.test_ambiguous_transport_predicate_excludes_status_and_dns_failures:HTTPError 401/503,URLError(gaierror),ValueError→ False.main's walk (bareTimeoutErrorescapes). Full gate in the commit message.Acceptance evidence after deploy
The
.githubsidecar pin moved to414f2297on 2026-09-06 (.github@efb89269, carrying #1081's retry-stacking fix); this PR lands after that commit, so a further pin advance inscripts/ci/contextual_orchestrator_review_sidecar.shis needed before Strix sees it. WithContextualWisdomLab/.github#1950(the sanitizer names the exception type) the next failing Strix artifact before this fix showsunexpected_exception type=TimeoutError frame=contextual_orchestrator/orchestrator.py:…:_send_raw; once the.githubsidecar pin moves past this fix that line must be gone and the same situation must read as aprovider_connection_error502 with acircuit_failureline for the candidate.Not in this PR
ssl.SSLErroris also a transport failure but was not observed; it keeps today's raw path. The 30 s breaker reset versus a 90 s attempt (#1045) is unchanged — this PR makes sure the breaker is told at all on the passthrough path.Developer experience
One small predicate and one branch in the walk, next to the branch that already classifies HTTP failures; the failover rules are untouched.
User experience
A review whose route stalls gets a provider-connection error it can act on instead of an opaque internal error, and a route that keeps stalling stops being tried first.
🤖 Generated with Claude Code