A 503 means the model is temporarily unavailable: wait the number of seconds in Retry-After and send the same request again. A 429 asks you to slow down: wait Retry-After and retry, with a cap on attempts. If the same 429 keeps coming back after waiting, stop the loop and write to support with the request identifier. The rest of the 4xx range should not be retried at all.
Retry-Afteris set on every429and503, in whole seconds — never a date, never zero;30when no better estimate exists.- There is no published per-key request rate; what stops you is money (
402) or a temporary refusal (429/503). - Wait at least what the header says, grow the wait on each repeat, add jitter on top and cap the attempts.
- A refused request is not charged. Once a stream has started, a failure arrives inside it — status
200, noRetry-After— and what was produced before it is billed.
What 429 and 503 mean on this API
Both statuses say “not now”, and both are answered the same way: wait at least what Retry-After says, then send the same request again, a limited number of times. What differs is what to do when waiting does not help:
| Status | What it means | What to do |
|---|---|---|
503 provider_ | The model is temporarily unavailable. | Wait Retry-After and send the same request again. If it repeats, grow the wait. |
429 rate_ | Slow down. | Wait Retry-After and retry, with a cap on attempts. If the same 429 keeps coming back after waiting, stop the loop and write to support with the request identifier. |
Scroll the table sideways for what to do.
A text request can also wait before it starts: when the model is busy it can be held for up to four minutes, and the 503 comes when it cannot start within that time. Set the client timeout above that — 300 seconds or more — or a request that would have gone through ends as a timeout on your side instead.
Branch on the typed code rather than on the status alone: metadata. on the OpenAI-shaped routes, error. inside the native envelope of the Messages routes — where the client library also sees overloaded_ for a 503 and rate_ for a 429.
And do not answer a 503 by switching models. It says this model cannot answer right now, not that it is the wrong model. Another model is another price and another answer, and swapping on a passing refusal is how a batch ends up half generated by something you did not choose. A request is refused, never quietly answered by a different model.
Read Retry-After as whole seconds
Retry-After is set on every 429 and every 503, always as a whole number of seconds and never as an HTTP date, so it parses as an integer with nothing else to handle. It is never zero and never negative, and when no better estimate exists it says 30.
No other status carries it: on a 500, 502 or 504 the wait is yours to choose. Four rules make a wait that works:
- Never sooner than the header. The number is not a constant, and coming back earlier mostly fails again.
- Longer on each repeat. On a second and third refusal, grow the wait rather than looping at the same interval.
- Jitter on top. Workers that were refused in the same second read the same header and come back in the same second. A small random extra above the header spreads them out — add it, never subtract it.
- A cap. A fixed number of attempts, after which the error reaches your caller with its status and
error_intact.type
A retry loop that honours the header
The loop below retries 429 and 503, never sooner than Retry-After, and gives up after a fixed number of attempts. Everything else goes straight back to the caller. The client’s own retries are switched off, so the loop is the only policy:
import random
import time
import openai
from openai import OpenAI
client = OpenAI(
base_url="https://api.gtokens.io/api/v1",
api_key="sk-sl-your-key",
max_retries=0, # this loop is the only retry policy
)
MAX_ATTEMPTS = 4
def complete(prompt, model="anthropic/claude-sonnet-5"):
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
return client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
except openai.APIStatusError as e:
# 429 and 503 are waited out; everything else goes to the caller,
# and so does the last refusal once the attempts run out.
if e.status_code not in (429, 503) or attempt == MAX_ATTEMPTS:
raise
# Whole seconds, present on every 429 and 503.
delay = int(e.response.headers.get("retry-after", "30"))
# Never sooner than the header, doubled on each repeat, jitter on top.
delay *= 2 ** (attempt - 1)
time.sleep(delay + random.uniform(0, delay / 10))import OpenAI, { APIError } from 'openai'
const client = new OpenAI({
baseURL: 'https://api.gtokens.io/api/v1',
apiKey: 'sk-sl-your-key',
maxRetries: 0, // this loop is the only retry policy
})
const MAX_ATTEMPTS = 4
const sleep = (s: number) => new Promise((done) => setTimeout(done, s * 1000))
// Whole seconds. openai v4 hands error headers over as a plain object,
// v5 and later as a Headers instance; this reads either.
function retryAfter(err: APIError): number {
const h: any = err.headers
const value = typeof h?.get === 'function' ? h.get('retry-after') : h?.['retry-after']
return Number(value) || 30
}
export async function complete(prompt: string, model = 'anthropic/claude-sonnet-5') {
for (let attempt = 1; ; attempt++) {
try {
return await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
})
} catch (err) {
if (!(err instanceof APIError)) throw err
// 429 and 503 are waited out; everything else goes to the caller,
// and so does the last refusal once the attempts run out.
const retryable = err.status === 429 || err.status === 503
if (!retryable || attempt === MAX_ATTEMPTS) throw err
// Never sooner than the header, doubled on each repeat, jitter on top.
const delay = retryAfter(err) * 2 ** (attempt - 1)
await sleep(delay + Math.random() * (delay / 10))
}
}
}import random
import time
import anthropic
from anthropic import Anthropic
client = Anthropic(
base_url="https://api.gtokens.io", # the root: the library appends /v1/messages
auth_token="sk-sl-your-key", # sent as Authorization: Bearer
max_retries=0, # this loop is the only retry policy
)
MAX_ATTEMPTS = 4
def ask(prompt, model="anthropic/claude-haiku-4-5"):
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
return client.messages.create(
model=model,
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
except anthropic.APIStatusError as e:
# The same two statuses as on the other routes.
if e.status_code not in (429, 503) or attempt == MAX_ATTEMPTS:
raise
delay = int(e.response.headers.get("retry-after", "30"))
delay *= 2 ** (attempt - 1)
time.sleep(delay + random.uniform(0, delay / 10))Both client libraries retry some statuses on their own, and max_ (maxRetries in TypeScript) decides how many times. Left on under your loop, each of your attempts becomes several, and the cap no longer means what it says. If you would rather not write a loop, most client libraries honour Retry-After on their own; a raw fetch does not.
The Messages client takes the bare root as its base URL rather than the address with /; its loop waits out the same two statuses.
What not to retry
The rest of the 4xx range should not be retried at all: the same request is refused the same way, and a loop over it only adds delay before the error your code has to handle anyway. Each of these has its own fix:
| Status | error_type | What to do instead |
|---|---|---|
400 | invalid_ | Fix the request. With reason: model_ the model is listed but not served yet — pick one that is. |
401 | authentication | Check the key and the Authorization header. |
402 | payment_ | Top up, or wait for a spending ceiling’s window to turn over; then the same request goes through. |
403 | permission_ | The account is suspended, or the credential is the wrong kind. Write to us. |
404 | not_ | No such model or path. Check the identifier. |
Scroll the table sideways for what to do instead.
Three statuses without a header sit in between: 500 server, 502 provider_ and 504 timeout. Retry them with your own pause and a cap on attempts; if one persists, write to support.
When you write to us about a refusal that keeps coming back, include the time in UTC, the route, the model identifier, the status, the error_ and the request identifier if the error carried one. That is enough to find the request.
When the stream has already started
On a streamed text answer the status is sent with the first byte. Everything decided before that byte still arrives as a real status with the usual body — and, on a 429 or 503, with Retry-After — so the rules above apply unchanged.
A failure after that point cannot be a status: a 200 already sent cannot be taken back. It arrives inside the stream, closes it, and the status stays 200. On chat/ it is a chunk with finish_, an empty delta and the usual error body beside choices, followed by data: [DONE]. On the Messages route it is an event: error frame in the native envelope, and nothing follows it.
Three things differ from a status. There is no Retry-After — the headers are long gone — so for a retryable code you back off on your own schedule. What the stream used before the failure — the prompt and what was produced — is billed, and a retry sends the whole request again. And a stream that ends without any content is a failure too, not an empty answer, and is worth retrying; like any stream that broke after its first byte, it is billed for what it used — here, the prompt.
The OpenAI client library raises the error chunk as an exception without a status but with the same body, so the three checks — the status before the stream, the failure inside it, an empty result at the end — fit in one function:
# client, MAX_ATTEMPTS and the imports as in the loop above
# Worth a wait: the typed codes of 429 and 503, and provider_unavailable —
# the code of a failure the model reports mid-answer.
WAIT_TYPES = ("rate_limit_exceeded", "provider_overloaded", "provider_unavailable")
def stream_reply(prompt, model="anthropic/claude-sonnet-5"):
for attempt in range(1, MAX_ATTEMPTS + 1):
text = ""
delay = 10 # your own base: inside a stream there is no header
try:
stream = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
stream=True,
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
text += chunk.choices[0].delta.content
if text:
return text
# A 200 that carried nothing: a failure, worth retrying.
except openai.APIStatusError as e:
# Before the first byte: a real status, with Retry-After on 429 and 503.
if e.status_code not in (429, 503) or attempt == MAX_ATTEMPTS:
raise
delay = int(e.response.headers.get("retry-after", "30"))
except openai.APIError as e:
# After the first byte the status was 200: the library raises the
# error chunk, and its error_type says what failed.
body = e.body if isinstance(e.body, dict) else {}
error_type = (body.get("metadata") or {}).get("error_type")
if error_type not in WAIT_TYPES or attempt == MAX_ATTEMPTS:
raise
if attempt < MAX_ATTEMPTS:
delay *= 2 ** (attempt - 1)
time.sleep(delay + random.uniform(0, delay / 10))
raise RuntimeError("the stream ended without any content")// client, MAX_ATTEMPTS, sleep and retryAfter as in the loop above
// Worth a wait: the typed codes of 429 and 503, and provider_unavailable —
// the code of a failure the model reports mid-answer.
const WAIT_TYPES = ['rate_limit_exceeded', 'provider_overloaded', 'provider_unavailable']
export async function streamReply(prompt: string, model = 'anthropic/claude-sonnet-5'): Promise<string> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let text = ''
let delay = 10 // your own base: inside a stream there is no header
try {
const stream = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
stream: true,
})
for await (const chunk of stream) text += chunk.choices[0]?.delta?.content ?? ''
if (text) return text
// A 200 that carried nothing: a failure, worth retrying.
} catch (err) {
if (!(err instanceof APIError) || attempt === MAX_ATTEMPTS) throw err
if (err.status !== undefined) {
// Before the first byte: a real status, with Retry-After on 429 and 503.
if (err.status !== 429 && err.status !== 503) throw err
delay = retryAfter(err)
} else {
// After the first byte the status was 200: the library throws the
// error chunk, and its error_type says what failed.
const errorType = (err.error as any)?.metadata?.error_type
if (!WAIT_TYPES.includes(errorType)) throw err
}
}
if (attempt < MAX_ATTEMPTS) {
delay *= 2 ** (attempt - 1)
await sleep(delay + Math.random() * (delay / 10))
}
}
throw new Error('the stream ended without any content')
}Inside a stream the typed code says what failed, and it is not always the code a status would have carried: a failure the model reports mid-answer arrives as provider_, the code a 502 carries. This function waits out rate_, provider_, provider_ and the empty stream; a timeout or a server failure mid-answer goes back to the caller. The streaming reference shows the same three checks for the Messages route.
A frame is finished before the first byte leaves, so for an image model everything that can go wrong still arrives as a real status, with Retry-After where the status carries one. The failure inside a stream is a text-model case.
What a retry costs
Refusals are free, which is what makes waiting them out cheap. Money is only taken for a request that ran: refusals that happen before execution never touch the balance, and a failure before the reply starts releases the whole amount held for it. A 429, 503 or 504 that arrives as a status costs nothing, however many times you retry.
Two cases do cost, and both are worth knowing before you resend:
- A stream that broke after its first byte is billed for what it used before the break: the prompt and what the model produced. The retry is a request of its own and is billed for its own answer.
- A reply cut off by length —
finish_— is not an error and is billed for what it produced, so a retry with a higher ceiling costs the truncated attempt as well.reason: "length"
One 402 looks like a retry candidate and is not. Before a request goes out, an estimate is held from the balance — for text, your max_ (4,096 when you do not set it) plus the input. A thin balance with a very large max_ can be refused even though the answer would have been short. A repeat will not change that; a max_ you actually need, or a top-up, will.
A successful reply on chat/ carries usage. — the amount actually charged, not an estimate — and the request log in your account shows the same figure under the same id.



