Skip to content

fix(passthrough): record and classify ambiguous transport failures instead of leaking a 500 - #1082

Draft
seonghobae wants to merge 1 commit into
mainfrom
fix/passthrough-transport-failover
Draft

fix(passthrough): record and classify ambiguous transport failures instead of leaking a 500#1082
seonghobae wants to merge 1 commit into
mainfrom
fix/passthrough-transport-failover

Conversation

@seonghobae

@seonghobae seonghobae commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Current exact-head gate — 2026-09-07

  • Exact head: 812bf11fefcd0f671c2890485cf403fc6df765ba
  • Lifecycle: Draft / Proposed
  • Source/package, fuzz, Semgrep, Trivy, Scorecard, and direct CodeQL evidence is terminal-success.
  • Noema and Strix exhausted/unavailable through the governed free path; their safe exact-head reruns are queued. OpenCode still lacks an authenticated exact-head verdict. These states are non-passing.
  • The valid ambiguous-transport classification and breaker-recording delta remains live; it is not closed or merged on source-only/predecessor evidence.

Summary

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_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 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, commit 121aec01) — and changes only what "fail closed" produces: the candidate is recorded in the breaker and the caller receives the classified 502 provider_connection_error that classify_provider_failure already defines for these types.

Evidence

  • ContextualWisdomLab/.github#1812, Strix run 33993155419 (lane peer 1): 83 × 500 internal_error over 2.5 h, each ~90 s after its request (the per-recv timeout), the same first-ranked route every time; the three 503s in the same run went through the classified path as api_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.
  • "Before" sample at the current pin 414f2297 (lane peer 1, ContextualWisdomLab/.github#1930 Strix run 34008575120, 05:12–06:47Z): preflight ready 4 of 12; over the 83-minute scan, provider_attempt 83, provider_attempt_failed … error_type=TimeoutError transient=True 48, request_failed status=500 code=internal_error 48 — one 500 per timeout, 1:1. The breaker reacted only to the 14 HTTPErrors (circuit_failure 6, circuit_opened 1, circuit_reset 1) and zero times to the 48 timeouts, which is the unreached _record_failure path 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.
  • Second "before" sample, same pin (lane peer 1, ContextualWisdomLab/.github#1916 Strix run 34008489633, 05:08–07:24Z, artifact 9984863327): preflight ready 4 of 12; provider_attempt 139, provider_attempt_failed … TimeoutError 63, request_failed status=500 code=internal_error 63 (1:1 again); the 23 HTTPErrors (19 transient, 4 permanent) produced circuit_failure 13 / circuit_cleared 9 while the 63 timeouts produced none; Strix saw Error code: 500 ×6 and 503 ×1 and the gate failed closed after 7586 s.
  • Source chain (read at the run's pin 464da471, identical at 2e414d15 and main@414f2297; line numbers from 464da471): server.py:7003-7010 tool loop → proxy_completion(single_agent=True); orchestrator.py:4366-4369 proxy_send_once_proxy_send(allow_transient_retries=False):2673-2674 raise last_error raw; :4370-4379 if not _is_passthrough_failover_error(exc) → not HTTPError/ProviderUpstreamError → bare raise, _record_failure (:4388) unreachable; _send_raw:2700-2701 reads the body inside with _open_provider(...), so a read timeout is a bare TimeoutError; server.py:7990-8003 has no branch for it before except Exception → 500. Full write-up on fix(gateway): fail over long orchestrator/free transport 502 with typed attempt evidence #1045.

Change

  • New _is_ambiguous_passthrough_transport_failure(exc): TimeoutError (socket.timeout on ≥ 3.10), ConnectionError, http.client.HTTPException (IncompleteRead, RemoteDisconnected), or a urllib.error.URLError that is neither an HTTPError nor 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.
  • In the passthrough walk, after the existing HTTP/upstream classification branch and before the bare raise: for an ambiguous transport failure, _record_failure(candidate.id) (and the group router), then raise classify_provider_failure(exc, …, transport="passthrough") from NoneProviderUpstreamError(provider_connection_error, 502, retryable=True). The walk does not advance. _is_passthrough_failover_error, proxy_send_once's raw contract (the .github preflight reads HTTPError.code from it), and the HTTP handler are untouched.
  • classify_provider_failure additionally maps http.client.HTTPException (IncompleteRead, BadStatusLine — a connection dropped mid-read, which provider_error_body's own note already calls a transport failure) to the same retryable provider_connection_error instead of the opaque api_error default, 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_replayed keeps its name, its single-call assertion, and its intent; it now expects the classified 502 (provider_connection_error, retryable, transport="passthrough", __cause__ is None) and primary_agent in _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.
  • Negative control: the six ambiguous-failure tests fail on main's walk (bare TimeoutError escapes). Full gate in the commit message.

Acceptance evidence after deploy

The .github sidecar pin moved to 414f2297 on 2026-09-06 (.github@efb89269, carrying #1081's retry-stacking fix); this PR lands after that commit, so a further pin advance in scripts/ci/contextual_orchestrator_review_sidecar.sh is needed before Strix sees it. With ContextualWisdomLab/.github#1950 (the sanitizer names the exception type) the next failing Strix artifact before this fix shows unexpected_exception type=TimeoutError frame=contextual_orchestrator/orchestrator.py:…:_send_raw; once the .github sidecar pin moves past this fix that line must be gone and the same situation must read as a provider_connection_error 502 with a circuit_failure line for the candidate.

Not in this PR

ssl.SSLError is 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

…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>
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: d15519d7-b162-43bf-9d2b-762ba11af6ae

📥 Commits

Reviewing files that changed from the base of the PR and between 414f229 and 812bf11.

📒 Files selected for processing (5)
  • CHANGELOG.d/passthrough-transport-failover.md
  • contextual_orchestrator/orchestrator.py
  • contextual_orchestrator/provider_errors.py
  • tests/test_passthrough_provider_failover.py
  • tests/test_provider_error_taxonomy.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

패스스루 전송 실패를 예외 원인 체인에서 판별합니다. 모호한 실패는 후보를 재시도하지 않고 회로 차단기와 그룹 라우터에 기록합니다. 해당 실패와 HTTP 클라이언트 예외는 502 provider_connection_error로 분류합니다.

Changes

패스스루 전송 실패 처리

Layer / File(s) Summary
전송 오류 분류 계약
contextual_orchestrator/provider_errors.py, tests/test_provider_error_taxonomy.py
http.client.HTTPException을 재시도 가능한 502 provider_connection_error로 분류합니다. IncompleteReadBadStatusLine에 대한 검증을 추가합니다.
패스스루 후보 fail-closed 흐름
contextual_orchestrator/orchestrator.py, tests/test_passthrough_provider_failover.py, CHANGELOG.d/passthrough-transport-failover.md
예외 원인 체인에서 모호한 전송 실패를 판별합니다. 후보 실패, 회로 차단기, 그룹 라우터에 기록한 뒤 분류된 오류를 반환합니다. 상태 오류와 DNS 오류, 제한 초과 체인은 제외합니다.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to 812bf

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: claude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 3 files. (2 skipped: 1…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목은 모호한 패스스루 전송 실패를 기록하고 분류하여 500 오류 누출을 방지하는 주요 변경 사항을 정확하고 간결하게 설명합니다.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/passthrough-transport-failover

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copy link
Copy Markdown
Contributor Author

Post-#1081 confirmation of this PR's class at pin 414f2297, from the first Strix run on the new pin in .github (lane jan): .github#1930 run 34008575120, artifact strix-reports 9984330203, sidecar stderr under the DEBUG trace.

  • Preflight 05:17Z ready 4 / rejected 8 (both NIM keys' flash and pro ready). Serving 05:24–06:47Z: Strix issued 8 requests that completed with usage; the gateway logged 48 TimeoutErrors → 48 × 500 internal_error, 44 of them on nvidia_nim deepseek-v4-flash, the first-ranked candidate, at 90 s each — 66 of the run's 86 minutes — until Strix's stream idle timeout ended the scan.
  • Adjacency check over the 153 trace events: 0 of the 48 timeouts is followed by a circuit_failure record; 6 of the 14 fast HTTP failures are (those went _invokeclassify_provider_transport_failure and failed over to nvidia_nim_sub). attempt=1/1 throughout, no 2/3: fix(orchestrator): stop stacking transport retries under _invoke's failover #1081's stacking is gone, and what remains is exactly your source chain — the stalled first-ranked route is never recorded, so it is first again on every retry.

One residual to weigh with this change, read at the same pin: _circuit_open (orchestrator.py:8031-8046) resets failures to 0 after circuit_reset_seconds = 30.0, and _record_failure opens only at threshold = 3. Once timeouts are recorded, a route whose every attempt takes the full 90 s recv timeout costs three attempts (270 s) to open the breaker, is re-admitted 30 s later with a clean count, and costs three more. On this run that arithmetic gives about 4.5 of every 5 minutes still spent on the stalled route, versus 90 s per request for a candidate that answers in seconds. Recording is the necessary half; whether the reset window should be measured against the attempt timeout (or a stalled route's reset should not restart from zero) is the other half, and it is a policy question I am not proposing an answer to here — only noting that the artifact will look much the same after this merges unless it is addressed too.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Third "before" sample on pin 414f2297, from a different repository and a boot that reached six ready routes. ContextualWisdomLab/.github#1187 strix job 101451547867, 08:37–12:12Z (scan 08:51→12:12, 3 h 21 m), head 3da6596a:

probed 16, ready 6, rejected 8, deferred 2, skipped 4   (candidate_count 24)
healthz and provider-route preflight confirmed after 400s
...
STRIX_PROVIDER_UNAVAILABLE: contextual-orchestrator/orchestrator/free exhausted
Vulnerabilities  0

Your two samples both ran at ready 4 of 12; this one had 6 of 16 after #1949's lazy fill and still ended the same way. That removes the last confound I can think of on your evidence: the class is not "too few ready routes to fail over to". Six routes were available and the walk still exhausted the pool over three and a half hours on a two-file changed-scope scan.

One honest limit on my numbers: I read the job log, not the strix-reports artifact, so the provider_attempt / TimeoutError / internal_error lines I can count are only the sidecar-startup echo, not the full sanitized stderr. I am deliberately not quoting a timeout:500 ratio from it — your 48:48 and 63:63 are the measured ones. What the job log does establish independently is the preflight readiness and the wall-clock-to-exhaustion with that readiness.

On merge state. This PR's own gates are green — fuzzing, coverage-evidence, coverage-source-tree, Semgrep, trivy-fs, scorecard, CodeQL, admit-current-head, required-workflow-bootstrap. Everything red on it is the review path itself: noema-review (04:52Z), strix (04:57–10:08Z, 5 h 11 m), opencode-review (dispatch-pending), CodeQL-compat ×3 (dispatch-pending). So the PR that fixes the passthrough class cannot merge because that class is what fails its own required reviews — the same chicken-and-egg that .github#1951 hit, where a bypass merge was the resolution.

I am not merging it: it carries your lane-claim (host1/passthrough-transport-failover) and you are actively working it, and I do not take another lane's claimed PR on my own judgement. This is a note that the blocker is external to your change, in case that is useful for the merge decision you own.

What I will do the moment it lands: advance .github's sidecar pin to the merge commit, in the same four places #1951 used — scripts/ci/contextual_orchestrator_review_sidecar.sh's ORCHESTRATOR_PIN_SHA, tests/test_contextual_orchestrator_review_sidecar_contract.py's ORCH_PIN_SHA, an ADR-0003 amendment, and CHANGELOG.md — since without that advance the fix reaches no consumer, exactly as your "Acceptance evidence after deploy" section says. Ping me here or on .github#1948 and I will pick it up.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

One interaction worth knowing before the pin advance, because it can make this PR's own acceptance evidence unreadable.

#1053 ("fix(gateway): remove implicit model request timeout", head 661ce8db, pushed 12:48Z) changes ModelClient.__init__(timeout: int = 90)timeout: float | None = None at contextual_orchestrator/orchestrator.py:1696, and propagates the None through _local_provider_slot's deadline arithmetic, endpoint_race.py, cost_router.py, batch_routing.py, server.py, and the synchronous embedding path. That 90 s is the same one behind every TimeoutError in your three "before" samples — it is a library default, not a deadline the central repo sets.

Your Acceptance evidence after deploy section reads: once the sidecar pin moves past this fix, unexpected_exception type=TimeoutError frame=…_send_raw must be gone and the same situation must read as a provider_connection_error 502 with a circuit_failure line. If #1053 merges first and both reach the pin together, the TimeoutError line disappears because the socket no longer times out — the read blocks instead — and the absence of that line stops being evidence that this PR's branch ran. The 502 + circuit_failure half stays valid but gets much rarer, since it would then need a reset, truncated read, or upstream-imposed cutoff rather than the 90 s expiry that produced 48 and 63 of them.

Two consequences, offered rather than proposed:

  • If you want the clean before/after, the pin should advance to a commit that carries this PR and not #1053, and the artifact read at that pin. If they land in either order in main, that window may not exist.
  • If they do land together, an acceptance criterion that survives is the positive one: a circuit_failure/circuit_opened line attributable to a non-HTTP transport failure at all. On the current pin that count is zero across all three samples (0 of 48, 0 of 63, and the #1187 scan), so any non-zero is this PR's branch executing.

I have no claim on either PR and am not asking for a change to this one; recorded in .github#1884 (docs/product-technical-gap-baseline.md, residual (iii)) so the ordering is not rediscovered later. The pin-advance offer from my previous comment stands for whichever of the two lands last.


Generated by Claude Code

seonghobae pushed a commit that referenced this pull request Sep 6, 2026
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

Copy link
Copy Markdown
Contributor Author

Fourth "before" sample at the current pin 414f2297, and this time with the artifact numbers rather than a job-log estimate. On 2026-09-06 at 12:19Z I told you I was deliberately not quoting a timeout:500 ratio because I could only read the job log. I can now — the strix-reports artifact downloads fine over GET /repos/{owner}/{repo}/actions/artifacts/{id}/zip — so here is the measured version.

ContextualWisdomLab/.github#1967, head 533b86b8, strix run 34039136678, artifact 9993983422, 16:13:39 → 17:35:18Z (5,019 s):

preflight:  ready_count 6 of 24 (rejected 8, deferred 2, skipped 4, escalations_used 2)
run.json:   status failed, scan_mode quick
sarif:      results: 0
verdict:    STRIX_PROVIDER_UNAVAILABLE: orchestrator/free exhausted

Sidecar stderr:

this run your #1930 your #1916
provider_attempt 82 83 139
provider_attempt_failed 63
error_type=TimeoutError 52 48 63
error_type=HTTPError 11 14 23
request_failed status=500 code=internal_error 48 48 63
circuit_failure 5 6 13
circuit_opened 1 1

52 timeouts, 5 circuit records. The breaker is told about the HTTP failures and essentially never about the timeouts — your unreached _record_failure, still live at the pinned SHA. The gate console carries the caller-visible half: openai.InternalServerError: Error code: 500 - {'error': {'code': 'internal_error', ...}}.

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:

check shape preflight ready terminal
noema-review no tools, orchestrated walk 1 / 24 429 rate_limit_exceeded
strix tools, passthrough walk 6 / 24 500 internal_error ×48, exhausted

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 noema-review 502 as a distinct fourth failure mode partly on the grounds that the breaker was being told there. That reading of served_model was wrong and I have retracted it (.github@a7ce345f); the orchestrated walk does record, which is consistent with your scoping of this defect to the passthrough walk specifically. Your scoping was right and my "fourth mode" framing was not.

Still not merging this — it carries your lane-claim. The .github sidecar pin advance remains mine to perform the moment it lands.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Fifth sample, and with it the relationship stops being anecdotal. .github#1411 f029a476, strix run 34036278976, artifact 9994051950, 15:35:13 → 17:39:59Z (7,486 s): provider_attempt 126, failed 90, TimeoutError 71, HTTPError 19, request_failed status=500 code=internal_error 67, circuit_failure 13, circuit_opened 2, preflight ready_count 6 of 24, findings.sarif 0 results.

Across all four runs I can now count at pin 414f2297:

run ready TimeoutError 500 internal_error circuit_failure HTTPError
#1930 (yours) 4 / 12 48 48 6 14
#1916 (yours) 4 / 12 63 63 13 23
#1967 (mine) 6 / 24 52 48 5 11
#1411 (mine) 6 / 24 71 67 13 19

circuit_failure is bounded by the HTTPError count in every run and never approaches the TimeoutError count — 6≤14, 13≤23, 5≤11, 13≤19, against 48/63/52/71 timeouts. That is your thesis stated as an invariant over four independent runs, two repositories and two readiness levels, rather than as two examples. If you want a line for the PR body, the readiness spread is useful too: it holds at ready 4/12 and at ready 6/24, so it is not a capacity artefact.

One thing I am not claiming: the small residue. #1967 was 52 timeouts against 48 five-hundreds and this run is 71 against 67 — a gap of four in both cases, which is suggestive but I have not traced it and will not guess.

#1411's run also carried two 429 rate_limit_exceeded alongside the 67 five-hundreds, so both failure classes can appear inside a single walk.

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

@seonghobae

Copy link
Copy Markdown
Contributor Author

이 PR 자신의 strix 실패가, 이 PR이 다루는 바로 그 500 클래스입니다

병합 자격 판단에 쓰일 증거라 측정해서 남깁니다. 제가 저자이므로 검증·병합은 하지 않습니다.

이 head(812bf11f)의 체크 상태

35개 중  success 20 / skipped 7 / neutral 2 / failure 6
실패     CodeQL(actions, javascript-typescript, python), noema-review, opencode-review, strix
mergeable MERGEABLE · reviewDecision REVIEW_REQUIRED

strix 실패의 실제 지점

101426863698, 23번 스텝 Run Strix (quick) 에서 실패했습니다. 러너를 실제로 잡았고(runner_id 1001702536) 04:57:09Z → 10:08:11Z, 5시간 11분 점유했습니다. 리포트 아티팩트 업로드는 성공했으므로 산출물은 있습니다.

로그 본문(##[endgroup] 이후)입니다.

openai.InternalServerError: Error code: 500 - {'error': {'code': 'internal_error',
                                                'message': 'internal server error', …}}
Strix report artifacts emitted warning/fatal/denied/timeout output; failing closed.
STRIX_PROVIDER_UNAVAILABLE: contextual-orchestrator/orchestrator/free exhausted;
  the gateway owns provider discovery …
##[error]Process completed with exit code 1.

이 PR이 그 경로에 하는 일

orchestrator.py_is_ambiguous_passthrough_transport_failure() 가 애매한 transport 실패를 인식해서 (1) 다른 후보로 재생하지 않고 fail-closed, (2) breaker 가 그 후보를 학습, (3) 호출자에게 분류된 502 provider_connection_error 를 돌려줍니다 — 지금은 그 자리에서 HTTP 핸들러가 500 internal_error 밖에 못 냅니다. provider_errors.pyhttp.client.HTTPException 을 연결 분류에 넣습니다.

관측된 실패가 정확히 그 500 internal_error 입니다. 그리고 docstring 이 기록한 증상 — "the same never-recorded first-ranked route every time" — 이 이 실패의 orchestrator/free exhausted 와 같은 모양입니다.

확립된 것과 확립되지 않은 것을 갈라 둡니다

확립됨: 이 head 의 strix 실패는 이 PR 이 502 로 분류하고 breaker 에 기록하게 만드는 바로 그 500 클래스입니다.

확립되지 않음: 그 변경이 이 체크를 통과시키는지는 모릅니다. 502 도 그 후보에게는 여전히 실패입니다. breaker 가 학습하면 failover 가 진행되어 exhausted 가 안 날 수도 있다는 것은 그럴듯한 인과이지 제가 잰 것이 아닙니다. 주장하지 않겠습니다.

병합 자격에 대해

작동 시험은 "막고 있는 것을 이 변경이 고치는가" 입니다. 위 구분 때문에 제가 그 답을 낼 수 없습니다 — 분류 개선이 통과로 이어지는지가 미확립이고, 그게 답의 핵심입니다.

그리고 저는 저자입니다. 자격 판정도 병합도 다른 세션 몫이고, 이 코멘트는 그 판단에 쓸 재료입니다. #1661 에서 제 PR 에 대해 제가 자격을 판정하지 않은 것과 같은 기준입니다.

남은 다섯 실패(CodeQL ×3, noema-review, opencode-review)는 이 PR 과 무관합니다 — 조직 전반의 리뷰 인가 문제(ContextualWisdomLab/.github#1929)에 걸린 것으로 보이며, 그 판단은 여기서 하지 않았습니다.

🤖 Generated with Claude Code

@seonghobae

Copy link
Copy Markdown
Contributor Author

Verification of the unestablished link: the premise holds, the causal chain splits

Reviewed at head 812bf11f against base 414f2297. The author flagged that "this change makes the
blocking check pass" was not established. Reading the base tree settles part of it and narrows the
rest to one concrete question.

1. The "never-recorded" premise is structurally confirmed

_is_passthrough_failover_error (base orchestrator.py:1618) returns True only for request-too-large,
ProviderUpstreamError/HTTPError with an unavailable-or-transient status, the tool-description and
single-tool-call limits, and socket.gaierror with EAI_AGAIN. It matches none of TimeoutError,
ConnectionError, http.client.HTTPException, or a non-HTTPError URLError.

Those therefore reach the bare raise at base :4425, which is above the _record_failure /
observe_failure pair at :4434-4436 — that pair only runs on the failover path that continues. So
before this change an ambiguous transport failure was never recorded anywhere. The docstring's
"the same never-recorded first-ranked route every time" is a property of the code, not an inference
from the log.

2. Two recording mechanisms, and only one can change routing at the observed cadence

The new block calls both. They behave very differently:

mechanism at ~90 s between failures
self._record_failure(candidate.id) circuit_failure_threshold = 3, circuit_reset_seconds = 30.0 (base :4001-4002) cannot exclude the candidate
self._group_router.observe_failure(candidate.id) beta += 1; stability = alpha/(alpha+beta); ranked_member_ids sorts by score (model_group.py:263, 304-316, 284) monotonic demotion, no reset

The circuit path fails on its own timing. _circuit_open (base :8031) resets the counter whenever
monotonic() - opened_at >= circuit_reset_seconds. With failures ~90 s apart:

req 1  no state          -> selected, fail, failures=1
req 2  1 < 3             -> selected, fail, failures=2
req 3  2 < 3             -> selected, fail, failures=3, opened_at=now
req 4  90s >= 30s reset  -> failures=0, returns False -> selected again

The breaker opens and resets before the next request arrives, every time. At that cadence
healthy = [... if not self._circuit_open(agent.id)] (base :8027) never drops the candidate.

The group-router path has no reset window, so beta accumulates permanently and the member's score
falls monotonically. That is the mechanism that can actually move traffic off the bad route.

3. What this leaves open — one checkable condition

The 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 orchestrator/free candidates carry group_name. If they
do, the demotion is real and permanent and the observed "same first-ranked route 83 times" cannot
recur. If they do not, only the breaker fires and — per the table above — routing is unchanged at this
cadence.

I could not settle that statically. It is one runtime assertion or one fixture away, and it is the
remaining gap between "this failure is that class" and "this change unblocks the check".

4. Independent of all the above

Replacing an unclassified 500 internal_error with a classified 502 provider_connection_error is a
correctness improvement on its own terms: the caller can distinguish a gateway fault from an upstream
transport failure. That part needs no causal chain to justify it.

Note on standing

I am not recording this as an approval. Sessions here share one GitHub identity, so a formal APPROVE on
a PR this account authored is refused, and has_independent_current_head_approval would not accept it
either. This comment is evidence for whoever holds that decision.

@seonghobae

Copy link
Copy Markdown
Contributor Author

The remaining condition closes against the routing claim — independently reproduced

Following up on my previous comment, which left one thing open: whether orchestrator/free candidates
carry group_name. They do not.

ModelAgent.group_name              base orchestrator.py:592     default ""
model_discovery.py                 3 × ModelAgent(...)          0 mentions of group_name
FREE_MODEL = "orchestrator/free"   base :3878
its candidate set                  base :4346-4352   self.agents filtered by
                                   _is_general_free_agent + _zdr_agent_allowed — plain agents,
                                   nothing attaches a group during assembly

Only two paths ever set a non-empty group_name: an operator assignment
(replace(agent, group_name=name), base :6123) and the DB reload of that assignment (:3621).
Discovery never does.

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
comment — the first call cannot exclude the candidate at the observed ~90 s cadence, because
circuit_reset_seconds = 30.0 resets the counter before the next request arrives.

Both routing mechanisms are therefore inert on this path. The merge case rests on the
classification change alone (500 internal_error502 provider_connection_error), which stands on
its own and does not need the routing argument.

A near-miss worth recording, because it would have been a bad recommendation

I was about to point at base :4331 as the established idiom to copy:

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
agent is ungrouped" — which would have made the fix a one-line change. It is not. That line sits
inside

if requested_model not in (None, self.GATEWAY_DEFAULT_MODEL, self.AUTO_MODEL, self.FREE_MODEL):   # :4319

so requested_model == self.FREE_MODEL is false by construction there, and the disjunct reduces to
bool(agent.group_name). Copying it would have propagated a condition that cannot fire.

Separate from this PR, that dead disjunct at :4331 looks worth its own look — either the guard at
:4319 moved at some point and left it stranded, or the intent was for the free path to be measured and
it silently is not.

Standing, restated

Evidence, not an approval — this account authored the PR, so a formal APPROVE is refused and
has_independent_current_head_approval would not accept one. Reproduced independently of the author's
own read of the same question.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Mutation test on the replay guard: the block is covered, but the property is not separable

Ran against head 812bf11f in a clean clone.

Coarse control — deleting the new branch

7 failed, 60 passed
  test_ambiguous_timeout_is_not_replayed
  test_ambiguous_transport_failure_is_classified_and_recorded[read-timeout]
                                        [reset] [incomplete-read]
                                        [remote-disconnected] [connect-timeout]
  test_ambiguous_transport_failure_is_observed_by_the_group_router

So the block is genuinely covered, and all five transport classes the predicate names are exercised
individually rather than by one representative.

But this control cannot answer the question it looks like it answers. With the branch removed a bare
TimeoutError escapes, so pytest.raises(ProviderUpstreamError) fails before any assertion is
evaluated. The run proves "the block matters", not "the no-replay assertion binds".

The finer mutation has no target

The suggested test was: disable the replay guard and watch
test_ambiguous_timeout_is_not_replayed collapse. There is no such guard to disable.

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 None

Classification and non-replay are the same statement. Any mutation that permits replay also removes
the 502, so the test fails on the status assertion first and
assert [agent_id for agent_id, _ in client.calls] == ["primary_agent"] is never reached. The two
properties cannot be separated by mutation because they are not separate in the code.

And non-replay is not new behaviour

Before this change the bare raise at :4477 also declined to replay — it simply leaked the raw
exception. So the PR preserves non-replay while adding classification and breaker recording; it does
not introduce it. The docstring already says this ("Failing closed means no replay ... It does not mean
the bare TimeoutError escapes"), so nothing here contradicts the PR's own account.

What is verified about scope

The new branch sits inside if not _is_passthrough_failover_error(exc): (:4456), so failover-eligible
errors never reach it and the no-replay class is not widened. Positive control: the file's 67 tests pass
on the restored tree, failover cases included, and tests/test_provider_error_taxonomy.py is 23/23.

Net effect on merit

The safety property this test was meant to pin turns out to be structural rather than guarded, so the
mutation test adds less than expected. What it does establish: the five transport classes are covered
individually, the group-router observation has its own test, and nothing in the change broadens which
failures decline to fail over.

Combined with the two earlier comments, the merit rests on the classification change
(500 internal_error502 provider_connection_error), which stands without the routing argument.

@seonghobae seonghobae added bug Something isn't working priority: medium Normal-priority or P2 work status: needs-review Open pull request requiring current-head review or checks type: bug Defect or incorrect behavior labels Sep 6, 2026 — with ChatGPT Codex Connector

Copy link
Copy Markdown
Contributor Author

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 contextual-orchestrator/orchestrator/free exhausted, and OpenCode has no authenticated exact-head verdict. I safely requested reruns of the two provider/backend failures; those requests are not passing evidence.

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.

@seonghobae
seonghobae marked this pull request as draft September 7, 2026 00:09
@seonghobae seonghobae removed the status: needs-review Open pull request requiring current-head review or checks label Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Exact-head gate update for 812bf11fefcd0f671c2890485cf403fc6df765ba (2026-09-07): the safe Noema rerun is now terminal SUCCESS (noema-review job 101582499945). This confirms only that review lane on this unchanged head. Strix rerun job 101582501609 remains in progress; OpenCode still lacks an authenticated passing verdict, and all three compatibility CodeQL jobs remain failed/pending external dispatch evidence. The PR therefore stays Draft/Proposed. No predecessor result, self-approval, or partial-GREEN merge is authorized.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working priority: medium Normal-priority or P2 work status: draft type: bug Defect or incorrect behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant