Skip to content

HTTP 429 is surfaced as "exceeded retry limit" without retrying; expose retry_429 or clarify error #30471

Open

Description

@nyl199310

What version of Codex CLI is running?

Source review against main at bdd282f3bbd55df3a869a5438519cd948c134d4d (2026-06-27).

What subscription do you have?

N/A - source-review / reliability issue.

Which model were you using?

N/A.

What platform is your computer?

N/A.

What issue are you seeing?

There are many user reports of errors like:

exceeded retry limit, last status: 429 Too Many Requests, request id: ...

After tracing the current source, this message appears misleading for at least one common path: a direct HTTP 429 from the Responses HTTP/WebSocket wrapper does not appear to be retried by the local request retry policy, because retry_429 is hard-coded to false for providers.

Relevant code:

// codex-rs/model-provider-info/src/lib.rs
let retry = ApiRetryConfig {
    max_attempts: self.request_max_retries(),
    base_delay: Duration::from_millis(200),
    retry_429: false,
    retry_5xx: true,
    retry_transport: true,
};

The retry machinery does support 429 in principle:

// codex-rs/codex-client/src/retry.rs
TransportError::Http { status, .. } => {
    (self.retry_429 && status.as_u16() == 429)
        || (self.retry_5xx && status.is_server_error())
}

But since providers set retry_429: false, a direct HTTP 429 falls through and is mapped here:

// codex-rs/codex-api/src/api_bridge.rs
} else if status == http::StatusCode::TOO_MANY_REQUESTS {
    ...
    CodexErr::RetryLimit(RetryLimitReachedError {
        status,
        request_id: extract_request_tracking_id(headers.as_ref()),
    })
}

The displayed text then comes from:

// codex-rs/protocol/src/error.rs
"exceeded retry limit, last status: {}{}"

And CodexErr::RetryLimit(_) is explicitly non-retryable in CodexErr::is_retryable(), so the turn-level stream retry loop will not retry it either.

This creates two problems:

  1. The user-facing message says exceeded retry limit, but in this path the client may not have retried the HTTP 429 at all.
  2. request_max_retries is user-configurable, but users cannot make HTTP 429 retryable because retry_429 is not exposed in config.toml / ModelProviderInfo.

This is distinct from SSE response.failed rate-limit events. Those can become ApiError::Retryable and use the stream retry path. The confusing behavior here is for direct HTTP 429 responses from the transport/wrapper layer.

What steps can reproduce the bug?

Source-level reproduction / audit:

  1. Configure a provider normally, with default request_max_retries.
  2. Have the Responses endpoint return an HTTP 429 before a successful SSE stream is established.
  3. Observe that provider retry config has retry_429: false.
  4. The request is mapped to CodexErr::RetryLimit and displayed as exceeded retry limit, last status: 429 Too Many Requests.
  5. CodexErr::RetryLimit is non-retryable, so the turn fails instead of entering the stream retry/backoff path.

What is the expected behavior?

At minimum, the behavior and message should be aligned:

  • If HTTP 429 is not retried by design, do not call it exceeded retry limit; display a clearer message such as rate limited: HTTP 429 Too Many Requests and include any useful reset/retry-after information if available.
  • If HTTP 429 is intended to be retryable, set retry_429: true for appropriate providers or expose it as a provider config option.

A possible config shape:

[model_providers.openai]
request_max_retries = 8
retry_429 = true

Potential implementation points:

  • Add retry_429: Option<bool> to ModelProviderInfo.
  • Add it to core/config.schema.json.
  • In to_api_provider(), use something like retry_429: self.retry_429.unwrap_or(false) or a provider-specific default.
  • Consider honoring Retry-After headers for transport-level 429, not only delay text parsed from SSE response.failed messages.
  • Consider renaming or splitting RetryLimitReachedError so direct 429s are not mislabeled as retry exhaustion.

Additional information

Related user reports include many issues with this exact text, for example:

In #26034, a maintainer noted an active incident and said it was not client-side. This issue is not disputing that some 429s are service-side incidents. It is specifically about the local client behavior and wording when a transport-level HTTP 429 is received.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    rate-limitsIssues related to rate limits, quotas, and token usage reporting
    configIssues involving config.toml, config keys, config merging, or config updates
    on Jun 28, 2026
  2. wgu9 commented on Jun 29, 2026

    @wgu9

    Current code already has the pieces to make this clearer, but the behavior is ambiguous.

    In codex-rs/codex-api/src/provider.rs, retry policy has an explicit retry_429 flag. Several endpoint configs set it to false, including realtime/search/images/models paths. In codex-rs/codex-api/src/api_bridge.rs, HTTP 429 is only mapped to UsageLimitReached when the body matches the Codex usage-limit shape; otherwise it falls through to a more generic transport/API error path.

    So there are two separate cases that should probably have different user-facing messages:

    • 429 usage limit reached: parse headers/body into UsageLimitReached as today
    • 429 not retried because retry_429=false: do not say or imply that a retry limit was exhausted

    Suggested fix boundary:

    • preserve retry_429=false for endpoints where automatic retry is not wanted
    • but when the first 429 is not retried, surface wording like HTTP 429 received; this endpoint is configured not to retry 429 automatically
    • include whether a Retry-After header was present
    • add a unit test with a non-usage-limit 429 body and retry_429=false to prevent the misleading “exceeded retry limit” wording
  3. callmemorgan commented on Sep 24, 2026

    @callmemorgan

    +1 on this issue. Reading through the code, this looks like either an over-optimization against OpenAI's first party APIs, or a bug. I would expect 429s should be retryable, especially if the underlying service provider supplies a Retry-After: header.

    The rest that follows is LLM-generate (possible slop):


    Note

    GPT-6 in Codex, posting on behalf of Morgan.

    We're hitting this too. We're configured against an API that frequently returns HTTP 429, and want to use Codex for long-running jobs with no fixed completion deadline. Waiting for capacity is acceptable; having to manually restart the turn after each temporary rejection is not.

    The same behavior is still present in 12fd929f724371104a1dea7c14930b078d6b4a18:

    One clarification about Retry-After: the HTTP transport already parses it, and the API error mapper preserves it. The terminal classification prevents that advice from being used for these 429s.

    We'd like an opt-in policy that retries temporary capacity/rate-limit responses for as long as the job remains active, honors Retry-After, and otherwise uses exponential backoff with a capped delay and jitter. It should remain cancellable and keep genuine quota/billing failures terminal. The existing retry machinery appears to cover much of this; the 429 classification is the immediate blocker.

  4. WhiteWarrior625 commented on Oct 26, 2026

    @WhiteWarrior625

    Submitted by Codex on behalf of @WhiteWarrior625.

    Confirmed a related diagnostic problem in Codex CLI 0.164.0, using ChatGPT Pro, gpt-6.1-sol, Standard service, through CLIProxyAPI 8.0.38 on Linux. This is an observed incident plus a source trace, not a deterministic reproduction of OpenAI capacity rejection.

    On October 26 at approximately 13:54 UTC, the inspected proxy response was HTTP 429 with:

    {"error":{"code":"turn_rate_limit_exceeded","message":"Codex is at capacity, please try again later","type":"rate_limit_error"}}

    The app-server consumer received the generic responseTooManyFailedAttempts error and exceeded retry limit, last status: 429 Too Many Requests. The detailed capacity explanation was absent from the persisted failure.

    The authenticated account check reported ordinaryUsageAllowed=true and 2% weekly usage. That establishes remaining weekly allowance, but does not rule out a separate request, model or concurrency restriction. A subsequent native Codex request through the same provider configuration completed successfully.

    The released 0.164.0 source explains the lost detail:

    Please preserve a safe structured upstream error code/message, or expose recognized capacity rejection distinctly, while preserving genuine quota/reset behavior. When the cause is unknown, neutral HTTP 429 wording would be more accurate than suggesting a known exhausted allowance.

    This affects downstream clients too: T3 Code #24885 shows the resulting misleading usage-limit explanation. Our bounded proxy retries mitigate brief refusals; they do not repair the lost diagnostic information in Codex.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIbugSomething isn't workingconfigIssues involving config.toml, config keys, config merging, or config updatesrate-limitsIssues related to rate limits, quotas, and token usage reporting

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions