Skip to content

fix(ai): honor Retry-After when retry-after-ms is unparseable#111353

Merged
steipete merged 2 commits into
openclaw:mainfrom
Yigtwxx:fix/chatgpt-responses-retry-after-fallback
Jul 19, 2026
Merged

fix(ai): honor Retry-After when retry-after-ms is unparseable#111353
steipete merged 2 commits into
openclaw:mainfrom
Yigtwxx:fix/chatgpt-responses-retry-after-fallback

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

Fixes an issue where users on ChatGPT (Codex) models would hit repeated rate-limit
failures when the server asked them to wait a specific amount of time. When a 429
response carries a retry-after-ms value the client cannot parse, the server's
valid Retry-After header sitting next to it is discarded, and the client retries
on its own short exponential schedule instead. It gets rate-limited again, burns
its remaining attempts well inside the server's cooldown window, and surfaces a
rate-limit error for a turn the server had told it to simply wait out.

Why This Change Was Made

retry-after-ms and Retry-After are ordered preferences, not exclusive
alternatives: the millisecond header is preferred when usable, otherwise the
seconds header applies. The retry path treated the mere presence of
retry-after-ms as the decision point and returned before ever reading
Retry-After, so an unparseable value — or an empty string, which is present but
carries no information — silently downgraded the wait to blind backoff.

This aligns the path with parseRetryAfterSeconds in
src/agents/provider-transport-fetch.ts:465, which handles the same two-header
protocol and falls through to Retry-After when retry-after-ms fails to parse.
The fix adopts that shape exactly: return only on a successful parse.

Scope is deliberately narrow. Precedence is unchanged, a parseable
retry-after-ms still wins, and the existing clamp and HTTP-date handling are
untouched. The behavior predates the extraction of this helper in #110655; the
refactor preserved it faithfully rather than introducing it.

User Impact

On rate-limited ChatGPT Responses turns, the client now waits as long as the
server asked whenever the server communicated that in either header. Sessions
that previously failed with a rate-limit error during a provider cooldown now
recover on their own. Users who never receive a malformed retry-after-ms see no
change.

Evidence

Real-behavior proof — no mocks, no fake timers, real socket, real wall clock.

A genuine node:http server answers the first request with 429,
retry-after-ms: not-a-number, retry-after: 3, and the real
streamOpenAICodexResponses is driven against it via tsx. The measured gap is
the actual elapsed time between the two inbound requests observed by the server:

Before (this branch with the source change reverted):

server requests observed : 2
measured retry gap (ms)  : 1016
stopReason               : error

FAIL - ignored Retry-After, used blind backoff (gap 1016ms)

After:

server requests observed : 2
measured retry gap (ms)  : 3018
stopReason               : error

PASS - honored the server's Retry-After: 3 (gap 3018ms)

1016 ms is BASE_DELAY_MS * 2 ** 0 — the blind fallback. 3018 ms is the server's
instruction.

Regression tests. Three cases added to the existing suite: an unparseable
retry-after-ms, an empty one, and a guard that a parseable retry-after-ms
still takes precedence over a conflicting Retry-After.

The new tests are load-bearing. Reverting only the source change (test file
untouched) turns them red with exactly the symptom described above:

× honors retry-after when retry-after-ms is 'unparseable'
× honors retry-after when retry-after-ms is 'empty'

AssertionError: expected [ 1000 ] to include 7000

Local checks. openai-chatgpt-responses.retry.test.ts and
openai-chatgpt-responses.test.ts: 42 passed. oxlint clean, oxfmt --check
clean, check-max-lines-ratchet clean (the helper's line count is unchanged).

AI-assisted.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal backlog priority with limited blast radius. labels Jul 19, 2026
@Yigtwxx

Yigtwxx commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

The single red check (checks-node-compact-large-3) is unrelated to this branch.

It fails in src/media/store.download-timeout.test.ts > "times out when the server accepts a connection but never sends headers" with Error: server request was not observed — a socket-timing assertion in the media download suite. This branch touches only packages/ai/src/providers/openai-chatgpt-responses.ts and its retry test; git diff origin/main...HEAD --name-only lists no files under src/media/.

That suite passes locally on the same commit (8/8, node scripts/run-vitest.mjs run src/media/store.download-timeout.test.ts).

Happy to rebase if you'd prefer a fresh run, but I did not want to churn the branch over an unrelated flake.

@clawsweeper

clawsweeper Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 19, 2026, 7:36 AM ET / 11:36 UTC.

Summary
The PR changes ChatGPT Responses retry-delay resolution so an unusable retry-after-ms falls through to Retry-After, adding regression coverage for malformed, empty, and precedence cases.

PR surface: Source 0, Tests +58. Total +58 across 2 files.

Reproducibility: yes. source-reproducible with high confidence: a retryable 429 carrying retry-after-ms: not-a-number and retry-after: 7 takes the current-main fallback path instead of the standard header; the added tests encode that case.

Review metrics: none identified.

Stored data model
Persistent data-model change detected: serialized state: packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts. Confirm migration or upgrade compatibility proof before merge.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦞 diamond lobster
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Risk before merge

  • [P2] The branch is cleanly mergeable but behind main; refresh the merge result or use the merge queue so the exact final integration retains the narrow fallback and its regression tests.

Maintainer options:

  1. Decide the mitigation before merge
    Land the focused ordered-header fallback after the final merge-result check, preserving the existing clamp and Retry-After seconds/date behavior.
  2. Pause or close
    Do not merge this PR until maintainers decide whether the risk is worth taking.

Next step before merge

  • No automated repair is warranted because the review found no concrete defect in the branch; it needs normal maintainer merge review on the final merge result.

Security
Cleared: The two-file diff only changes response-header retry selection and test coverage; it adds no dependencies, permissions, secret handling, artifact execution, or supply-chain surface.

Review details

Best possible solution:

Land the focused ordered-header fallback after the final merge-result check, preserving the existing clamp and Retry-After seconds/date behavior.

Do we have a high-confidence way to reproduce the issue?

Yes, source-reproducible with high confidence: a retryable 429 carrying retry-after-ms: not-a-number and retry-after: 7 takes the current-main fallback path instead of the standard header; the added tests encode that case.

Is this the best way to solve the issue?

Yes. Falling through only when the preferred millisecond header cannot be parsed is the narrowest fix and matches the ordered parser behavior used by the official OpenAI client. (github.com)

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against c95a8e3df1e6.

Label changes

Label justifications:

  • P2: This corrects a provider-specific rate-limit recovery bug that can fail affected ChatGPT Responses turns but has limited blast radius.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR body supplies a concrete before/after live local-server run using the real stream function and wall-clock request gaps, showing fallback backoff before the change and the server-directed three-second retry after it.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body supplies a concrete before/after live local-server run using the real stream function and wall-clock request gaps, showing fallback backoff before the change and the server-directed three-second retry after it.
Evidence reviewed

PR surface:

Source 0, Tests +58. Total +58 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 4 4 0
Tests 1 58 0 +58
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 62 4 +58

What I checked:

  • Current-main behavior: Current main reads retry-after-ms first and only inspects retry-after in the else branch, so any present-but-invalid millisecond value suppresses the standard header; the PR changes that early decision into a successful-parse-only return path. (packages/ai/src/providers/openai-chatgpt-responses.ts:147, c95a8e3df1e6)
  • Focused regression coverage: The branch adds cases for an unparseable and an empty retry-after-ms alongside a guard that a parseable millisecond value still wins over a conflicting Retry-After. (packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts:101, 20fdd76f6342)
  • Upstream retry contract: The official OpenAI Python client first attempts non-standard retry-after-ms, then falls through to standard retry-after when it cannot parse the first header, matching this branch's ordered-fallback interpretation. (github.com)
  • HTTP header contract: Retry-After is the standard response header for a delay in seconds or an HTTP date; the branch preserves the existing seconds/date parsing path when the preferred millisecond header is unusable. (developer.mozilla.org)
  • Feature-history provenance: The related merged retry-path PR introduced adjacent control-flow coverage in the same provider surface; this PR's author also authored the current branch's original fix commit, making the area relationship concrete rather than author-only routing. (packages/ai/src/providers/openai-chatgpt-responses.ts:147, 19d887169f78)

Likely related people:

  • Yigtwxx: Authored the prior merged ChatGPT Responses retry-control-flow repair and the original commit for this follow-up in the same provider file. (role: recent area contributor; confidence: high; commits: 19d887169f78, c527ae5b8b67; files: packages/ai/src/providers/openai-chatgpt-responses.ts, packages/ai/src/providers/openai-chatgpt-responses.retry.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (3 earlier review cycles)
  • reviewed 2026-07-19T09:51:03.480Z sha c527ae5 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-19T10:08:49.843Z sha af27e84 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-19T10:53:42.295Z sha 20fdd76 :: needs maintainer review before merge. :: none

@Yigtwxx

Yigtwxx commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Refreshed against current main (merge af27e84ca80), which addresses the P1 and re-runs the previously failed shard on the reviewed head.

On the earlier red check, for the record: checks-node-compact-large-3 failed in src/media/store.download-timeout.test.ts > "times out when the server accepts a connection but never sends headers" with Error: server request was not observed. That is a socket-timing assertion in the media download suite, and this branch touches no file under src/media/. The suite passes locally on this head (8/8).

One correction on the "stored data model" note: no persistent data-model change is present. The flagged path is openai-chatgpt-responses.retry.test.ts, a test file, and the only state it constructs is an in-memory array of observed setTimeout delays. The source diff is 4 lines inside one function and touches no serialized or persisted structure, so there is no migration or upgrade-compatibility surface to prove.

Post-refresh verification on this head: openai-chatgpt-responses.retry.test.ts + openai-chatgpt-responses.test.ts 42 passed, oxlint clean, oxfmt --check clean, check-max-lines-ratchet clean.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

Re-review progress:

@Yigtwxx

Yigtwxx commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Update on the checks after refreshing against main: the new red lanes (check-lint, check-prod-types, check-test-types, check-additional-boundaries-a, ci-gate) are inherited from the base, not from this branch.

They all trace to one error:

ui/src/api/gateway.ts:364:7  error  Identifier `instanceId` has already been declared

origin/main itself declares get instanceId() twice, at ui/src/api/gateway.ts:364 and :382. This branch changes two files under packages/ai/ and touches nothing in ui/git diff origin/main...HEAD --name-only | grep '^ui/' is empty. #111362 is already open to remove the duplicate getter.

So the pre-existing checks-node-compact-large-* media flake and these lint/type lanes are both base-side. Happy to refresh again once #111362 lands if that helps get a clean run on the exact head.

@steipete steipete self-assigned this Jul 19, 2026
Yigtwxx and others added 2 commits July 19, 2026 17:04
The ChatGPT Responses retry path returned as soon as `retry-after-ms` was
present, so a malformed or empty value discarded a valid `Retry-After`
header sent alongside it and the client fell back to blind exponential
backoff. The sibling parser in `provider-transport-fetch.ts` treats the
two headers as ordered preferences and falls through on a parse failure;
this aligns the Responses path with that contract.
@steipete
steipete force-pushed the fix/chatgpt-responses-retry-after-fallback branch from 20fdd76 to fe205ac Compare July 19, 2026 16:14
@steipete
steipete merged commit 6c9b31e into openclaw:main Jul 19, 2026
108 of 110 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Merged via squash.

github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 20, 2026
…aw#111353)

* fix(ai): honor Retry-After when retry-after-ms is unparseable

The ChatGPT Responses retry path returned as soon as `retry-after-ms` was
present, so a malformed or empty value discarded a valid `Retry-After`
header sent alongside it and the client fell back to blind exponential
backoff. The sibling parser in `provider-transport-fetch.ts` treats the
two headers as ordered preferences and falls through on a parse failure;
this aligns the Responses path with that contract.

* test(ai): deduplicate retry header coverage

---------

Co-authored-by: Peter Steinberger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants