Skip to content

fix(codex): rebind sticky pool threads after connect/transient failures - #194

Closed
Wibias wants to merge 3 commits into
lidge-jun:mainfrom
Wibias:fix/codex-pool-affinity-connect-recovery
Closed

Wibias wants to merge 3 commits into
lidge-jun:mainfrom
Wibias:fix/codex-pool-affinity-connect-recovery

Conversation

@Wibias

@Wibias Wibias commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Soft-avoid Codex pool accounts for 30s after connect_error / timeout / transient 5xx so thread affinity can leave a flaky peer on the next continue ([Bug]: Frequent 502 upstream_server_error after the first failure in a conversation. #186)
  • Clear all affinities pinned to an account once the failover streak trips (same behavior as 429), and plumb x-codex-parent-thread-id into outcome recording for immediate per-thread unbind
  • Force Connection: close on keep-alive connection-reset retries so Bun does not reuse the half-closed socket

Test plan

  • bun test tests/codex-routing.test.ts tests/upstream-retry.test.ts
  • Pre-push hook full suite (bun test, typecheck, lint)
  • Manual: multi-account OpenAI pool, provoke Provider unreachable: socket connection was closed unexpectedly in one Codex session, confirm continue rebinds to another account instead of sticky 502s

…ilures

Thread affinity stayed pinned after connect_error/timeout while only new
threads failed over, so continue in a dead session kept 502ing (#186). Soft-
avoid the flaky account, clear affinities on failover, and force Connection:
close on keep-alive reset retries.

@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: 51a27c1898

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/lib/upstream-retry.ts Resolved
Wibias added 2 commits July 20, 2026 21:47
Review pass: wire compact through connect-timeout + reset/5xx retries,
record pool outcomes only after the body is buffered, and plumb thread
affinity into sidecar outcome recording so continue/compact leave a flaky
account.
Connection: close alone is unreliable in Bun (historically ignored). Use
keepalive: false as the transport-level pool opt-out on reset retries, and
keep Connection: close as a secondary signal.
@lidge-jun

Copy link
Copy Markdown
Owner

Please retarget this PR to the dev branch — all contributions go to dev first, and main receives only release promotions. See our Contributing guide for details. Thanks! 🙏

@lidge-jun

Copy link
Copy Markdown
Owner

Adversarial Review Feedback

The soft-avoid concept is sound, and the keepalive:false recovery is correct for Bun 1.3.14.

Blockers

  1. Non-transient terminal failures trigger transient soft avoidance — Every failed/incomplete terminal is collapsed to status 502 in responses.ts:421-433, which feeds the transient soft-avoid branch. An invalid_request_error (400) still soft-avoids the account and rebinds the thread. Health recording must use the derived semantic status and soft-avoid only actual transport/timeout/5xx outcomes.

  2. Late failures can delete a newer healthy affinity — threadAccountMap.delete(meta.threadId) at routing.ts:507 doesn't verify the current mapping still belongs to the failing account. Sequence: T→A, A fails, T→B, late A failure deletes B's mapping. Fix: delete only when threadAccountMap.get(threadId)?.accountId === accountId.

Please retarget to dev — see Contributing guide. 🙏

@lidge-jun

Copy link
Copy Markdown
Owner

Closing this PR — the soft-avoid concept has real value for transient failure recovery, but the implementation has two fundamental design issues that need a rework rather than incremental fixes:

  1. Non-transient failures trigger soft avoidance — every failed/incomplete terminal is collapsed to 502, causing even invalid_request_error (400) to soft-avoid the account and rebind threads. The health recording path needs to use the derived semantic status.

  2. Late failures can delete a newer healthy affinity — threadAccountMap.delete(meta.threadId) doesn't verify the mapping still belongs to the failing account, creating a race: T→A, A fails, T→B, late A failure deletes B's mapping.

The keepalive:false recovery for connection-reset retries is solid and we may adopt that piece separately. Thank you for the contribution — the soft-avoid direction is worth revisiting with proper status classification. 🙏

Co-authored-by: Wibias

@lidge-jun lidge-jun closed this Jul 21, 2026
lidge-jun pushed a commit that referenced this pull request Jul 21, 2026
…atus (#205)

* fix(codex): rework soft-avoid pool affinity with semantic terminal status

Rework the transient-failure soft-avoid mechanism to address the two
fundamental design issues identified in #194:

1. Non-transient failures no longer trigger soft avoidance — the terminal
   outcome recorder now uses the semantic HTTP status from logCtx.terminalHttpStatus
   (derived from the SSE error payload via httpStatusFromTerminalError) instead
   of collapsing every non-completed terminal to 502. A 400 invalid_request_error
   no longer soft-avoids the account or rebinds threads.

2. Late failures can no longer delete a newer healthy affinity — the threadId
   meta now guards the threadAccountMap.delete with an ownership check
   (bound?.accountId === accountId), preventing the race: T→A, A fails, T→B,
   late A failure must not delete B's mapping.

Additional improvements:
- Transport-level keepalive:false via applyUpstreamRecoveryInit for connection-reset
  retries (Bun has historically ignored the Connection: close header alone)
- Compact endpoint (/v1/responses/compact) now uses the same transient-retry +
  recovery-init + pool-outcome recording as /v1/responses
- Failover streak clears all affinities for the failing account (matching 429 behavior)
- Soft-avoid extends on repeated transient failures and clears on 2xx (hard quota
  cooldown intentionally survives)
- Affinity reuse now checks shouldFailover to catch post-avoid residual streaks

Closes #194

* fix(codex): address Codex review — incomplete terminals, compact size, combo WS logCtx

Three fixes from the Codex bot review on #205:

1. Incomplete terminals (max_output_tokens, content_filter, adapter EOF) no
   longer fall back to 502 and trigger soft-avoid. They are normal delivery
   outcomes — record success so a healthy account isn't penalized.

2. Compact endpoint: when upstream returns 200 but bufferCompactResponse
   produces a 502 (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES),
   record the upstream status instead of connect_error. The account is
   healthy; the failure is a deterministic local payload-size limit.

3. Combo WS path: the terminal recorder closed over the child logCtx, but
   sendResponseToWebSocket inspects SSE payloads into the parent logCtx.
   The recorder now accepts an httpStatusOverride parameter, and the WS
   caller passes logCtx.terminalHttpStatus so the semantic status is not
   lost. Also properly types the WS logCtx as RequestLogContext.
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…atus (lidge-jun#205)

* fix(codex): rework soft-avoid pool affinity with semantic terminal status

Rework the transient-failure soft-avoid mechanism to address the two
fundamental design issues identified in lidge-jun#194:

1. Non-transient failures no longer trigger soft avoidance — the terminal
   outcome recorder now uses the semantic HTTP status from logCtx.terminalHttpStatus
   (derived from the SSE error payload via httpStatusFromTerminalError) instead
   of collapsing every non-completed terminal to 502. A 400 invalid_request_error
   no longer soft-avoids the account or rebinds threads.

2. Late failures can no longer delete a newer healthy affinity — the threadId
   meta now guards the threadAccountMap.delete with an ownership check
   (bound?.accountId === accountId), preventing the race: T→A, A fails, T→B,
   late A failure must not delete B's mapping.

Additional improvements:
- Transport-level keepalive:false via applyUpstreamRecoveryInit for connection-reset
  retries (Bun has historically ignored the Connection: close header alone)
- Compact endpoint (/v1/responses/compact) now uses the same transient-retry +
  recovery-init + pool-outcome recording as /v1/responses
- Failover streak clears all affinities for the failing account (matching 429 behavior)
- Soft-avoid extends on repeated transient failures and clears on 2xx (hard quota
  cooldown intentionally survives)
- Affinity reuse now checks shouldFailover to catch post-avoid residual streaks

Closes lidge-jun#194

* fix(codex): address Codex review — incomplete terminals, compact size, combo WS logCtx

Three fixes from the Codex bot review on lidge-jun#205:

1. Incomplete terminals (max_output_tokens, content_filter, adapter EOF) no
   longer fall back to 502 and trigger soft-avoid. They are normal delivery
   outcomes — record success so a healthy account isn't penalized.

2. Compact endpoint: when upstream returns 200 but bufferCompactResponse
   produces a 502 (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES),
   record the upstream status instead of connect_error. The account is
   healthy; the failure is a deterministic local payload-size limit.

3. Combo WS path: the terminal recorder closed over the child logCtx, but
   sendResponseToWebSocket inspects SSE payloads into the parent logCtx.
   The recorder now accepts an httpStatusOverride parameter, and the WS
   caller passes logCtx.terminalHttpStatus so the semantic status is not
   lost. Also properly types the WS logCtx as RequestLogContext.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants