Skip to content

fix(codex): keep shared network failures account-neutral - #922

Open
luvs01 wants to merge 5 commits into
lidge-jun:devfrom
luvs01:fix/914-account-neutral-network
Open

fix(codex): keep shared network failures account-neutral#922
luvs01 wants to merge 5 commits into
lidge-jun:devfrom
luvs01:fix/914-account-neutral-network

Conversation

@luvs01

@luvs01 luvs01 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • classify exact pre-connect DNS and network-reachability failures as account-neutral in regular Responses, native compact, and every shared pool-backed OpenAI sidecar
  • treat parent/client cancellation as account-neutral while keeping OpenCodex-owned timeouts, resets, socket closure, HTTP failures, and semantic failures account-scoped
  • retain one operation-owned quota-probe lease across vision and web-search, then settle it exactly once on success, skip, cancellation, discarded responses, or sideband closure
  • preserve known HTTP outcomes and client-facing 502/504 behavior without allowing later body/SSE errors or stale events to mutate account state twice
  • document the precise bounded classifier scope in every shipped documentation locale

Root cause

Regular Responses and native compact mapped every non-timeout transport exception to an account-specific connect_error. A host-wide DNS outage therefore incremented a selected credential's failure streak and could clear affinity or rotate accounts even though every account used the same unreachable ChatGPT host.

Pool-backed sidecars had the same policy gap plus an ownership problem: a probe lease could be acquired before the final consumer was known, released by an intermediate vision attempt before web-search, stranded on skipped/discarded paths, or followed by a stale outcome after cancellation. Some paths also recorded a known HTTP status and then recorded the subsequent body error a second time.

The classifier remains deliberately narrow. It trusts exact own-data code and cause properties through a bounded chain, and any abort, timeout, reset, or socket-close blocker wins unless the event is specifically the parent/client cancellation of that operation.

User impact

An existing Codex task remains on its affined account during shared DNS/reachability failures and user cancellation. The request keeps its existing retryable error or timeout response, but OpenCodex no longer treats an account-neutral interruption as evidence that another credential is healthier.

Actual upstream 429, 402, 5xx, credential rejection, semantic failure, OpenCodex-owned timeout, and pre-header reset behavior remain account-scoped. This change does not replay a partially committed request.

Validation

All contributor tests below used Bun 1.4.0-canary.1 unless otherwise noted.

  • bun test tests/codex-account-neutral-network.test.ts tests/codex-sidecar-turn-lease.test.ts tests/sidecar-abort.test.ts --timeout 15000
    • 32 passed, 0 failed, 195 assertions
  • bun test tests/server-combo-failover-e2e.test.ts tests/combo-child-headers.test.ts --timeout 15000
    • 45 passed, 0 failed, 250 assertions
  • bun test tests/server-search.test.ts tests/server-images.test.ts tests/server-live.test.ts --timeout 15000
    • 82 passed, 0 failed, 344 assertions
  • bun test tests/server-auth.test.ts --timeout 15000
    • 58 passed, 0 failed, 357 assertions
  • bun run typecheck
    • passed
  • bun run privacy:scan
    • passed
  • git diff --check
    • passed
  • node_modules/bun/bin/bun install --frozen-lockfile && node_modules/bun/bin/bun run build from docs-site/
    • passed with the repository-pinned Bun 1.3.14; 216 pages built
  • two independent exact-diff final reviews
    • no remaining actionable findings

Fixes #914.

Related: #919 covers the distinct main Responses post-header mid-stream body failure; this PR keeps that separate from pre-connect classification and sidecar ownership.

Summary by CodeRabbit

  • Bug Fixes

    • Improved upstream failover handling for account-neutral DNS and network reachability failures.
    • These failures now return a 502 response without penalizing, rotating, or recording health against the selected account.
    • Probe leases are correctly released while account affinity is preserved across supported request types.
    • Abort, timeout, reset, ambiguous, and malformed errors continue to be handled safely.
  • Documentation

    • Clarified upstreamFailoverThreshold behavior across supported languages, including which failures count and how to disable the threshold.

@github-actions github-actions Bot added the bug Something isn't working label Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The PR adds bounded transport-error classification and applies it across Codex Responses, relay, sidecar, vision, web-search, and Live flows. Account-neutral failures preserve account state and release probe leases. Tests and localized documentation cover the new behavior.

Account-neutral transport handling

Layer / File(s) Summary
Bounded transport classification
src/lib/upstream-retry.ts
Classifies exact error codes through safe, bounded cause-chain traversal. It preserves existing timeout, abort, reset, and connection-close handling.
Codex response and sidecar lease handling
src/server/responses/compact.ts, src/server/responses/core.ts, src/providers/openai-sidecar.ts, src/vision/index.ts, src/web-search/loop.ts
Neutral failures release probe leases without recording account health. Sidecar leases transfer to delegated web search and finalize once across completion and cancellation paths.
Relay and Live outcome handling
src/server/search.ts, src/server/images.ts, src/server/live.ts, src/server/index.ts, src/server/ws-bridge.ts, src/vision/describe.ts, src/web-search/executor.ts
Relays record response status before body processing and classify transport failures only when no response was received. Live sideband paths guard outcome recording against duplicates and release leases during cleanup.
Validation and documentation
tests/codex-account-neutral-network.test.ts, tests/codex-sidecar-turn-lease.test.ts, tests/sidecar-abort.test.ts, tests/server-auth.test.ts, docs-site/src/content/docs/{,ja/,ko/,ru/,zh-cn/}reference/configuration/providers.md
Tests cover classification, routing state, lease cleanup, cancellation, response-body failures, and sidecar behavior. Documentation describes the updated failover threshold rules.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Relay
  participant TransportClassifier
  participant AccountPool
  participant ProbeLease
  Client->>Relay: Send upstream request
  Relay->>TransportClassifier: Classify transport result
  TransportClassifier-->>Relay: Return neutral or account-scoped outcome
  Relay->>AccountPool: Record account-scoped outcome when applicable
  Relay->>ProbeLease: Release owned lease
  Relay-->>Client: Return upstream or transport response
Loading

Possibly related PRs

Suggested reviewers: ingwannu, lidge-jun

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #914 by classifying bounded network errors, preserving routing state, releasing leases, and retaining existing transport behavior.
Out of Scope Changes check ✅ Passed The implementation, documentation, and tests remain focused on account-neutral network failures, outcome tracking, and probe-lease lifecycle management.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: keeping shared network failures account-neutral in Codex handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 888c9558b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs-site/src/content/docs/reference/configuration/providers.md Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-site/src/content/docs/reference/configuration/providers.md`:
- Line 25: Update the upstreamFailoverThreshold documentation to explicitly
describe account-neutral failures as runtime-classified exact pre-connect DNS
and reachability errors within a bounded cause chain, and state that timeouts,
aborts, resets, socket closures, HTTP failures, and semantic upstream failures
remain account-scoped. Apply equivalent wording in
docs-site/src/content/docs/reference/configuration/providers.md lines 25-25,
docs-site/src/content/docs/ja/reference/configuration/providers.md lines 24-24,
docs-site/src/content/docs/ko/reference/configuration/providers.md lines 24-24,
docs-site/src/content/docs/ru/reference/configuration/providers.md lines 25-25,
and docs-site/src/content/docs/zh-cn/reference/configuration/providers.md lines
24-24, translating the qualifiers appropriately for each locale.

In `@src/lib/upstream-retry.ts`:
- Around line 32-53: Update the ACCOUNT_NEUTRAL_NETWORK_ERROR_CODES and
ACCOUNT_SCOPED_CONNECTION_ERROR_CODES sets to match Bun 1.3.14: retain ENOTFOUND
and the existing supported codes, and remove DNSResolveFailed,
DNSResolutionFailed, Timeout, Aborted, AbortedBeforeConnecting, and
ClientAborted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8aa7417e-a90c-4725-b3f6-750bc7b24b6d

📥 Commits

Reviewing files that changed from the base of the PR and between fa51fce and 888c955.

📒 Files selected for processing (10)
  • docs-site/src/content/docs/ja/reference/configuration/providers.md
  • docs-site/src/content/docs/ko/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/ru/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
  • src/lib/upstream-retry.ts
  • src/server/responses/compact.ts
  • src/server/responses/core.ts
  • tests/codex-account-neutral-network.test.ts
  • tests/server-auth.test.ts

Comment thread docs-site/src/content/docs/reference/configuration/providers.md Outdated
Comment thread src/lib/upstream-retry.ts
@lidge-jun

Copy link
Copy Markdown
Owner

Reviewing this against a runtime probe, because I spent four audit rounds on the same problem in a planning unit and want to hand over what killed each attempt rather than have you rediscover it.

Including ConnectionRefused is the right call and it is the thing my first design missed. Bun's fetch does not surface Node codes at all, so a classifier built only on ENOTFOUND/EAI_AGAIN is green in CI against an injected Object.assign(err, {code}) and dead in production. You caught that; I did not, until a reviewer probed the runtime.

But there is a consequence worth deciding deliberately. On Bun 1.3.14 a nonexistent hostname and a refused port are indistinguishable from the error object:

fetch("https://no-such-host.invalid/x")  -> code: "ConnectionRefused", errno: 0, no cause
fetch("http://127.0.0.1:1/x")            -> code: "ConnectionRefused", errno: 0, no cause

Same code, same errno, no cause on either. So ConnectionRefused in the neutral set means a genuinely refused connection to a resolving upstream host also stops counting against account health. That may well be what you want — every pool account shares the host, so rotating cannot repair it either — but the PR describes the set as "exact pre-connect DNS and network-reachability failures", and on Bun it is broader than that. Worth saying so in the code comment so the next reader does not assume the distinction holds.

Two cases my audit found that are worth adding to your test matrix, both of which put a rejection on this path after the credential was seen:

  1. Redirects. Bun follows them by default. A server can accept the authenticated request, return 307 to a dead host, and the wrapper rejects with ConnectionRefused — headers arrived, credential evaluated.
  2. Read-then-close. A server that reads Authorization and closes the socket without responding yields ECONNRESET. Your account-scoped set covers this one correctly, but the pair is worth pinning together so a later refactor cannot move ECONNRESET across the line by accident.

One more, from the same audit: a transient 5xx that exhausts fetchWithTransientRetry() surfaces as a rejected promise rather than a response, so "the promise rejected" and "no HTTP response arrived" are not the same condition on this path. Your classifier keys on error shape rather than on rejection, so I do not think it is affected — but it is the kind of thing that bites a later simplification.

The analysis that produced all of this is in devlog/_plan/260803_transport_attribution/000_plan.md on dev, including the three designs that failed and why. Your approach is a fourth one I had not considered, and the ConnectionRefused inclusion is what makes it viable where mine were not.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-site/src/content/docs/reference/configuration/providers.md`:
- Line 25: Update the upstreamFailoverThreshold documentation in
docs-site/src/content/docs/reference/configuration/providers.md:25-25,
docs-site/src/content/docs/ja/reference/configuration/providers.md:24-24,
docs-site/src/content/docs/ko/reference/configuration/providers.md:24-24,
docs-site/src/content/docs/ru/reference/configuration/providers.md:25-25, and
docs-site/src/content/docs/zh-cn/reference/configuration/providers.md:24-24 to
state that account-neutral pre-connect failures leave account quarantine,
cooldown, failure streak, thread affinity, rotation, and active-account
selection unchanged, while preserving the existing account-scoped transitions
for 401/403, 429, timeouts, resets, socket closures, HTTP failures, and semantic
upstream failures; provide equivalent wording in each locale.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 022e3281-6268-49d8-b09e-b0d908c5cdf4

📥 Commits

Reviewing files that changed from the base of the PR and between 888c955 and 399a991.

📒 Files selected for processing (20)
  • docs-site/src/content/docs/ja/reference/configuration/providers.md
  • docs-site/src/content/docs/ko/reference/configuration/providers.md
  • docs-site/src/content/docs/reference/configuration/providers.md
  • docs-site/src/content/docs/ru/reference/configuration/providers.md
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
  • src/lib/upstream-retry.ts
  • src/providers/openai-sidecar.ts
  • src/server/images.ts
  • src/server/index.ts
  • src/server/live.ts
  • src/server/responses/core.ts
  • src/server/search.ts
  • src/server/ws-bridge.ts
  • src/vision/describe.ts
  • src/vision/index.ts
  • src/web-search/executor.ts
  • src/web-search/loop.ts
  • tests/codex-account-neutral-network.test.ts
  • tests/codex-sidecar-turn-lease.test.ts
  • tests/sidecar-abort.test.ts

| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. |
| `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. |
| `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. |
| `upstreamFailoverThreshold?` | `number` | `3` | Consecutive account-scoped transient outcomes before future new sessions fail over. Only exact pre-connect DNS and network reachability errors that the runtime classifies within a bounded cause chain are account-neutral and do not count. Timeouts, aborts, connection resets, socket closures, HTTP status failures, and semantic upstream failures remain account-scoped. Set `0` to disable. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Document the complete account-neutral routing contract in every locale.

All five lines document only threshold exclusion. They must also state that account-neutral pre-connect failures leave account quarantine, cooldown, failure streak, thread affinity, rotation, and active-account selection unchanged. Preserve the existing account-scoped transitions for 401/403, 429, timeouts, resets, socket closures, HTTP failures, and semantic upstream failures.

  • docs-site/src/content/docs/reference/configuration/providers.md#L25-L25: add the complete guarantee to the canonical English wording.
  • docs-site/src/content/docs/ja/reference/configuration/providers.md#L24-L24: add the equivalent Japanese wording.
  • docs-site/src/content/docs/ko/reference/configuration/providers.md#L24-L24: add the equivalent Korean wording.
  • docs-site/src/content/docs/ru/reference/configuration/providers.md#L25-L25: add the equivalent Russian wording.
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md#L24-L24: add the equivalent Simplified Chinese wording.

As per path instructions, localized provider documentation must stay synchronized with the full runtime routing behavior.

📍 Affects 5 files
  • docs-site/src/content/docs/reference/configuration/providers.md#L25-L25 (this comment)
  • docs-site/src/content/docs/ja/reference/configuration/providers.md#L24-L24
  • docs-site/src/content/docs/ko/reference/configuration/providers.md#L24-L24
  • docs-site/src/content/docs/ru/reference/configuration/providers.md#L25-L25
  • docs-site/src/content/docs/zh-cn/reference/configuration/providers.md#L24-L24
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/reference/configuration/providers.md` at line 25,
Update the upstreamFailoverThreshold documentation in
docs-site/src/content/docs/reference/configuration/providers.md:25-25,
docs-site/src/content/docs/ja/reference/configuration/providers.md:24-24,
docs-site/src/content/docs/ko/reference/configuration/providers.md:24-24,
docs-site/src/content/docs/ru/reference/configuration/providers.md:25-25, and
docs-site/src/content/docs/zh-cn/reference/configuration/providers.md:24-24 to
state that account-neutral pre-connect failures leave account quarantine,
cooldown, failure streak, thread affinity, rotation, and active-account
selection unchanged, while preserving the existing account-scoped transitions
for 401/403, 429, timeouts, resets, socket closures, HTTP failures, and semantic
upstream failures; provide equivalent wording in each locale.

Source: Path instructions

@lidge-jun lidge-jun left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this — the problem is real and your framing of it in #914 was accurate. But I have to ask for changes, and the reason is specific: this is the first of four designs that were already tried and rejected for this exact issue.

The full history is on dev at devlog/_plan/260803_transport_attribution/000_plan.md. Worth reading before the next revision, because it will save you the next two attempts as well.

The blocker

src/lib/upstream-retry.ts:32-43 allowlists Node codes plus Bun aliases including ConnectionRefused, and :140-182 walks the cause chain returning neutral on a match. That is "infer attribution from the rejected fetch's error code." Adding the Bun spellings makes the branch reachable — which the original design was not — but reachable is not correct.

A review probed Bun 1.3.14 against your classifier directly:

same .invalid host, 8 calls: ConnectionRefused → neutral=true
                             FailedToOpenSocket → neutral=false   (alternating)
refused port:                ConnectionRefused → neutral=true
server read Authorization: Bearer …, then 307 → refused port:
                             ConnectionRefused → neutral=true
first attempt 503, retry redirected to refusal:
                             ConnectionRefused → neutral=true

Three consequences:

  1. A real DNS outage still rotates the account. Bun evicts its DNS cache after a failure, so calls alternate between the two labels. Calls 2, 4, 6 return FailedToOpenSocket, are not neutral, accumulate, and trip the threshold. The exact symptom #914 reports survives.
  2. Credential-visible failures get suppressed. Bun follows redirects by default. A server that received your bearer and answered 307 to a dead host produces a final ConnectionRefused — your classifier calls that neutral, but a credential-aware upstream can behave differently for account A than for B. That is a genuine account signal being discarded.
  3. The 503 → rejection hole. fetchWithTransientRetry returns a transient 5xx only on the final attempt. An attributable 503 observed on attempt one vanishes when a later attempt rejects, and the rejection is then marked neutral.

Why the tests pass anyway

tests/codex-account-neutral-network.test.ts:153-256 injects hand-built errors — codedError("ENOTFOUND") and friends. That mirrors the allowlist rather than testing it. The suite is green because it asks the classifier the same question the classifier answers by construction; the runtime probe asks the question production asks, and gets a different answer.

This is not a criticism of your care — it is the specific trap this issue keeps setting, and the reason the recorded plan requires a runtime-grounded test against real Bun errors for any classifier here.

What the recorded conclusion suggests instead

Four designs died in the same place, which is evidence about the problem rather than about the designs. Every one tried to answer "was this the credential's fault?" from evidence that is insufficient in principle.

The direction the plan lands on is separating host health from account health: every pool account shares the host, so a network fault should mark the (provider, host) pair, not a credential, and rotation stops being the response at all. This PR does not do that — src/codex/routing.ts is untouched, there is no host key, threshold, or cooldown; it only skips account recording when the classifier fires.

If you want to take that on, it is genuinely valuable work and I would review it gladly.

Two smaller things

Scope. The sidecar quota-probe ownership work (OpenAiSidecarTurnLease, releaseProbeLease, the 387-line lifecycle suite) and the post-header outcome precedence changes in images.ts:445 / web-search/executor.ts:85 are separate policy questions — the latter is adjacent to #919, not required by #914. They would land more easily on their own.

Conflict. src/server/responses/compact.ts conflicts semantically, not textually: dev gained #913's bounded alternate-account send with per-context recording, and this PR edits the older single-account catch. Reconciling needs a decision about attribution for both contexts.

The documentation also states two things the implementation does not do: it calls the classified failures "pre-connect" (the rejection cannot prove that), and says aborts stay account-scoped while accountScopedTransportOutcome():198 makes them neutral. Same text in all four locales.

Leaving this open rather than closing it — the issue is real and you clearly have the context to solve it.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants