exceeded retry limit, last status: 429 Too Many Requests
This line is a report, not a failure. It is written by the client after its
retry policy ran out, and it summarizes several requests rather than describing
one. That already rules out most of the classes people check: the request was
well-formed (a malformed one returns 400 and is not retried), authentication
worked (401 is not retried either), and the platform was not down — model
overload is 503 with server_is_overloaded, a different status that a handler
keyed on rate limiting will miss entirely. Every attempt reached OpenAI and
every attempt was refused. The question is which refusal, and whether the loop
ever had a chance.
Data as of 2026-09. Vendor limits and defaults change; check the official docs for current values before acting on any number below.
The report kept the least informative field
last status: 429 is the HTTP status of the final attempt. Three things it does
not tell you, each of which matters more:
Were all the attempts the same? A run of identical 429s is a pacing problem.
A run that started with 500s or 503s and ended on a 429 is a capacity event
that pushed your traffic into a burst on recovery — same ending, different
cause, different fix. Only your client’s logs have the earlier statuses.
What was error.code? “429 Too Many Requests” is the generic HTTP reason
phrase, and it is identical across at least five distinct conditions.
error.code is what separates them, and it is the field the summary drops.
Several of those conditions could never have succeeded no matter how many
attempts the policy allowed — the documentation is blunt about it: “Retrying
billing, spend, or quota errors won’t restore API access. Update the relevant
credits or limits before sending another request.”
How many requests actually went out? Probably more than the retry count suggests — see below.
Also worth knowing before you go looking for a vendor page about it: the attempt count and backoff schedule are your client’s, not OpenAI’s. An SDK, a CLI, or your own wrapper chose them. If you want to change that number, it is configured wherever the client is configured, and the provider has no say in it.
Is retrying useful?
Yes for an ordinary rate limit — but the retry that works is a slower one, not a longer one.
Honor Retry-After when it is present and valid; otherwise use exponential
backoff with jitter, and cap both the attempt count and the total retry time.
Do not invent a fixed wait: the documented guidance is “short exponential
backoff” and no base delay or cap is published for you to copy, and the same
docs note that handling of Retry-After — especially long delays — varies by
SDK version and configuration.
That variance is itself a cause of this message. An SDK may return the
original HTTP error rather than retry when the server-requested delay exceeds
the maximum it supports. So “exceeded retry limit” can mean the server asked for
a wait your client was unwilling to take, not that the server refused you
indefinitely. Check the Retry-After value on the final response before
concluding anything: a large value there changes the diagnosis from “I am being
throttled” to “my client will not wait that long.”
And the hard bound on the verdict: if error.code names credits, spend, or a
usage limit, retrying was never going to work, and the whole run was a loop
reproducing a certainty.
Retrying spends the thing you are waiting for
The instinct after reading “exceeded retry limit” is to raise the retry limit. It is the wrong direction, and the reason is specific rather than philosophical: failed requests still count against your per-minute limit. The documentation states this outright.
So a retry loop against a rate limit is not neutral while it waits — it consumes the allowance it is waiting to get back. Each refused attempt pushes recovery further out. Doubling the attempts doubles the consumption and delays the moment the window clears. This is the mechanism behind the experience of a client that “keeps failing harder the longer it runs”.
There is a second multiplier that catches people with their own wrappers. The
official SDKs already retry eligible failures — connection errors, 408, 409,
429 and 5xx — twice by default. A loop of your own around that does not add
attempts, it multiplies them: five outer attempts over an SDK doing two inner
retries is fifteen requests, not five, and the count in this message is the
outer one. The docs say what to do about it: “If you manage retries in your
application, disable SDK retries or account for them.” Until you do, your real
request volume is not the number you think you configured, and it is the real
volume the limit counts.
Which 429 you were looping against
Each of these has an observation that confirms it, and you need the response
body or headers from one of the failed attempts — which is the argument for
logging error.code and x-request-id on every failure before you need them.
error.codeis absent andRetry-Afteris present — an ordinary rate limit on requests or tokens. Retrying was correct; the policy was just too aggressive to let the window clear.error.code: slow_downwitherror.type: rate_limit_error— a ramp-rate limit. The docs are explicit that this “can occur even when your traffic is within its requests-per-minute and tokens-per-minute limits. It reflects how quickly traffic increased, not whether you exhausted those limits.” The confirming observation is headroom:x-ratelimit-remaining-tokensstill healthy while you are being refused.error.code: credit_balance_exhausted— prepaid credits are gone. Every attempt in your run failed for this and every future one will.organization_spend_limit_exceededorproject_spend_limit_exceeded— a cap you configured. Nothing client-side clears it.organization_usage_limit_exceeded— the usage limit OpenAI assigns your tier, which is a different number from the spend limits you set yourself. Needs a limit increase, not a retry.- A
503earlier in the run — model overload rather than your usage. Note that SDKs raiseInternalServerErrorfor503andRateLimitErrorfor429, so a handler catching only the rate-limit class treats overload as an unhandled exception.
Two header details to read correctly while you are in there: the x-ratelimit-reset-*
values are duration strings like 6m0s and 1s, not timestamps, and the
project-scoped token headers appear only when a project-scoped limit applies —
their absence is not evidence of headroom.
Fix by what you found
- Ordinary rate limit — reduce concurrency before you touch anything else. Per-minute limits are about arrival rate, and halving in-flight requests clears them faster than any prompt change. Then add jitter, so a batch of clients that failed together does not retry together.
slow_down— introduce a ramp instead of going idle-to-full in one step. The documented rule of thumb is that once traffic reaches 1 million input tokens per minute, you should increase it by no more than 50% every 15 minutes — with the docs’ own hedge attached, that “the exact point at which the ramp-rate limit applies can vary by model and traffic conditions.” Treat it as a shape to copy, not a threshold to sit under.- Any billing, spend or quota code — stop retrying and fix the account side. Raising the retry limit here converts a clear failure into a slow one.
- Nested retries — turn one of the two layers off. Keep the SDK’s and drop yours, unless you need per-request policy the SDK cannot express.
- A
Retry-Afterlonger than your client tolerates — either let the client wait, or queue the work rather than looping on it. Batch endpoints exist for exactly this and are counted against separate queue limits rather than your per-minute pool.
Confirming the fix
“It worked when I ran it again” is not evidence: limits refill, and a single success is indistinguishable from having waited while you edited the config.
Re-run the same workload at the same volume for several minutes and count two
things — total retries attempted, and x-ratelimit-remaining-requests and
-tokens at their lowest point. A fixed workload is one where the retry count
falls to near zero and the remaining counters never bottom out, not one where
the final request happened to land. If you changed the ramp, the falsifiable
version is that the first minute of the run no longer produces refusals, since
the ramp-rate case fails at the beginning of a burst rather than at its peak.
For the account-side cases the confirmation is simpler and binary: the first
request that previously returned a 429 with a billing or quota error.code
returns a normal response. Nothing but the account change can produce that
transition, which is exactly what makes it a real test.
Related errors
If the body you captured says the quota is exhausted rather than the rate too
high —
You exceeded your current quota, please check your plan and billing details
— then the retry run never had a chance and the fix is entirely on the billing
side. The same condition surfaced through the Python SDK appears as
a RateLimitError carrying insufficient_quota,
which is the case most likely to be misread as throttling because of the
exception class it arrives in. And if you are sizing concurrency rather than
diagnosing a single run, the
API rate limit calculator works in the
dimension that actually refused you — arrival rate against your tier — which is
the number this message compressed away.