Next.js · AI costs ·
OpenAI and Anthropic 429 errors in Next.js: which to retry, which to stop
Your Next.js route calls OpenAI or Anthropic and gets a 429. The usual advice is to add a retry with backoff. That fixes some 429s and makes others worse, because both providers now use the same status for problems that clear in a second and problems that won't clear until someone changes a billing setting. This post shows how to tell them apart from the error code, what the official SDKs do by default, and a small helper that retries rate limits and overload but stops on the billing codes it knows.
This guide pairs with Spend Caps Kit, and if you want the next step, read Prompt caching not working? Check it in OpenAI and Anthropic (Next.js).
Checked 17 Oct 2026 with openai 7.36.0 and @anthropic-ai/sdk 0.135.0 (Node 20.19, TypeScript strict). Error codes and their meanings come from the OpenAI and Anthropic docs, quoted below and listed in Sources. Providers add codes from time to time, so check the docs against the date above.
What we did and didn't test. We didn't trigger a real 429. We ran both SDKs against a local mock server that returns error bodies and headers we rebuilt from the docs, so the bodies are our reconstruction, not captured failures. What the mock does show is real SDK behaviour: the error class each SDK throws, which fields it fills in, how many requests it sends with its default retries, and how it handles retry-after. Our helper type-checks with tsc --strict and has 20 unit tests. We aren't affiliated with or endorsed by OpenAI or Anthropic; their product and model names are their trademarks, used here only to identify them.
Quick answer: read the code, not the status
A 429 on its own doesn't tell you what to do. Look one level down:
- OpenAI: read
error.code. The error codes guide says: "For billing-related errors, inspecterror.codeto identify the specific cause. The broadererror.typecan still beinsufficient_quota." - Anthropic: read the error
type, and checkerror.details.error_codefor a spend cap.
Then split the result into two groups:
- Can clear by waiting: rate limits, ramp-up limits and overload. Retry a few times, honouring
Retry-After. - Won't clear by waiting: credits, quota and spend limits. OpenAI is direct about it: "Retrying billing, spend, or quota errors won't restore API access. Update the relevant credits or limits before sending another request." Stop, log it and show the user a clear message.
The OpenAI 429s
The OpenAI error codes guide lists several 429 causes. Here they are with what each one likely needs from you:
| What you see | Code | Retry? | What the docs say to do |
|---|---|---|---|
| Rate limit reached for requests | (rate limit) | Yes, with a cap | "Pace your requests and follow the Retry-After header when it's present." |
| Slow down | slow_down |
Yes, with a cap | Follow Retry-After, "reduce your request rate, and increase it gradually." |
| Credit balance exhausted | credit_balance_exhausted |
No | "Add credits to continue using the API." |
| Project spend limit reached | project_spend_limit_exceeded |
No | "Increase or remove the spend limit in your project settings." |
| Organization spend limit reached | organization spend code (below) | No | "Increase or remove your organization spend limit." |
| Organization usage limit reached | organization usage code (below) | No | "Request a higher approved usage limit or contact support." |
The two organization codes are too long for a table cell:
organization_spend_limit_exceeded
organization_usage_limit_exceeded
Two of these can surprise you:
slow_downcan appear under your limits. The guide says it "can occur even when your traffic is within its requests-per-minute and tokens-per-minute limits." It's about how fast your traffic grows. As a rule of thumb, once traffic reaches 1 million input tokens per minute, the guide suggests you "increase it by no more than 50% every 15 minutes."- A 429 on your first request is unlikely to be a rate limit. It's more likely one of the billing codes, which share the 429 status. Check
error.codebefore you add retries.
Overload isn't a 429 on OpenAI. The guide lists "503 - Model temporarily overloaded" with code server_is_overloaded, and notes that "Video overload, for example, now returns 503 where it previously returned 429." If your handler only catches rate-limit errors, it may miss this one.
OpenAI also sends rate-limit headers on responses. The rate limits guide lists these, among others:
retry-after
x-ratelimit-limit-requests
x-ratelimit-remaining-requests
x-ratelimit-reset-requests
x-ratelimit-limit-tokens
x-ratelimit-remaining-tokens
x-ratelimit-reset-tokens
The same guide warns that Retry-After "does not mean that quota, billing, or other errors that require user action can be resolved by retrying."
The Anthropic 429, 529 and the spend-limit 400
Anthropic's errors page describes one 429 type that covers several causes:
429 -
rate_limit_error: Your organization has hit a rate limit, reached its usage tier's monthly spend cap, or reached a spend limit on the Claude Code workspace. A tier spend-cap 429 has noretry-afterheader and keeps failing until access resumes
The rate limits page explains how to tell the spend cap apart: on the Messages API, error.details.error_code is enforced_spend_limit_reached. A real rate limit names the limit you hit (requests, input tokens or output tokens per minute), and "When the response has a retry-after header, wait that many seconds before retrying."
Three more cases are worth handling:
| What you see | Status and type | Retry? |
|---|---|---|
| Rate limit (RPM, ITPM or OTPM) | 429 rate_limit_error |
Yes, with a cap |
| Tier spend cap | 429 rate_limit_error, enforced_spend_limit_reached |
No |
| API overloaded | 529 overloaded_error |
Yes, with a cap |
| Spend limit you set on the organization or workspace | 400 invalid_request_error |
No |
The 400 one is easy to miss. The errors page says: "The API also returns a 400 when usage reaches an organization or workspace spend limit you set." It won't show up in a 429 handler at all.
The errors page also mentions acceleration limits: "if your organization has a sharp increase in usage, you might see 429 errors because of acceleration limits on the API. To avoid hitting acceleration limits, ramp up your traffic gradually and maintain consistent usage patterns."
Anthropic's rate-limit headers have longer names, for example:
retry-after
anthropic-ratelimit-requests-limit
anthropic-ratelimit-requests-remaining
anthropic-ratelimit-requests-reset
anthropic-ratelimit-input-tokens-remaining
What the SDKs do by default (our mock test)
Both SDKs retry for you. The OpenAI SDK README says: "Certain errors will be automatically retried 2 times by default, with a short exponential backoff." It lists "429 Rate Limit, and >=500 Internal errors" among them. Anthropic's errors page says the SDK "automatically retries transient failures (such as connection errors, rate limits, and 5xx server errors) with exponential backoff, twice by default, honoring the retry-after header when present."
We counted the requests our mock server received. These are the results from our run:
- Every 429 arrived as
RateLimitErrorin both SDKs, including the credit, quota and spend-limit bodies. The class alone doesn't tell you whether to retry. - With default settings, the mock received 3 requests for every 429, 503 and 529 body, including
insufficient_quotaandcredit_balance_exhaustedas well as the spend-limit codes and Anthropic's spend cap. So by default each failed user action can cost three calls that the docs say won't succeed. The Anthropic 400 got 1 request. - With
maxRetries: 0, the mock received exactly 1 request for every case. - OpenAI 503 and Anthropic 529 arrived as
InternalServerError, notRateLimitError. The Anthropic 400 arrived asBadRequestError. retry-after: 1produced gaps of about one second in both SDKs.retry-after: 120was handled differently.openai7.36.0 retried after about half a second and one second, so it didn't wait the 120 seconds. We didn't check whether that's a deliberate limit in the SDK; either way, don't rely on it to honour long waits.@anthropic-ai/sdk0.135.0 waited over two minutes before each retry, so the call took about five minutes to fail. In a serverless route, a wait that long may hit your function timeout first.- Where the code lives differs. In
openai,err.codeanderr.typewere set (for exampleproject_spend_limit_exceededandinsufficient_quota). In@anthropic-ai/sdk,err.codewas empty, and the type and details were insideerr.error.error.
Our mock bodies are reconstructions, and real responses can differ. The SDK behaviour above is what the code did with those bodies.
One classifier for both SDKs
This helper reads both SDKs' errors and returns what to do. It looks at spend and billing codes first, so a billing 429 with one of these codes is never treated as a rate limit. A code added later would be treated as a rate limit and retried within the caps, so check the docs for new codes:
// lib/provider-error.ts
export type ProviderErrorKind =
| "rate_limit" | "overloaded" | "quota_or_billing" | "spend_limit" | "other";
export type Classified = {
kind: ProviderErrorKind; retryable: boolean;
retryAfterMs: number | null; code: string | null;
};
const BILLING = new Set(["insufficient_quota", "credit_balance_exhausted",
"organization_usage_limit_exceeded"]);
const SPEND = new Set(["project_spend_limit_exceeded",
"organization_spend_limit_exceeded", "enforced_spend_limit_reached"]);
const SPEND_400 = "You have reached your specified"; // Anthropic 400 prefix
type Headersish = { get(name: string): string | null } | undefined;
const str = (v: unknown) => (typeof v === "string" ? v : null);
export function parseRetryAfter(h: Headersish, now = Date.now()): number | null {
const ms = h?.get("retry-after-ms");
if (ms && Number.isFinite(Number(ms))) return Math.max(0, Number(ms));
const v = h?.get("retry-after");
if (!v) return null;
const s = Number(v);
if (Number.isFinite(s)) return Math.max(0, s * 1000);
const d = Date.parse(v); // HTTP-date form
return Number.isNaN(d) ? null : Math.max(0, d - now);
}
export function classifyProviderError(err: unknown): Classified {
const e = (err ?? {}) as Record<string, unknown>;
const status = typeof e.status === "number" ? e.status : undefined;
const h = e.headers instanceof Headers ? e.headers : undefined;
const body = (e.error ?? {}) as Record<string, unknown>;
const inner = (body.error ?? body) as Record<string, unknown>; // Anthropic nests it
const details = (inner.details ?? {}) as Record<string, unknown>;
const code = str(details.error_code) ?? str(e.code) ?? str(inner.code);
const type = str(e.type) ?? str(inner.type);
const message = str(inner.message) ?? "";
const retryAfterMs = parseRetryAfter(h);
const out = (kind: ProviderErrorKind, retryable: boolean): Classified =>
({ kind, retryable, retryAfterMs: retryable ? retryAfterMs : null, code: code ?? type });
if (code && SPEND.has(code)) return out("spend_limit", false);
if (status === 400 && message.startsWith(SPEND_400))
return out("spend_limit", false);
if ((code && BILLING.has(code)) || type === "insufficient_quota")
return out("quota_or_billing", false);
if (status === 429) return out("rate_limit", true);
if (status === 529 || status === 503 || type === "overloaded_error")
return out("overloaded", true);
return out("other", false);
}
The docs don't give a separate code for the Anthropic spend-limit 400, but the rate limits page says its message begins "You have reached your specified API usage limits" or "You have reached your specified workspace API usage limits". So the helper treats a 400 with that opening as spend_limit, not retryable, and the route below stops and returns the same message as for billing errors. If Anthropic rewords the message, it would fall back to other.
A capped retry that stops on known billing errors
Turn the SDK's own retries off with maxRetries: 0, so you don't stack two retry loops, and use one you control. This one caps attempts, caps each wait and the total wait, honours Retry-After inside those caps, and gives up straight away on anything not retryable:
// lib/provider-retry.ts
import { classifyProviderError } from "./provider-error";
export async function withProviderRetry<T>(
fn: () => Promise<T>,
{ maxAttempts = 3, maxTotalWaitMs = 20_000, maxSingleWaitMs = 10_000 } = {},
): Promise<T> {
const attempts = Math.min(maxAttempts, 5);
let waited = 0;
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
const c = classifyProviderError(err);
if (!c.retryable || attempt >= attempts) throw err;
const backoff = 500 * 2 ** (attempt - 1) * (1 - Math.random() * 0.25);
const wait = c.retryAfterMs ?? backoff;
// A Retry-After longer than our caps means "not now": fail fast.
if (wait > maxSingleWaitMs || waited + wait > maxTotalWaitMs) throw err;
waited += wait;
await new Promise((r) => setTimeout(r, wait));
}
}
}
In our unit tests, a billing or spend error led to one call and no wait. A rate limit with retry-after: 1 led to three calls with two one-second waits. A retry-after: 120 failed at once without sleeping, and a 529 with no header backed off at 500 ms and then 1 s.
Pick the caps to fit your route's timeout. If your platform stops functions after a set time, keep the total wait well under it.
Wiring it into a Next.js route
The route takes the user id from the auth session, never from the request body, and returns a status your UI can act on:
// app/api/chat/route.ts
import { createHash } from "node:crypto";
import OpenAI from "openai";
import { createClient } from "@/lib/supabase/server";
import { classifyProviderError } from "@/lib/provider-error";
import { withProviderRetry } from "@/lib/provider-retry";
const openai = new OpenAI({ maxRetries: 0 }); // our helper does the retrying
export async function POST(req: Request) {
const supabase = await createClient();
const { data: auth } = await supabase.auth.getClaims();
const userId = auth?.claims.sub; // from the verified session only
if (!userId) return Response.json({ error: "Sign in first" }, { status: 401 });
// OpenAI gets a hash, not the raw id
const safetyId = createHash("sha256").update(userId).digest("hex");
const { message } = (await req.json()) as { message: string };
try {
const res = await withProviderRetry(() =>
openai.responses.create({
model: process.env.OPENAI_MODEL!,
input: message,
safety_identifier: safetyId,
}),
);
return Response.json({ text: res.output_text });
} catch (err) {
const c = classifyProviderError(err);
console.error("provider error", { userId, kind: c.kind, code: c.code });
if (c.kind === "rate_limit" || c.kind === "overloaded") {
const secs = Math.ceil((c.retryAfterMs ?? 5_000) / 1000);
return Response.json({ error: "Busy, try again shortly" },
{ status: 503, headers: { "Retry-After": String(secs) } });
}
if (c.kind === "quota_or_billing" || c.kind === "spend_limit") {
return Response.json({ error: "AI is unavailable right now" }, { status: 503 });
}
return Response.json({ error: "Something went wrong" }, { status: 500 });
}
}
A few choices here are deliberate:
- Your users get a 503, not the provider's 429. A 429 from your route should mean "this user is sending too much". When the provider is the problem, that's a service issue on your side.
- Known billing errors are logged, not retried, and the message doesn't expose your account state to users. Alert yourself on
quota_or_billingandspend_limit. - OpenAI gets a hash of the user id, not the raw id. OpenAI suggests hashing the identifier you send, so we pass a SHA-256 hash of the user id as
safety_identifier. - The same pattern works for Anthropic. Create the Anthropic client with
maxRetries: 0and callmessages.create. The classifier already reads the nested Anthropic error.
Why one user can cause 429s for everyone
OpenAI's error codes guide says that "limits are applied per organization and not per user." Anthropic's limits are also set at the organization level. If one signed-in user scripts your chat route, they use up the shared requests and tokens per minute, and every other user starts seeing errors.
Retries don't help with that. What helps is a limit per user in your own app, keyed on the session user id, so one account can't use up the shared allowance. Our per-user spend limits post walks through a per-user rate limit and a monthly dollar cap in Supabase. To estimate what each user may cost per month, and how many requests a day a given cap would allow, you can try our AI API cost calculator.
Reduce rate-limit pressure with prompt caching
On Anthropic, cached reads can take pressure off your input-token limit. The rate limits page says: "For most Claude models, only uncached input tokens count toward your ITPM rate limits." If your prompts share a long prefix, getting caching to work can leave more room under that limit. Our prompt caching post covers how to check whether requests actually hit the cache.
Checklist
- Read
error.code(OpenAI) or the type anddetails.error_code(Anthropic) before deciding to retry. - Never retry credit, quota or spend-limit errors. Alert on them.
- Set
maxRetries: 0on the SDK if you run your own retry loop, so the two don't stack. - Cap attempts, each wait and the total wait. Honour
Retry-Afteronly within those caps. - Handle OpenAI 503 and Anthropic 529 as overload, and the Anthropic spend-limit 400 as not retryable.
- Take the user id from the session, and limit each user so one account can't exhaust the organization's limits.
Related
- OpenAI and Anthropic spend limits per user in Next.js: a per-user rate limit and a monthly dollar cap in Supabase.
- Prompt caching not working? Check it in OpenAI and Anthropic (Next.js): how to confirm cache hits and price cached tokens.
Disclosure: this is our own paid product. Spend Caps Kit ($19 plus VAT where applicable) is copy-paste TypeScript and one Supabase migration for Next.js. It adds per-user rate limits (five presets, on Upstash or a Supabase Postgres counter, returning 429 with Retry-After when a user goes over) and a monthly dollar cap per user, priced from each response's token usage. It doesn't fix provider 429s or classify them; it helps limit how much any one user can consume, so a single account is less likely to use up your shared limits.
Sources (checked 17 October 2026):