The six 429s and what each one means
OpenAI's error-codes guide lists six different errors behind the same status. Two are about speed. Four are about money or quota, and OpenAI says plainly that retrying those won't restore access.
error.code | Retry? | Message, and what to do |
|---|---|---|
| none | Yes | Rate limit reached for requests: Requests or tokens per minute for the organization or project are used up. Wait for retry-after, then retry. |
slow_down | Yes | Slow down: Traffic rose too fast, even inside your limits. Retry with backoff and ramp up more slowly. |
credit_balance_exhausted | No | Credit balance exhausted: The account's paid-in balance is used up. Retrying cannot succeed; someone has to add funds. |
organization_spend_limit_exceeded | No | Organization spend limit reached: The organization's enforced monthly spend limit, across all projects. Fails until the limit is raised or the month resets. |
project_spend_limit_exceeded | No | Project spend limit reached: The project's enforced monthly spend limit. Other projects keep working. |
organization_usage_limit_exceeded | No | Organization usage limit reached: A usage limit OpenAI assigned to the organization. Fails until OpenAI raises it. |
insufficient_quota; it can still appear as the error type on billing errors, so treat it like the four billing codes.Two things follow. A retry loop that does not read the code turns "you hit your spend limit" into minutes of retries and a timeout. And because failed requests count toward your per-minute limit, a tight retry loop on a real rate limit makes the 429 last longer.
What the response tells you
Every response, successful or not, carries the state of your limits. The values below are the examples in OpenAI's rate-limits guide:
x-ratelimit-limit-requests: 60
x-ratelimit-limit-tokens: 150000
x-ratelimit-remaining-requests: 59
x-ratelimit-remaining-tokens: 149984
x-ratelimit-reset-requests: 1s
x-ratelimit-reset-tokens: 6m0s
retry-after: 56retry-afteris the minimum number of seconds to wait before retrying a temporary rate-limit error, when present. When it is missing, use exponential backoff with jitter.- The
resetheaders are durations (1s,6m0s), not timestamps. - Limits are set per organization and per project, never per user. If one project's batch job shares a key's project with your live traffic, it shares the limit too.
- The SDK puts the body's
message,type,codeandparamon the error it throws, along with the response headers and thex-request-idyou quote to OpenAI support.
What the SDK already does, and why to turn it off
Both official SDKs retry connection errors, 408, 409, 429 and 5xx twice by default, with a short exponential backoff. They honour retry-after (and retry-after-ms): the Node SDK up to 60 seconds, the Python SDK up to 120. That default is reasonable for a script. In production it has three problems:
- It retries all six 429s, including the four that cannot succeed. The only exception is a response carrying
x-should-retry: false. - It cannot fail over: two more attempts against a provider that is rate limiting you is two more requests counted against the limit.
- The default timeout is 10 minutes, far longer than a user will wait.
Set maxRetries: 0 (Node) or max_retries=0 (Python) where you handle errors yourself, and set a timeout that matches what the caller can wait.
Retry only the 429s that can succeed
This reads the code before deciding, waits what OpenAI asks for, and gives up instead of sleeping when the wait is longer than a user should see. Both files are run against OpenAI's documented responses on every build.
import OpenAI, { APIError } from "openai";
// Turn the SDK's own retries off: it retries every 429, including the ones
// that cannot succeed. The loop below reads the error code first.
const client = new OpenAI({ maxRetries: 0, timeout: 30_000 });
// 429s that no amount of waiting fixes: billing, spend limits and quota.
const QUOTA_CODES = new Set([
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
"insufficient_quota",
]);
export function isRetryable(err: unknown): err is APIError {
if (!(err instanceof APIError)) return false;
if (err.status === undefined) return true; // connection error or timeout
if (err.status === 429) return !QUOTA_CODES.has(String(err.code)) && !QUOTA_CODES.has(String(err.type));
return err.status === 408 || err.status === 409 || err.status >= 500;
}
// Wait what the server asks for; otherwise exponential backoff with jitter.
export function retryDelayMs(err: APIError, attempt: number): number {
const header = (name: string) => Number(err.headers?.get(name));
if (header("retry-after-ms") > 0) return header("retry-after-ms");
if (header("retry-after") > 0) return header("retry-after") * 1000;
const ceiling = Math.min(500 * 2 ** attempt, 8_000);
return ceiling / 2 + Math.random() * (ceiling / 2);
}
export async function withRetries<T>(
call: () => Promise<T>,
{ maxAttempts = 5, maxWaitMs = 20_000 } = {},
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await call();
} catch (err) {
if (!isRetryable(err) || attempt + 1 >= maxAttempts) throw err;
const wait = retryDelayMs(err, attempt);
// A user is waiting: past this, fail over or fail fast instead of sleeping.
if (wait > maxWaitMs) throw err;
await new Promise((resolve) => setTimeout(resolve, wait));
}
}
}
try {
const completion = await withRetries(() =>
client.chat.completions.create({
model: "gpt-5.4-mini",
messages: [{ role: "user", content: "Summarize this ticket in one line." }],
}),
);
console.log(completion.choices[0]?.message.content);
} catch (err) {
if (err instanceof APIError) {
console.error(`gave up: ${err.status} ${err.code ?? err.type} (request ${err.requestID})`);
process.exitCode = 1;
} else {
throw err;
}
}import random
import sys
import time
import openai
from openai import OpenAI
# Turn the SDK's own retries off: it retries every 429, including the ones
# that cannot succeed. The loop below reads the error code first.
client = OpenAI(max_retries=0, timeout=30.0)
# 429s that no amount of waiting fixes: billing, spend limits and quota.
QUOTA_CODES = {
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
"insufficient_quota",
}
def is_retryable(err: Exception) -> bool:
if isinstance(err, openai.APIConnectionError): # includes timeouts
return True
if not isinstance(err, openai.APIStatusError):
return False
if err.status_code == 429:
return err.code not in QUOTA_CODES and err.type not in QUOTA_CODES
return err.status_code in (408, 409) or err.status_code >= 500
def retry_delay(err: Exception, attempt: int) -> float:
"""Wait what the server asks for; otherwise exponential backoff with jitter."""
headers = err.response.headers if isinstance(err, openai.APIStatusError) else {}
for name, divisor in (("retry-after-ms", 1000), ("retry-after", 1)):
try:
value = float(headers.get(name, ""))
except ValueError:
continue
if value > 0:
return value / divisor
ceiling = min(0.5 * 2**attempt, 8.0)
return ceiling / 2 + random.random() * ceiling / 2
def with_retries(call, max_attempts=5, max_wait=20.0):
for attempt in range(max_attempts):
try:
return call()
except Exception as err:
if not is_retryable(err) or attempt + 1 >= max_attempts:
raise
wait = retry_delay(err, attempt)
# A user is waiting: past this, fail over or fail fast instead of sleeping.
if wait > max_wait:
raise
time.sleep(wait)
if __name__ == "__main__":
try:
completion = with_retries(
lambda: client.chat.completions.create(
model="gpt-5.4-mini",
messages=[{"role": "user", "content": "Summarize this ticket in one line."}],
)
)
print(completion.choices[0].message.content)
except openai.APIStatusError as err:
print(f"gave up: {err.status_code} {err.code or err.type} (request {err.request_id})", file=sys.stderr)
sys.exit(1)slow_down are retried and succeed on the third attempt; project_spend_limit_exceeded is not retried; five 5xx in a row end in a clear error.Slow down before OpenAI has to send a 429
For batch jobs and workers that control their own pace, the cheapest 429 is the one you never get. Read the remaining requests and tokens on every response and pause new work when they run low.
import OpenAI from "openai";
const client = new OpenAI();
// .withResponse() returns the parsed body and the raw response, headers included.
const { data, response } = await client.chat.completions
.create({
model: "gpt-5.4-mini",
messages: [{ role: "user", content: "Classify: 'refund not received'" }],
})
.withResponse();
const remainingRequests = Number(response.headers.get("x-ratelimit-remaining-requests"));
const remainingTokens = Number(response.headers.get("x-ratelimit-remaining-tokens"));
const resetRequests = response.headers.get("x-ratelimit-reset-requests"); // e.g. "1s"
console.log(data.choices[0]?.message.content);
console.log({ remainingRequests, remainingTokens, resetRequests });
// Slow a batch job down before OpenAI has to send a 429.
if (remainingRequests < 5 || remainingTokens < 2_000) {
console.log(`near the limit: pause new work until the window resets (${resetRequests})`);
}from openai import OpenAI
client = OpenAI()
# with_raw_response returns the HTTP response; .parse() gives the usual object.
raw = client.chat.completions.with_raw_response.create(
model="gpt-5.4-mini",
messages=[{"role": "user", "content": "Classify: 'refund not received'"}],
)
completion = raw.parse()
remaining_requests = int(raw.headers.get("x-ratelimit-remaining-requests", "0"))
remaining_tokens = int(raw.headers.get("x-ratelimit-remaining-tokens", "0"))
reset_requests = raw.headers.get("x-ratelimit-reset-requests") # e.g. "1s"
print(completion.choices[0].message.content)
print({"remaining_requests": remaining_requests, "remaining_tokens": remaining_tokens, "reset_requests": reset_requests})
# Slow a batch job down before OpenAI has to send a 429.
if remaining_requests < 5 or remaining_tokens < 2_000:
print(f"near the limit: pause new work until the window resets ({reset_requests})")When waiting is not an option: fail over
A user-facing request cannot sleep for 56 seconds. When OpenAI is rate limiting or failing you, send the request to a second provider instead, and keep three rules.
import OpenAI, { APIError } from "openai";
import Anthropic from "@anthropic-ai/sdk";
// No retries on OpenAI: a rate-limited provider stays limited for seconds, so
// the fallback IS the retry. Both clients read their keys from the environment.
const openai = new OpenAI({ maxRetries: 0, timeout: 20_000 });
const anthropic = new Anthropic({ maxRetries: 1, timeout: 20_000 });
// Never fail over on these: your own spend or quota limit said stop, and
// moving the traffic to another provider would spend around it.
const QUOTA_CODES = new Set([
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
"insufficient_quota",
]);
function shouldFailOver(err: unknown): boolean {
if (!(err instanceof APIError)) return false;
if (err.status === undefined) return true; // connection error or timeout
if (err.status === 429) return !QUOTA_CODES.has(String(err.code)) && !QUOTA_CODES.has(String(err.type));
return err.status === 408 || err.status >= 500;
}
export async function answer(prompt: string): Promise<{ text: string; servedBy: string }> {
try {
const res = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: [{ role: "user", content: prompt }],
});
return { text: res.choices[0]?.message.content ?? "", servedBy: "openai" };
} catch (err) {
if (!shouldFailOver(err)) throw err;
const res = await anthropic.messages.create({
model: "claude-haiku-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: prompt }],
});
const text = res.content.map((block) => (block.type === "text" ? block.text : "")).join("");
return { text, servedBy: "anthropic" };
}
}
const { text, servedBy } = await answer("Draft a two-line status update for a delayed order.");
// Log who served it: a fallback that nobody can see becomes a silent quality change.
console.log(`[${servedBy}] ${text}`);import anthropic
import openai
# No retries on OpenAI: a rate-limited provider stays limited for seconds, so
# the fallback IS the retry. Both clients read their keys from the environment.
openai_client = openai.OpenAI(max_retries=0, timeout=20.0)
anthropic_client = anthropic.Anthropic(max_retries=1, timeout=20.0)
# Never fail over on these: your own spend or quota limit said stop, and
# moving the traffic to another provider would spend around it.
QUOTA_CODES = {
"credit_balance_exhausted",
"organization_spend_limit_exceeded",
"project_spend_limit_exceeded",
"organization_usage_limit_exceeded",
"insufficient_quota",
}
def should_fail_over(err: Exception) -> bool:
if isinstance(err, openai.APIConnectionError): # includes timeouts
return True
if not isinstance(err, openai.APIStatusError):
return False
if err.status_code == 429:
return err.code not in QUOTA_CODES and err.type not in QUOTA_CODES
return err.status_code == 408 or err.status_code >= 500
def answer(prompt: str) -> tuple[str, str]:
try:
res = openai_client.chat.completions.create(
model="gpt-5.4-mini",
messages=[{"role": "user", "content": prompt}],
)
return res.choices[0].message.content or "", "openai"
except Exception as err:
if not should_fail_over(err):
raise
res = anthropic_client.messages.create(
model="claude-haiku-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(block.text for block in res.content if block.type == "text")
return text, "anthropic"
if __name__ == "__main__":
text, served_by = answer("Draft a two-line status update for a delayed order.")
# Log who served it: a fallback that nobody can see becomes a silent quality change.
print(f"[{served_by}] {text}")- Never fail over on a spend or quota code. Your own limit said stop; sending the traffic to another provider spends around it.
- Fail over at once instead of retrying. A rate-limited provider stays limited for seconds; the fallback is the retry.
- Log who served each request. A fallback to a different model is a quality change nobody sees unless the response says where it came from.
What to do, case by case
- Wait
retry-after, then retry - if the 429 has no code, or
slow_down, and the caller can wait that long. - Fail over to another provider
- if the 429 is a rate limit or the error is a 5xx or timeout, and a user is waiting.
- Stop and alert a human
- if the code is
credit_balance_exhausted,organization_spend_limit_exceeded,project_spend_limit_exceededororganization_usage_limit_exceeded. - Pace the job
- if a batch job sees
x-ratelimit-remaining-*approach zero. - Split traffic across projects
- if a batch job and live traffic share one project's limit.
Questions
- Should I retry insufficient_quota or a spend-limit 429?
- No. OpenAI says retrying billing, spend or quota errors won't restore access. Stop, alert someone, and show the user a clear message.
- How long should I wait after an OpenAI 429?
- The number of seconds in retry-after when the response has one. When it does not, use exponential backoff with jitter, starting around half a second and capped at a few seconds.
- Does the OpenAI SDK retry 429 errors automatically?
- Yes. The Node and Python SDKs retry 429s twice by default, whatever the error code, and honour retry-after. Set maxRetries to 0 where you handle the error yourself.