You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A client-fault upstream 400 is charged against the upstream account that happened to serve it. Because that penalty feeds error_count -> exponential error backoff -> in_error_backoff, and because a hard Codex-session sticky request is narrowed to exactly one candidate account where the backoff rescue can never fire, one tenant's poisoned conversation degrades shared account health and 502s unrelated tenants.
The trigger we observed is the missing-tool-output 400, and it comes in a fourth wording that _is_missing_tool_output_error does not know about:
{"type":"error","status":400,
"error":{"type":"invalid_request_error",
"message":"No tool output found for tool search call call_XXXX",
"param":"input"}}
Two separable defects, described below. Verified against origin/main @ b91d9bb8.
Defect A — the tool search call wording is unclassified
_MISSING_TOOL_OUTPUT_MESSAGE_PREFIXES (app/modules/proxy/service.py:2105) enumerates exactly three wordings:
_MISSING_TOOL_OUTPUT_MESSAGE_PREFIXES= (
"no tool output found for function call call_",
"no tool output found for custom tool call call_",
"no tool output found for apply patch call call_",
)
so _is_missing_tool_output_error (service.py:2112) returns False for the tool-search wording. Consequences at each consumer of that classifier:
app/modules/proxy/_service/http_bridge/upstream_events.py:670 and app/modules/proxy/_service/websocket/mixin.py:605 use is_missing_tool_output_event as prefer_previous_response_not_found when matching an anonymous upstream error event (no response.id) to a pending request. On a multiplexed bridge/WS session the unclassified 400 falls through to the generic matcher (_match_websocket_request_state_for_anonymous_event, websocket/helpers.py:1414), so it can be attributed to a pending request that did not cause it.
app/modules/proxy/_service/websocket/helpers.py:1041 and :1303, and app/modules/proxy/_service/streaming/helpers.py:511 never rewrite it, so the raw upstream message and the raw call_XXXX leak to the client instead of the stream_incomplete continuity failure the other three wordings get, and _record_continuity_fail_closed(reason="missing_tool_output") is never recorded — the condition is invisible in continuity observability.
This is the same enumeration gap as #1168, which fixed the custom_tool_call / apply_patch_call wordings. #1168 named three function_call-only sites; on current main sites 1 and 2 have been generalised through the _PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE table (app/modules/proxy/_service/support.py:60), so only site 3, the classifier, is still hard-coded to a fixed wording list.
The item type is real, but the synthesiser is not a safe place to extend
tool_search_call and tool_search_output are already modelled in this repo — app/modules/proxy/replay_safety.py:20-22 lists them in _ACCOUNT_NEUTRAL_REPLAY_OMITTED_ITEM_TYPES, and tests/unit/test_replay_safety.py:187-203 fixes their shape:
Note that the output item is tool_search_output (not tool_search_call_output) and that it carries a tools list, not the {"output": "<text>"} field that _synthetic_interrupted_function_call_output (app/modules/proxy/_service/response_create.py:363) emits for every entry in _PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE. So adding "tool_search_call": "tool_search_output" to that table would synthesise an item whose shape we cannot verify against upstream; a wrong shape converts a maskable 400 into a different, unmaskable one. Adding it would also silently pull tool_search_call into _TTFT_OUTPUT_ITEM_TYPES (support.py:67).
Open question for maintainers: is the right long-term remedy for an orphaned tool_search_call (a) synthesising {"type": "tool_search_output", "call_id": ..., "tools": []}, or (b) dropping the orphaned tool_search_call — which replay_safety.py already proves upstream accepts, since the account-neutral replay projection omits both items entirely? Either way it needs upstream-shape evidence this repo does not currently contain, which is why the PR below is classifier-only.
Suggested fix scope (A)
Add the no tool output found for tool search call call_ prefix; keep the hosted web search call wording unclassified (a web_search_call is executed upstream and has no client-addressable output — tests/unit/test_proxy_utils.py already asserts it must stay False). Consider a follow-up that derives the prefixes from the pending-call-type table so the next tool-call variant is one edit, but note that the wordings ("apply patch call" vs apply_patch_call, "tool search call" vs tool_search_call) are upstream prose and do not mechanically follow from item-type names.
Defect B — client-fault 400 mutates the serving account's health (higher impact)
Mechanism, step by step
app/modules/proxy/_service/streaming/helpers.py:785-816 — _handle_stream_error. This is the only call site of LoadBalancer.record_error in the whole app (rg 'record_error\(' app returns one non-definition hit, helpers.py:809). It classifies, then returns early only when _facade()._is_account_neutral_error_code(code); otherwise a non-rate-limit / non-quota / non-permanent code lands in:
Every surface funnels here: _handle_proxy_error (service.py:2038), the streaming retry loop (streaming/retry.py), the websocket mixin, and the compact path (_service/compact.py:1419, :1463).
invalid_request_error is not in that set, and classify_upstream_failure (app/modules/proxy/helpers.py:66) classifies it non_retryable — which failover_decision (app/core/balancer/logic.py:1190) turns into "surface". So there is no failover amplification; the only effect of the client's malformed payload is that it increments error_count on an innocent account, once per retry.
app/core/balancer/logic.py:484-497.error_count >= 3 puts the account into min(300, 30 * 2 ** (error_count - 3)) seconds of backoff and appends it to in_error_backoff instead of available.
app/core/balancer/logic.py:519. The rescue that pulls an account back out of backoff fires only when
and hard_blocked_exists (:507) explicitly excludes accounts already in in_error_backoff.
app/modules/proxy/_load_balancer/sticky_selection.py:356-361. For a hard Codex-session sticky request with a resolved owner mapping, the candidate set is narrowed to that single account:
With one candidate, in_error_backoff can only ever have length 1 and hard_blocked_exists is False by construction, so step 4's rescue is unreachable. _select_account_preferring_budget_safe returns no account, sticky_selection.py:436-441 sets selection_error_code = "hard_affinity_saturated", and load_balancer.py:709/721 turns that into an immediate 502 — no failover, no wait, for a session that had nothing to do with the offending payload.
Field evidence (aggregate, 24h, codex-lb 1.21, shared multi-tenant deployment)
Signal
Count
HTTP 400, all attributable to one repeated tool-call id from one client
25,747
Recorded transient account error code=invalid_request_error
25,747 (1:1 with the 400s)
HTTP 502
2,192
... of which hard_affinity_saturated
1,583 (72%)
... of those, belonging to a single uninvolved tenant
~95%
... of those 1,583 present in request_logs
11
One five-minute window served zero successful requests. The 502s are near-invisible on the dashboard because selection fails before an account is assigned, so almost none of them reach request_logs — the failure is only legible in container logs.
The offending client's conversation was permanently poisoned: every retry re-sent the identical orphaned tool call, so this is a sustained, not bursty, load on account health.
Why not simply neutralise invalid_request_error
Some 400s carrying invalid_request_error are genuinely account-scoped and must keep backing their account off:
account-scoped hosted state referenced from input (file_id, item_reference, and the rest of _ACCOUNT_SCOPED_HOSTED_INPUT_TYPES, replay_safety.py:114), which is why gating on param == "input" alone does not prove account independence either.
The invariant worth writing into the spec is narrower and provable from the payload alone:
An error that would reproduce identically on every account must never mutate one account's health.
Suggested fix scope (B)
In _handle_stream_error, skip all account-health mutation for an upstream rejection that satisfies that invariant, decided by the classified message (never by the code alone) plus http_status == 400 when a status is known. Log the skip so the decision stays observable. Leave classify_upstream_failure, failover_decision, and the client-visible status/body untouched.
Candidates for that set, in increasing order of debatability:
missing tool output (all wordings) — unambiguous: the input array contradicts itself, no account state is involved;
previous_response_not_found (_is_previous_response_not_found_error, app/core/errors.py:88) — arguably a proxy/client anchor-bookkeeping failure rather than account fault, but a stale anchor is account-scoped state, so this one is a real judgement call and is left out of the PR;
the whole param == "input" class — rejected above.
Secondary, clearly separate proposal
Independently of B, consider letting hard-sticky selection rescue a single-elementin_error_backoff. A hard-pinned session has no alternative account by construction, so len(in_error_backoff) > 1 or hard_blocked_exists is structurally unreachable for it; a request that can only ever go to one account arguably should be allowed to try that account rather than 502 while it waits out an advisory, replica-local backoff window (openspec/specs/account-routing/spec.md, "Transient balancer health signals are replica-local"). Not bundled with B — it changes routing policy, whereas B only stops a bogus health signal from being produced in the first place.
Summary
A client-fault upstream 400 is charged against the upstream account that happened to serve it. Because that penalty feeds
error_count-> exponential error backoff ->in_error_backoff, and because a hard Codex-session sticky request is narrowed to exactly one candidate account where the backoff rescue can never fire, one tenant's poisoned conversation degrades shared account health and 502s unrelated tenants.The trigger we observed is the missing-tool-output 400, and it comes in a fourth wording that
_is_missing_tool_output_errordoes not know about:{"type":"error","status":400, "error":{"type":"invalid_request_error", "message":"No tool output found for tool search call call_XXXX", "param":"input"}}Two separable defects, described below. Verified against
origin/main@b91d9bb8.Defect A — the
tool search callwording is unclassified_MISSING_TOOL_OUTPUT_MESSAGE_PREFIXES(app/modules/proxy/service.py:2105) enumerates exactly three wordings:so
_is_missing_tool_output_error(service.py:2112) returnsFalsefor the tool-search wording. Consequences at each consumer of that classifier:app/modules/proxy/_service/http_bridge/upstream_events.py:670andapp/modules/proxy/_service/websocket/mixin.py:605useis_missing_tool_output_eventasprefer_previous_response_not_foundwhen matching an anonymous upstream error event (noresponse.id) to a pending request. On a multiplexed bridge/WS session the unclassified 400 falls through to the generic matcher (_match_websocket_request_state_for_anonymous_event,websocket/helpers.py:1414), so it can be attributed to a pending request that did not cause it.app/modules/proxy/_service/websocket/helpers.py:1041and:1303, andapp/modules/proxy/_service/streaming/helpers.py:511never rewrite it, so the raw upstream message and the rawcall_XXXXleak to the client instead of thestream_incompletecontinuity failure the other three wordings get, and_record_continuity_fail_closed(reason="missing_tool_output")is never recorded — the condition is invisible in continuity observability.This is the same enumeration gap as #1168, which fixed the
custom_tool_call/apply_patch_callwordings. #1168 named threefunction_call-only sites; on currentmainsites 1 and 2 have been generalised through the_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPEtable (app/modules/proxy/_service/support.py:60), so only site 3, the classifier, is still hard-coded to a fixed wording list.The item type is real, but the synthesiser is not a safe place to extend
tool_search_callandtool_search_outputare already modelled in this repo —app/modules/proxy/replay_safety.py:20-22lists them in_ACCOUNT_NEUTRAL_REPLAY_OMITTED_ITEM_TYPES, andtests/unit/test_replay_safety.py:187-203fixes their shape:{"type": "tool_search_call", "id": ..., "call_id": ..., "arguments": {...}, "execution": "client", "status": "completed"} {"type": "tool_search_output", "call_id": ..., "tools": [], "execution": "client", "status": "completed"}Note that the output item is
tool_search_output(nottool_search_call_output) and that it carries atoolslist, not the{"output": "<text>"}field that_synthetic_interrupted_function_call_output(app/modules/proxy/_service/response_create.py:363) emits for every entry in_PENDING_TOOL_CALL_OUTPUT_ITEM_TYPE_BY_CALL_TYPE. So adding"tool_search_call": "tool_search_output"to that table would synthesise an item whose shape we cannot verify against upstream; a wrong shape converts a maskable 400 into a different, unmaskable one. Adding it would also silently pulltool_search_callinto_TTFT_OUTPUT_ITEM_TYPES(support.py:67).Open question for maintainers: is the right long-term remedy for an orphaned
tool_search_call(a) synthesising{"type": "tool_search_output", "call_id": ..., "tools": []}, or (b) dropping the orphanedtool_search_call— whichreplay_safety.pyalready proves upstream accepts, since the account-neutral replay projection omits both items entirely? Either way it needs upstream-shape evidence this repo does not currently contain, which is why the PR below is classifier-only.Suggested fix scope (A)
Add the
no tool output found for tool search call call_prefix; keep the hostedweb search callwording unclassified (aweb_search_callis executed upstream and has no client-addressable output —tests/unit/test_proxy_utils.pyalready asserts it must stayFalse). Consider a follow-up that derives the prefixes from the pending-call-type table so the next tool-call variant is one edit, but note that the wordings ("apply patch call" vsapply_patch_call, "tool search call" vstool_search_call) are upstream prose and do not mechanically follow from item-type names.Defect B — client-fault 400 mutates the serving account's health (higher impact)
Mechanism, step by step
app/modules/proxy/_service/streaming/helpers.py:785-816—_handle_stream_error. This is the only call site ofLoadBalancer.record_errorin the whole app (rg 'record_error\(' appreturns one non-definition hit,helpers.py:809). It classifies, then returns early only when_facade()._is_account_neutral_error_code(code); otherwise a non-rate-limit / non-quota / non-permanent code lands in:Every surface funnels here:
_handle_proxy_error(service.py:2038), the streaming retry loop (streaming/retry.py), the websocket mixin, and the compact path (_service/compact.py:1419,:1463).app/modules/proxy/_service/support.py:1307—_is_account_neutral_error_code.invalid_request_erroris not in that set, andclassify_upstream_failure(app/modules/proxy/helpers.py:66) classifies itnon_retryable— whichfailover_decision(app/core/balancer/logic.py:1190) turns into"surface". So there is no failover amplification; the only effect of the client's malformed payload is that it incrementserror_counton an innocent account, once per retry.app/core/balancer/logic.py:484-497.error_count >= 3puts the account intomin(300, 30 * 2 ** (error_count - 3))seconds of backoff and appends it toin_error_backoffinstead ofavailable.app/core/balancer/logic.py:519. The rescue that pulls an account back out of backoff fires only whenand
hard_blocked_exists(:507) explicitly excludes accounts already inin_error_backoff.app/modules/proxy/_load_balancer/sticky_selection.py:356-361. For a hard Codex-session sticky request with a resolved owner mapping, the candidate set is narrowed to that single account:With one candidate,
in_error_backoffcan only ever have length 1 andhard_blocked_existsisFalseby construction, so step 4's rescue is unreachable._select_account_preferring_budget_safereturns no account,sticky_selection.py:436-441setsselection_error_code = "hard_affinity_saturated", andload_balancer.py:709/721turns that into an immediate 502 — no failover, no wait, for a session that had nothing to do with the offending payload.Field evidence (aggregate, 24h, codex-lb 1.21, shared multi-tenant deployment)
Recorded transient account error code=invalid_request_errorhard_affinity_saturatedrequest_logsOne five-minute window served zero successful requests. The 502s are near-invisible on the dashboard because selection fails before an account is assigned, so almost none of them reach
request_logs— the failure is only legible in container logs.The offending client's conversation was permanently poisoned: every retry re-sent the identical orphaned tool call, so this is a sustained, not bursty, load on account health.
Why not simply neutralise
invalid_request_errorSome 400s carrying
invalid_request_errorare genuinely account-scoped and must keep backing their account off:The '<model>' model is not supported when using Codex with a ChatGPT account.— already matched narrowly by_is_account_model_unsupported_error(app/modules/proxy/helpers.py:45);input(file_id,item_reference, and the rest of_ACCOUNT_SCOPED_HOSTED_INPUT_TYPES,replay_safety.py:114), which is why gating onparam == "input"alone does not prove account independence either.The invariant worth writing into the spec is narrower and provable from the payload alone:
Suggested fix scope (B)
In
_handle_stream_error, skip all account-health mutation for an upstream rejection that satisfies that invariant, decided by the classified message (never by the code alone) plushttp_status == 400when a status is known. Log the skip so the decision stays observable. Leaveclassify_upstream_failure,failover_decision, and the client-visible status/body untouched.Candidates for that set, in increasing order of debatability:
previous_response_not_found(_is_previous_response_not_found_error,app/core/errors.py:88) — arguably a proxy/client anchor-bookkeeping failure rather than account fault, but a stale anchor is account-scoped state, so this one is a real judgement call and is left out of the PR;param == "input"class — rejected above.Secondary, clearly separate proposal
Independently of B, consider letting hard-sticky selection rescue a single-element
in_error_backoff. A hard-pinned session has no alternative account by construction, solen(in_error_backoff) > 1 or hard_blocked_existsis structurally unreachable for it; a request that can only ever go to one account arguably should be allowed to try that account rather than 502 while it waits out an advisory, replica-local backoff window (openspec/specs/account-routing/spec.md, "Transient balancer health signals are replica-local"). Not bundled with B — it changes routing policy, whereas B only stops a bogus health signal from being produced in the first place.PRs
fix(proxy): classify tool-search missing-tool-output rejectionsfix(proxy): keep upstream request-shape rejections account neutralBoth branch from
origin/main@b91d9bb8and merge cleanly with each other.Refs
custom_tool_call/apply_patch_callwordings; its sites 1 and 2 are now table-driven, site 3 is not.error_count-> cooldown -> backoff ->No available accountscascade, reached from a staleplan_typeinstead of from a client payload. Same downstream mechanism, different upstream cause.invalid_request_errorthat B must not neutralise.