Skip to content

fix(retry): refuse ambiguous reset replay without inviting a client retry (#4741) - #4798

Merged
lidge-jun merged 3 commits into
devfrom
codex/r2570-lane040-replay-refusal
Sep 16, 2026
Merged

lidge-jun merged 3 commits into
devfrom
codex/r2570-lane040-replay-refusal

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Summary

An upstream connection that drops before response headers is not evidence that the model POST was never processed, so replaying it can duplicate a paid, side-effecting turn. This carries #4741, which makes that replay opt-in, and corrects the half that would have made things worse.

The refusal was reported as HTTP 502. The Codex client builds ApiRetryConfig with retry_429 false and retry_5xx true and a default of four attempts, so a 502 there is an instruction to send the whole turn four more times: the proxy stopped replaying and handed the amplification to the client. The refusal now answers 429 with its own code, upstream_reset_replay_refused, and the bridge error formatter restates that status when a combo or adapter formatter re-wraps it holding an upstream-shaped 502.

Changing the status is not free, and that is the part worth reviewing. Once the refusal carries 429, a 429 stops being sufficient evidence of a provider rate limit, and ten call sites read it that way; nine were safe only because 502 never matched them. Left alone, the corrected status would have re-created the same duplicate send inside native Chat and the continuation loop and written cooldowns against credentials that rate-limited nothing. Each now consults isNonReplayableResponse first, in chat-native.ts, adapter-continuation.ts, passthrough-dispatch.ts and compact.ts, and records the transport outcome rather than the client-facing status so pool health sees exactly what it saw before. adapter-dispatch already had the guard from the original PR.

The worst case on the happy path is unchanged behaviour.

Co-authored work from #4741 is carried with authorship preserved; the first two commits are tree-identical to that PR head.

Verification

  • The client-retry premise is first-hand from the upstream client source rather than from our documentation of it.
  • Guard sweep established statically across all ten 429 reactors, with the transport outcome separated from the client-facing status.
  • Exact-head hosted CI on this branch is the gate.
  • No local suite, focused test, typecheck, build, install, or service command was run.

Checklist

  • Targets dev.
  • Preserves the original author with Co-authored-by trailers.
  • Documented invariant change recorded: the owning section of structure/transports/responses.md is rewritten to separate a refusal this proxy made from an upstream reset reported mid-stream or after a terminal, and the WebSocket transport's post-send 502/504 verdicts are explicitly unchanged, which is why a distinct code was necessary rather than optional. The stale Upstream reset retry section, which still described reset retries as the default and pointed at the pre-split src/server/responses.ts, is repaired in the same change.
  • Exact-head hosted CI is green.
  • Maintainer review is complete.

Summary by CodeRabbit

  • New Features

    • Added safer handling for ambiguous connection failures during model requests.
    • Returns HTTP 429 with upstream_reset_replay_refused when a request may already have been processed, preventing duplicate submissions.
    • Replay-safe side requests, including image and web-search operations, can still retry automatically.
  • Bug Fixes

    • Prevented unnecessary retries, account switching, cooldowns, and quota deductions after ambiguous failures.
    • Omitted Retry-After for replay-refusal responses.
  • Documentation

    • Documented reset handling, replay behavior, and resulting HTTP statuses.

luvs01 and others added 3 commits September 16, 2026 18:56
Redesigns luvs01#135 on upstream dev. Preserve total-send accounting
and provider HTTP retry policy while carrying the terminal verdict through combo
recovery and error formatting.

Carried from #4621's sibling PR #4741 by a maintainer; original authorship
preserved.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
…eserve verdict

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
An ambiguous pre-header reset is a refusal this proxy made, not something the
upstream reported. It was borrowing `upstream_closed_before_response` and its 502,
and the Codex client builds its policy from `retry_429: false` / `retry_5xx: true`
over `DEFAULT_REQUEST_MAX_RETRIES = 4`, so the status invited up to four more sends
of the exact turn the refusal exists to protect.

It now carries `upstream_reset_replay_refused` and HTTP 429, following the
`request_send_budget_exhausted` precedent. The distinct code keeps it separable from
the WebSocket transport's post-send 502/504 verdicts, which are unchanged, and lets
`formatErrorResponse` restate the status when a combo or adapter formatter re-wraps it
holding an upstream-shaped 502.

Because a 429 is no longer sufficient evidence of a provider rate limit, every
same-target replay, key rotation, account rotation and pool-quota recorder that keys on
429 now consults `isNonReplayableResponse` first. Without that, correcting the status
would have re-created the duplicate send inside native Chat and the continuation loop
and written cooldowns against credentials that refused nothing. Compact records the
transport outcome rather than the client-facing status, so pool health sees exactly what
it saw before. `adapter-dispatch` already had the guard at the top of its recovery loop.

Rewrites the owning section of structure/transports/responses.md to separate a
pre-header rejection the proxy refuses to replay from an upstream reset seen mid-stream
or after a terminal, and records the reclassification as the behaviour change it is.
Also repairs the stale `## Upstream reset retry` section, which still described
reset retries as the default and pointed at the pre-split `src/server/responses.ts`.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 7a8e5fcd-9aa9-471c-a0aa-044e240782e4

📥 Commits

Reviewing files that changed from the base of the PR and between ada3a9b and 2ea335f.

📒 Files selected for processing (24)
  • docs-site/src/content/docs/reference/configuration/server.md
  • src/bridge/errors.ts
  • src/images/loop.ts
  • src/lib/upstream-retry.ts
  • src/server/chat-native.ts
  • src/server/responses/adapter-continuation.ts
  • src/server/responses/adapter-dispatch.ts
  • src/server/responses/compact.ts
  • src/server/responses/passthrough-dispatch.ts
  • src/vision/anthropic-describe.ts
  • src/vision/describe.ts
  • src/web-search/anthropic-executor.ts
  • src/web-search/exa-executor.ts
  • src/web-search/executor.ts
  • src/web-search/gemini-executor.ts
  • src/web-search/loop.ts
  • src/web-search/ollama-executor.ts
  • src/web-search/xai-executor.ts
  • structure/transports/responses.md
  • tests/codex-integration/issue-914-transport-attribution.test.ts
  • tests/codex-integration/reserve-dispatch.test.ts
  • tests/lib/upstream-retry.test.ts
  • tests/providers/upstream-transient-retry.test.ts
  • tests/responses/responses-send-budget-counts.test.ts

📝 Walkthrough

Walkthrough

The change treats pre-header upstream connection resets as ambiguous. Model requests now receive a marked HTTP 429 refusal instead of automatic replay. Replay-safe image, vision, and web-search sidecars retain reset retries. Recovery and failover paths preserve the refusal.

Changes

Reset replay boundary

Layer / File(s) Summary
Reset refusal contract
src/lib/upstream-retry.ts, src/bridge/errors.ts
Pre-header resets return 429 with upstream_reset_replay_refused. The response is marked non-replayable, omits Retry-After, and preserves cyber-policy precedence.
Recovery and quota terminality
src/server/chat-native.ts, src/server/responses/...
Marked refusals stop same-target retries, account rotation, pool failover, combo hops, and quota attribution.
Replay-safe sidecar wiring
src/images/loop.ts, src/vision/..., src/web-search/...
Sidecar requests pass replaySafe: true to retain reset retries.
Validation and transport contract documentation
tests/lib/upstream-retry.test.ts, tests/responses/responses-send-budget-counts.test.ts, tests/codex-integration/..., tests/providers/..., structure/transports/responses.md, docs-site/src/content/docs/reference/configuration/server.md
Tests cover terminal resets, send counts, formatting, and recovery. Documentation describes pre-header refusals and replay-safe sidecars.

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

Change: Bug fix

Suggested reviewers: luvs01

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Proxy as fetchWithResetRetry
  participant Recovery as Responses recovery
  participant Upstream
  Client->>Proxy: send model request
  Proxy->>Upstream: forward request
  Upstream--xProxy: reset before response headers
  Proxy-->>Recovery: marked 429 refusal
  Recovery-->>Client: return upstream_reset_replay_refused
Loading
✨ 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 codex/r2570-lane040-replay-refusal

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.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 16, 2026
@lidge-jun
lidge-jun marked this pull request as ready for review September 16, 2026 10:43
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 16, 2026 10:43
@lidge-jun
lidge-jun merged commit 0f8f2f5 into dev Sep 16, 2026
29 of 30 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-16T10:49:01.490196Z 2ea335f Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@lidge-jun
lidge-jun deleted the codex/r2570-lane040-replay-refusal branch September 16, 2026 10:43

@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: 2ea335f11c

ℹ️ 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 on lines +530 to +532
// Preserve the terminal verdict through adapter and combo error formatting.
// This also covers a reset reached by a 401/429/413 recovery refetch.
if (isNonReplayableResponse(upstreamResponse)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck the marker after adapter recovery refetches

The guard runs only when entering the outer recovery loop, but several inner 401/429 recovery branches assign upstreamResponse = result and remain inside their current loop. For example, if a same-target 429 retry returns the synthesized reset refusal, another configured retry or the following key-pool loop treats its 429 as a provider rate limit and sends the possibly completed turn again. Check isNonReplayableResponse after every rebuildAndRefetch result or include it in each recovery-loop condition.

Useful? React with 👍 / 👎.

Comment on lines 1120 to +1124
upstreamResponse.status === 429
// Not a provider rate limit when this proxy synthesized it for a refused reset
// replay; rotating accounts on it would re-send an inference that may already
// have run and would cool down an account that refused nothing.
&& !isNonReplayableResponse(upstreamResponse)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Record reset refusals as transport failures in pool health

For a Codex forward-pool request, this guard prevents immediate account failover, but the same marked 429 subsequently reaches deliverPassthroughResponse, where recordCodexUpstreamOutcome(..., upstreamResponse.status, ...) records it as quota evidence. An ambiguous reset therefore writes a false 429 cooldown and can release affinity or route later requests away from an account that never rate-limited anything. Preserve the marker through delivery and normalize this outcome to the prior transport status, as the compact path already does.

Useful? React with 👍 / 👎.

Comment on lines +57 to +58
turn again, which is the duplicate the refusal exists to prevent. No `Retry-After` is
attached, and the proxy performs no key rotation, account failover or same-target replay on

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent the passthrough formatter from adding Retry-After

On a direct native Responses request, the synthesized refusal has a non-empty JSON body, so deliverPassthroughResponse sends it through formatPassthroughUpstreamError; that formatter treats every headerless 429 as a retryable rate limit and adds the default Retry-After: 2. Thus the documented no-retry directive is false and clients that honor the header are explicitly invited to resend the possibly completed turn. Make that formatter recognize upstream_reset_replay_refused before documenting that the header is absent.

AGENTS.md reference: docs-site/AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

Comment thread src/server/chat-native.ts
Comment on lines +383 to +387
// A 429 this proxy synthesized for a refused reset replay is not a provider rate
// limit: waiting and re-sending here is exactly the duplicate inference the refusal
// exists to stop. It kept the same shape under the old 502 only because 502 never
// matched this branch.
&& !isNonReplayableResponse(response)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the refusal code in native Chat responses

When native Chat receives the marked reset response, this guard correctly stops internal retries, but the final error path calls classifyError on status 429, producing rate_limit_exceeded; it copies upstreamCode only when the classified code is null. Consequently clients and operators receive a normal provider-rate-limit code rather than upstream_reset_replay_refused, defeating the new distinction on /v1/chat/completions. Special-case the refusal code or route it through the marker-aware formatter.

Useful? React with 👍 / 👎.

lidge-jun added a commit that referenced this pull request Sep 16, 2026
…ence and Retry-After (#4807)

Release-blocker fix for 2.57.0, found by the final cross-change regression audit. Exact head has a green aggregate ci check with no failing job. The 429 reclassification that landed in #4798 guarded the call sites that read a 429 as a rate limit but not the ones that write quota evidence, synthesize Retry-After, or reclassify the status on the way out, so in two places the release as it stood invited the replay the change exists to prevent: passthrough recorded the synthetic 429 as quota evidence and attached a default Retry-After, and native Chat dropped the distinct code. Adapter recovery could also replay a refusal produced by a refetch inside an arm, which the single-retry regression could not catch. The invariant is now stated once and recorded: a refusal this proxy made never acquires a Retry-After and never becomes quota evidence. Host-owned merge decision; no local suite, typecheck, build, or install was run.
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…etry (lidge-jun#4741) (lidge-jun#4798)

Maintainer integration for the 2.57.0 stabilization scope. Exact head 2ea335f has a green aggregate ci check with no failing job. This carries lidge-jun#4741 and corrects the half that would have made things worse: the refusal was reported as 502, which the Codex client retries up to four times, so the proxy stopped replaying and handed the amplification to the client. It now answers 429 with upstream_reset_replay_refused, and because a 429 then stops being sufficient evidence of a provider rate limit, all ten call sites that read it that way consult isNonReplayableResponse first and record the transport outcome rather than the client-facing status, so pool health sees exactly what it saw before. The owning structure section is rewritten to separate a refusal this proxy made from an upstream reset reported mid-stream or after a terminal, and the WebSocket post-send verdicts are explicitly unchanged. Host-owned merge decision; no local suite, typecheck, build, or install was run.
agentHits pushed a commit to agentHits/opencodex that referenced this pull request Sep 17, 2026
…ence and Retry-After (lidge-jun#4807)

Release-blocker fix for 2.57.0, found by the final cross-change regression audit. Exact head has a green aggregate ci check with no failing job. The 429 reclassification that landed in lidge-jun#4798 guarded the call sites that read a 429 as a rate limit but not the ones that write quota evidence, synthesize Retry-After, or reclassify the status on the way out, so in two places the release as it stood invited the replay the change exists to prevent: passthrough recorded the synthetic 429 as quota evidence and attached a default Retry-After, and native Chat dropped the distinct code. Adapter recovery could also replay a refusal produced by a refetch inside an arm, which the single-retry regression could not catch. The invariant is now stated once and recorded: a refusal this proxy made never acquires a Retry-After and never becomes quota evidence. Host-owned merge decision; no local suite, typecheck, build, or install was run.
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