Skip to content

retry: 🐛 Classify transient errors from the CLI error line, not echoed prompt - #4

Merged
yasyf merged 1 commit into
mainfrom
retry-regex
Sep 14, 2026
Merged

yasyf merged 1 commit into
mainfrom
retry-regex

Conversation

@yasyf

@yasyf yasyf commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

The core retry policy classified a failed run as transient with (?i)\b529\b|overloaded|rate.?limit|\b5\d\d\b over the whole error message. For a CLI backend that message is <provider> exited <rc>: <stderr tail>, and codex echoes the prompt into stderr ahead of its error line. A captain-hook prompt mentioning "512 MiB" turned this permanent failure into a transient one:

ERROR: {"type":"error","status":400,"error":{"type":"invalid_request_error","message":"The 'gpt-5.4-mini' model is not supported when using Codex with a ChatGPT account."}}

spawnllm then slept 5+15+45+60s before giving up, roughly 137s per call and about 7 minutes per captain-hook nudge.

Fix

is_transient in rust/spawnllm-core/src/retry.rs now reads two structured places and nothing else:

  • the last non-empty line of the message, which is where codex prints ERROR: ... and claude prints API Error: .... A 5xx counts there only in a status position ("status":503, last status: 502 Bad Gateway, unexpected status 503, API Error: 500); overloaded and rate limit keep matching on that line.
  • an ^<provider> exited 5xx: header, which only the HTTP backend produces, since its return code is the HTTP status.

A 4xx is never transient. The status forms come from the strings in the installed codex 0.154.0 and claude 2.1.270 binaries and the real 400 above from captain-hook's logs.

Tests

New golden vectors, regenerated with conformance-gen --write and replayed by the Python, Go, and Rust hosts:

  • retry_decision/non-transient-codex-400-echoed-prompt and resolve/codex-exit-400-echoed-prompt: a 400 whose echoed prompt says "512 MiB" and "503" is not retried. Generated against the old core, both recorded true.
  • retry_decision/transient-codex-retry-limit-502: ERROR: exceeded retry limit, last status: 502 Bad Gateway is retried.
  • retry_decision/non-transient-rate-limit-in-echoed-prompt: "rate limit" in the echoed prompt, over an unrelated error line, is not retried.

transient-503-attempt-2 and transient-500-attempt-3-caps-at-60 switch to the real claude and codex error forms; their expected decisions and backoffs are unchanged. The other retry and resolve vectors regenerate byte-identical, rate-limit and overloaded cases included.

https://claude.ai/code/session_015yr73szXYEmbjtNVLxnetK

…d prompt

The retry policy matched a bare 5xx, overloaded, or rate limit anywhere in the error message, and codex echoes the prompt into stderr, so a prompt mentioning "512 MiB" turned a permanent 400 into ~137s of backoff per call. is_transient now reads only the last non-empty line (codex ERROR:, claude API Error:) with a 5xx in a status position, plus the HTTP backend's exited 5xx: header. A 4xx is never transient.

Claude-Session: https://claude.ai/code/session_015yr73szXYEmbjtNVLxnetK

Claude-Session-Id: 6da31262-6ad0-4073-8a1f-91de79650dd7
@yasyf
yasyf merged commit 80e91ff into main Sep 14, 2026
17 checks passed
@yasyf
yasyf deleted the retry-regex branch September 14, 2026 10:25
yasyf added a commit that referenced this pull request Sep 14, 2026
…by version (#5)

SidecarError's doc comment claimed a host marks a response transient by
pattern-matching the composed message and a bare three-digit number leaking
through would read as a retryable 5xx. That stopped being true in v0.13.1
(#4): is_transient now reads only the CLI's last non-empty error line, and
counts a 5xx only after a `status` or `API Error` marker, never a bare
number, so it now names the keywords (`overloaded`, `rate limit`,
`status`/`API Error` 5xx) that interpolated text could actually trip.

CHANGELOG.md's Unreleased section carried the 0.12.0, 0.13.0, and 0.13.1
entries with no version headings, because the commits landing the 0.13.0
and 0.13.1 changes never renamed the section the way 4faadf0 did for
0.12.0. Split them under dated `## [0.12.0]`, `## [0.13.0]`, and
`## [0.13.1]` headings (attributed by cross-referencing each tag's
CHANGELOG.md and commit dates) and added the matching compare links,
leaving Unreleased empty.

Claude-Session: https://claude.ai/code/session_015yr73szXYEmbjtNVLxnetK
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.

1 participant