Skip to content

[Bug]: Failover combo does not move on when the native Codex upstream declines with server_is_overloaded before any output #8107

Open

Description

@hedging8563

Description

A failover combo whose first target is the native Codex route does not move to its next target when the upstream declines with server_is_overloaded (503) before any output. The combo returns the 503 to the client. Codex then reports "Selected model is at capacity. Please try a different model." and ends the turn.

This is the combo and main-account counterpart of #6969. The pre-output refusal hold from #6969 makes such a decline replayable, but it is enabled only for non-combo requests on pool / main-pool auth.

Steps to reproduce

  1. Run OpenCodex 2.95.0 with the openai provider in codexAccountMode: direct, authMode: forward (the main ChatGPT account, no pool).
  2. Define a failover combo gpt-6.1-sol with targets [openai/gpt-6.1-sol, deepseek/deepseek-flash] (any second provider shows the same result).
  3. Send streaming Responses requests (Codex CLI and other Responses clients) to gpt-6.1-sol while the ChatGPT backend is at capacity.

Expected

A decline before any output is safe to replay. The combo should try its next target, because comboFailureDecision() already returns hop for 5xx and for server_is_overloaded.

Actual

Each failed request has one attempt and the combo stops:

  • request: provider: "combo", status: 503, firstOutputMs: null
  • attempt: provider: "openai", status: 503, errorCode: "server_is_overloaded", sendCount: 1, recoveryKinds: [], failureStage: "protocol-prelude", failureCause: "upstream-declined"
  • codexWsStage: sent: true, relayedEvents: 4, terminalEvent: "error"
  • routeDecision.candidates[1] (the second target): exclusions: [{ code: "not-selected" }]

On 2026-11-02, 116 of 488 combo requests to the native route failed this way. Before 2026-11-01 the rate was below 1%.

As we read the code:

  1. poolPreOutputRefusalHold() (src/server/responses/core-codex-account.ts:174) returns undefined when comboAttempt is true. It also requires usesCodexForwardPoolAuth(), which is true only for pool and main-pool auth contexts, not main.
  2. Without the hold, the empty lifecycle frames are relayed before the refusal, so the failure is a post-send WebSocket failure and is non-replayable.
  3. In src/server/responses/core-combo.ts (around line 1381), failure.nonReplayable makes the decision stop before comboFailureDecision() is asked.

The same code is on main at 684ad75.

Suggested change

Enable the #6969 pre-output refusal hold for combo attempts and for the main auth context. Then a structured 5xx that arrives before any output settles as a replayable status, and the combo moves on. A refusal after any output should still stop, as it does now.

Environment

  • OpenCodex 2.95.0 (Bun 1.4.3), Linux, ocx start --port 10100 behind a TLS reverse proxy
  • Clients: Codex CLI and a Responses client, both streaming
  • openai provider: codexAccountMode: direct, authMode: forward; failover combos

Impact

For agent workloads every transient ChatGPT overload becomes a hard turn failure, although the combo has a healthy next target. Codex CLI review runs failed 61 times on 2026-11-02 for this reason.

Activity

  1. github-actions commented on Nov 2, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

  2. coderabbitai commented on Nov 2, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #7482 - feat(fallback): OCX-11 fallback off per scope, with home_unreachable on Children [merged]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  3. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Nov 2, 2026
  4. Ingwannu commented on Nov 4, 2026

    @Ingwannu
    Owner

    Thanks for the detailed attempt record. I traced this against current dev (72e80d280d). The relevant code is unchanged since 2.95.0, and the stop happens for a different reason than the one in the report.

    • Combo children skip the [Bug]: Pooled native Codex requests do not retry or fail over on upstream server_is_overloaded / upstream_server_error before output #6969 hold on purpose (core-codex-account.ts:163-177). Even with the hold enabled, an unrecovered projection is restored to the original 200 stream in passthrough-dispatch.ts:1711 before the combo sees it. So enabling the hold for combo attempts would not change the result.
    • Without the hold, the 200 SSE is committed at response.created and the overload error frame is relayed in-band. That body then goes through the combo stream preflight (core-combo.ts:1255). For a zero-output 503 server_is_overloaded, the preflight would normally return failed, without the non-replayable marker, and comboFailureDecision would hop.
    • The likely defect: the Codex WS metadata channel writes codex.rate_limits / codex.response.metadata control frames into the same SSE body (codex-ws-exchange.ts:626-716, codex-ws-metadata.ts:242). The combo preflight only treats response.created/in_progress/queued/heartbeat as pre-output control events (combo-stream-preflight.ts:24). Every other type commits the child (comboStreamPayloadCommitsOutput, :148-162, :336). So a rate-limit frame ahead of response.created commits the native target before the overload arrives, and the combo never reaches its next target. That matches your one attempt and candidates[1]: not-selected.

    Proposed fix: treat the two Codex metadata frame types as non-committing control events in the combo preflight. Add a unit test and a fake-WebSocket combo failover regression test (rate_limits → created → in_progress → error 503). Making the hold apply to the plain main context outside combos is a separate change and is not needed for the combo case.

    One thing to confirm: your stage record shows relayedEvents: 4, one more than the created → in_progress → error sequence we test with. If you can share only the ordered type values of the frames before the error (no payloads), we can check whether a new Responses event type also needs to be treated as pre-output.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions