Skip to content

fix(transports): answer an ambiguous resend the same way on every HTTP surface - #5377

Merged
lidge-jun merged 3 commits into
devfrom
codex/260921-l1-replay-refusal-parity
Sep 20, 2026
Merged

lidge-jun merged 3 commits into
devfrom
codex/260921-l1-replay-refusal-parity

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • A pre-header connection loss is answered with upstream_reset_replay_refused: the turn may already have executed, so this proxy refuses to send it again. Two of the three HTTP surfaces said that; the translated Chat wrapper in src/server/chat-completions.ts did not. It preserved only the cyber-policy code and model_not_found, assigned the upstream code only when classifyError had produced none, and then attached the default Retry-After: 2. A client on /v1/chat/completions against a Responses-wire provider was therefore told the provider had throttled it, and told when to send the turn again — the exact duplicate the refusal exists to prevent.
  • The verdict is now a property of the response the surfaces share instead of something each one re-derives. retainReplayRefusal and carryReplayRefusal (src/lib/upstream-retry.ts) restate the in-process marker at every re-wrap, including the deferred-logging wrapper in src/server/relay.ts that dropped it silently. All four formatters — native Chat, translated Chat, the Responses passthrough formatter and the shared bridge formatter — read the marker, or the code a body kept through an intermediate formatter, and never the status. 429 is precisely what a refusal and a real rate limit have in common, which is why reading the status is what produced this defect.
  • The translated surface also restates the refusal when it arrives as a failed Responses envelope rather than a non-2xx, and an unreadable refusal body on the passthrough path now keeps the code instead of degrading to a generic upstream_error.

The status decision, and why the status alone is not the fix

The status stays 429. Codex builds its retry policy from retry_429: false, retry_5xx: true over four attempts, so a 5xx here multiplies the duplicate send; 429 is where that client stops.

That is not sufficient for everyone else. The Stainless-generated clients — openai and anthropic, Python and Node — decide from a status table that retries 408, 409, 429 and every 5xx, and they compute their own backoff when no wait is named. Removing Retry-After therefore does not stop a resend by itself; it only removes the schedule. Those clients do read one explicit signal before that table, so every surface now also emits x-should-retry: false alongside the absent Retry-After. Keeping 429 and adding the header covers both client families without inventing a status that describes neither failure.

Scope is deliberately narrow: the two WebSocket post-send verdicts (upstream_no_response, upstream_closed_before_response) are genuine upstream observations and keep their existing 502/504 contract.

Verification

  • Local checks: NOT RUN. No local suite, focused test, typecheck, build, install or proxy run was performed for this branch, by instruction. The evidence is static source review plus hosted CI on this exact head.
  • Acceptance is a count rather than a shape. tests/server/replay-refusal-parity.test.ts starts the proxy on a real socket, counts physical upstream sends at the boundary, and drives all three surfaces with a client that implements the published SDK retry rule (an explicit x-should-retry first, then the 408/409/429/5xx table). Each logical request must produce exactly one upstream send and one client attempt, with status REPLAY_REFUSED_STATUS, code upstream_reset_replay_refused, no Retry-After and the suppression header — all derived from the source constants rather than restated as literals.
  • The header name and its two values are deliberate literals inside the client double: that function stands in for the third party and has to keep believing what those clients believe even if our own constant changed.
  • A rate-limit control in the same file drives the same client against an ordinary upstream 429 and asserts that it does resend. Without it, "one send" would be a property of the double rather than of the answer.
  • The new file is registered in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json; no file at its size cap grew.
  • Static review covered the existing coverage for these paths (tests/lib/upstream-retry.test.ts, tests/server/retry-after-429.test.ts, tests/providers/upstream-transient-retry.test.ts, tests/responses/responses-send-budget-counts.test.ts, tests/responses/responses-account-label.test.ts, tests/codex-integration/reserve-dispatch.test.ts, tests/usage/request-log.test.ts): each remaining assertion holds, since ordinary and cyber-policy formatting is untouched and only a marked or allowlisted refusal enters the new branches.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed. structure/transports/responses.md records the property and the client contract; docs-site states the wire behaviour users see.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults. No credential, auth or workflow path is touched; the refusal body is unchanged and still never carries the raw exception.

Summary by CodeRabbit

  • Bug Fixes

    • Replay refusals now return a dedicated response that prevents supported OpenAI and Anthropic SDKs from automatically resending requests.
    • Retry instructions are consistently applied across native Chat, translated Chat, and Responses APIs.
    • Ordinary rate-limit responses retain their existing retry behavior.
    • Replay-refusal details now remain intact when responses are reformatted or logged.
  • Documentation

    • Clarified replay-refusal behavior and retry handling across supported API surfaces.
  • Tests

    • Added coverage verifying consistent no-retry behavior across all three API surfaces.

…P surface

The pre-header replay refusal was correct where it was written and absent one
wrapper away. The translated Chat surface preserved only the cyber-policy code
and model_not_found, took the upstream code only when classifyError had produced
none, and then attached the default Retry-After for a retryable 429, so a refusal
to resend reached the client as an ordinary rate limit with an instruction to
send the turn again.

Carry the verdict as a property of the response every surface shares rather than
something each one re-derives: retainReplayRefusal and carryReplayRefusal restate
the in-process marker at each re-wrap, including the deferred-logging wrapper that
silently dropped it. All four formatters now read the marker, or the code a body
kept through an intermediate formatter, and never the status -- 429 is exactly
what a refusal and a real rate limit have in common.

Dropping Retry-After is necessary and not sufficient. The Stainless-generated
clients (openai and anthropic, Python and Node) retry 408, 409, 429 and every 5xx
from their own table and compute their own backoff when no wait is named, so the
status stays 429 (Codex stops there, a 5xx invites four more sends) and every
surface also emits x-should-retry: false, which those clients read first.
The acceptance unit for this refusal is not the shape of one response: it is how
many times the turn physically reaches upstream when a client with retries
enabled is the one deciding. A single fetch cannot see that, because the proxy
can answer correctly and the duplicate inference still happens.

The case runs the proxy over a socket, counts sends at the upstream boundary and
drives all three surfaces with a client that implements the published SDK rule
(an explicit x-should-retry first, then 408/409/429/5xx). Its header literals are
deliberate: the double stands in for the third party and has to keep believing
what those clients believe. A rate-limit control shows the same client resending,
so "one send" is a property of the answer rather than of the double.
Both documents stated the refusal carries no Retry-After and stopped there, which
is the half of the policy that does not survive contact with a retrying client.
Name the fourth writer, the property that now travels with the response, and the
suppression header, and point the invariant at the count that holds it.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 20, 2026 22:27
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 20, 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-20T22:30:16.448300Z a235bcf PR opened
ℹ️ 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.

@github-actions github-actions Bot added the bug Something isn't working label Sep 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Deterministic PR hygiene checks passed.

@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: lidge-jun/opencodex/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 77885e0b-ad2a-47e4-8c90-14ad54659e8f

📥 Commits

Reviewing files that changed from the base of the PR and between c58e0d6 and a235bcf.

📒 Files selected for processing (11)
  • docs-site/src/content/docs/reference/configuration/server.md
  • scripts/test-layout/layout.json
  • src/bridge/errors.ts
  • src/lib/upstream-retry.ts
  • src/server/chat-completions.ts
  • src/server/chat-native.ts
  • src/server/relay.ts
  • src/server/responses/passthrough-error.ts
  • structure/transports/responses.md
  • tests/fixtures/test-layout-expected.json
  • tests/server/replay-refusal-parity.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The change prevents replay-refusal responses from triggering SDK retries. It adds shared refusal headers and provenance handling, applies them across native and translated HTTP surfaces, preserves them during response rewrites, and adds parity tests.

Changes

Replay refusal handling

Layer / File(s) Summary
Shared refusal policy
src/lib/upstream-retry.ts
Defines x-should-retry: false, removes retry-after, and preserves replay-refusal state across response wrappers.
HTTP surface classification
src/server/chat-native.ts, src/server/chat-completions.ts
Classifies replay refusals with REPLAY_REFUSED_STATUS and UPSTREAM_RESET_REPLAY_REFUSED_CODE for native Chat, translated Chat, and failed Responses envelopes.
Response rewrite propagation
src/bridge/errors.ts, src/server/responses/passthrough-error.ts, src/server/relay.ts
Applies refusal headers and retains refusal provenance through bridge formatting, passthrough formatting, and deferred logging.
Parity validation and documentation
tests/server/replay-refusal-parity.test.ts, docs-site/src/content/docs/reference/configuration/server.md, structure/transports/responses.md, scripts/test-layout/layout.json, tests/fixtures/test-layout-expected.json
Tests one upstream send for replay refusals and retry behavior for ordinary 429 responses. Documentation describes the no-retry contract and supported surfaces.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Proxy
  participant Upstream
  Client->>Proxy: Send request
  Proxy->>Upstream: Forward request
  Upstream-->>Proxy: Pre-header ECONNRESET
  Proxy-->>Client: Replay refusal with x-should-retry: false
  Client->>Client: Do not resend request
Loading

Possibly related PRs

  • lidge-jun/opencodex#4807: Introduced the replay-refusal marker and initial native Chat and passthrough handling extended by this change.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 7 files. (4 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: consistent handling of ambiguous resend decisions across all HTTP transport surfaces.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 64.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 14 functions across 7 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

@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: a235bcfbd1

ℹ️ 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 +141 to +145
const surfaces = [
{ name: "native Chat", path: "/v1/chat/completions", body: { model: "native/model", ...CHAT_TURN } },
{ name: "translated Chat", path: "/v1/chat/completions", body: { model: "bridged/model", ...CHAT_TURN } },
{ name: "Responses", path: "/v1/responses", body: { model: "bridged/model", ...RESPONSES_TURN } },
];

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 Preserve replay refusal on the Messages surface

The claimed parity omits /v1/messages. For an Anthropic Messages request whose Responses-backed upstream hits an ambiguous pre-header reset, handleResponses returns the marked 429, but src/server/claude-messages.ts:981-1023 consumes it, ignores its code and marker, synthesizes Retry-After: 2, and rebuilds the response without x-should-retry: false; the Anthropic SDK therefore resends a turn that may already be executing. Preserve the refusal through that wrapper and add Messages to this surface table.

AGENTS.md reference: AGENTS.md:L7-L10

Useful? React with 👍 / 👎.

@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 74 / 80

이 PR은 “업스트림이 헤더도 받기 전에 끊겼는데, 그 턴이 이미 돌아갔을 수도 있다”는 상황에서 프록시가 다시 보내지 않기로 한 답(upstream_reset_replay_refused, HTTP 429)이 HTTP 표면마다 달라지던 구멍을 막습니다. 예전에 native Chat과 Responses 쪽은 거절을 제대로 말했는데, Responses 선을 거쳐 Chat으로 번역하는 src/server/chat-completions.ts만 달랐습니다. 그 래퍼는 사이버 정책 코드와 model_not_found만 지키고, classifyError가 이미 레이트리밋 코드를 붙인 뒤에는 업스트림 거절 코드를 살리지 못했고, 기본 Retry-After: 2까지 붙여 클라이언트가 “잠깐 쉬었다가 같은 턴을 다시 보내라”고 읽게 만들었습니다. 지금은 거절 판정을 상태코드에서 다시 추측하지 않고, 응답에 붙는 표시(retainReplayRefusal / carryReplayRefusal)와 본문 코드로 네 포맷터(native Chat, translated Chat, Responses passthrough, bridge formatter)가 같이 읽습니다. relay의 지연 로그 래퍼가 표시를 떨어뜨리던 길도 carryReplayRefusal로 이었습니다. Retry-After만 빼는 것으로는 Stainless 계열(openai/anthropic)이 429를 자기 표로 다시 보내므로, 거절마다 x-should-retry: false를 같이 냅니다. 수락 증거는 응답 모양이 아니라 tests/server/replay-refusal-parity.test.ts가 소켓 위에서 업스트림 물리 전송 횟수를 세고, SDK처럼 재시도하는 클라이언트로 세 표면이 각각 “보낸다 1번 / 클라이언트 시도 1번”인지 보는 것입니다. 진짜 레이트리밋 대조 케이스도 같은 클라이언트가 다시 보내는 것을 보여 “한 번만”이 더블 탓이 아님을 가립니다. base는 dev이고 types/config 분할·프리뷰 배포와는 무관합니다.

src/server/chat-completions.ts 번역 Chat 경로 - 예전에 거절을 레이트리밋+Retry-After로 바꾸던 구멍은 이번 분기와 헤더로 막혔습니다. 다만 이 헤더를 모르는 raw fetch·구형 SDK·자체 재시도 표는 여전히 429를 다시 보낼 수 있습니다. PR이 고른 계약(Codex는 429에서 멈추고, Stainless는 x-should-retry를 먼저 본다) 밖의 클라이언트는 이 패치만으로 중복 전송이 안 사라진다고 보는 게 맞습니다.

tests/server/replay-refusal-parity.test.ts - 수락 단위를 “업스트림 전송 횟수”로 잡은 방향은 버그 성격과 잘 맞습니다. 로컬 스위트는 안 돌렸고(작성자 명시), 이 리뷰 시점 hosted CI의 test shard·macos 일부는 아직 pending입니다. 머지 전에 그 초록을 확인하는 게 안전합니다.

메인테이너의 판단이 필요한 지점

거절을 계속 429로 두고 x-should-retry: false를 추가한 선택이 Codex(5xx면 네 번 더 보냄)와 Stainless 양쪽을 같이 잡으려는 타협입니다. x-should-retry를 무시하는 클라이언트를 이번 범위 밖으로 둘지, 아니면 별도 상태/문서 경고가 더 필요한지는 제품 판단입니다. WebSocket 쪽 upstream_no_response / upstream_closed_before_response의 502/504는 의도적으로 안 건드렸고, 그 분리는 타당해 보입니다.

너의 추천

핵심 수리(표시를 래핑마다 이어 주기 + 번역 Chat이 거절 코드를 살림 + x-should-retry: false)와 전송 횟수 패리티 테스트 방향은 맞습니다. CI test shard가 초록이면 머지해도 됩니다. types/config 중복 PR을 닫을 대상은 아닙니다.

이 댓글은 grok-bot이 작성했습니다

@lidge-jun
lidge-jun merged commit 556b670 into dev Sep 20, 2026
35 checks passed
@lidge-jun
lidge-jun deleted the codex/260921-l1-replay-refusal-parity branch September 20, 2026 23:22
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.

1 participant