429 vs 503: retrying LLM API requests with Retry-After

What 429 and 503 ask of your client: read Retry-After, back off with jitter, cap the attempts, and know which errors are never worth retrying.

GTokens teamUpdated

The short answer

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-After is set on every 429 and 503, in whole seconds — never a date, never zero; 30 when 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, no Retry-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:

429 and 503, and what each asks of you
StatusWhat it meansWhat to do
503 provider_overloadedThe model is temporarily unavailable.Wait Retry-After and send the same request again. If it repeats, grow the wait.
429 rate_limit_exceededSlow 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.error_type on the OpenAI-shaped routes, error.error_type inside the native envelope of the Messages routes — where the client library also sees overloaded_error for a 503 and rate_limit_error 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.

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:

retry.py
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))
retry.ts
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))
    }
  }
}
retry_messages.py
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_retries (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 /api/v1; 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:

Statuses that a repeat does not fix
Statuserror_typeWhat to do instead
400invalid_requestFix the request. With reason: model_not_available the model is listed but not served yet — pick one that is.
401authenticationCheck the key and the Authorization header.
402payment_requiredTop up, or wait for a spending ceiling’s window to turn over; then the same request goes through.
403permission_deniedThe account is suspended, or the credential is the wrong kind. Write to us.
404not_foundNo 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_unavailable 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_type 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/completions it is a chunk with finish_reason: "error", 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:

stream_retry.py
# 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")
stream-retry.ts
// 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_unavailable, the code a 502 carries. This function waits out rate_limit_exceeded, provider_overloaded, provider_unavailable 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.

Image models do not fail mid-stream

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_reason: "length" — is not an error and is billed for what it produced, so a retry with a higher ceiling costs the truncated attempt as well.

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_tokens (4,096 when you do not set it) plus the input. A thin balance with a very large max_tokens can be refused even though the answer would have been short. A repeat will not change that; a max_tokens you actually need, or a top-up, will.

A successful reply on chat/completions carries usage.cost — the amount actually charged, not an estimate — and the request log in your account shows the same figure under the same id.

More in API

All in API →