Fix OpenAI API 401 and 429 Errors: A Step-by-Step Debugging Guide

A 401 means your request never got past authentication β€” the server rejected the key itself before it even looked at what you were asking for. A 429 means the opposite: the key checked out, but something downstream throttled you, either a request-rate limit or an account that has run out of quota. This guide walks through both failure modes for any OpenAI-compatible API, with a copy-pasteable Python backoff snippet and notes on where CaMeL Hub's console, per-key model restrictions, and balance system change the diagnosis.

Decode the 401: Four Usual Suspects

A 401 from a Chat Completions-style endpoint almost always traces back to one of four things. Check them roughly in this order before assuming the provider's servers are at fault.

429 Has Two Different Root Causes β€” Don't Treat Them the Same

A 429 only tells you the request didn't go through this time; it doesn't tell you why. Rate limiting throttles requests-per-minute or tokens-per-minute to protect shared infrastructure, and it resolves itself once you back off β€” wait a bit, and the same call succeeds. Quota exhaustion means the account's prepaid balance or plan allowance ran out; it's a billing state, not a timing problem, and no amount of waiting or retrying fixes it.

Most OpenAI-compatible providers put a distinguishing field inside the JSON error body β€” commonly `error.type` or `error.code` β€” with values like `rate_limit_exceeded` versus `insufficient_quota`. Read that field before deciding whether to retry; retrying a quota error just burns time and API calls for a result that will never change.

Read Retry-After Before You Guess a Delay

When a 429 response includes a `Retry-After` header (in seconds), it's the most accurate wait time you'll get β€” the server is telling you exactly when the limit resets. Sleeping for that value once is faster and safer than a fixed guess: guess too short and you get rejected again for retrying while still over the limit; guess too long and you add latency the server never asked for.

Not every provider sends `Retry-After` consistently, so client code needs to handle its absence gracefully and fall back to exponential backoff rather than assuming it's always present.

A Copy-Pasteable Exponential Backoff Wrapper (Python)

The wrapper below retries on rate-limit errors, honors `Retry-After` when the response includes it, falls back to exponential backoff with jitter when it doesn't, and β€” critically β€” never retries a quota-exhaustion error, since waiting longer doesn't add money to an empty balance.

import random
import time

import openai

def call_with_backoff(client, *, max_retries=5, base_delay=1.0, max_delay=30.0, **kwargs):
    # Retries on 429 rate limits, honors Retry-After when present, and
    # never retries a quota-exhaustion error -- waiting longer never
    # fixes an empty balance.
    for attempt in range(max_retries + 1):
        try:
            return client.chat.completions.create(**kwargs)
        except openai.RateLimitError as e:
            body = getattr(e, 'body', None) or {}
            error_type = (body.get('error') or {}).get('type', '')
            if 'quota' in error_type or 'insufficient' in error_type:
                raise  # billing problem -- retrying will not help

            if attempt == max_retries:
                raise

            retry_after = e.response.headers.get('retry-after') if e.response else None
            if retry_after is not None:
                delay = float(retry_after)
            else:
                delay = min(max_delay, base_delay * (2 ** attempt))
                delay += random.uniform(0, delay * 0.25)  # jitter

            time.sleep(delay)

Cut Burst Traffic With Batching and Concurrency Caps

The cheapest way to avoid 429s is to generate fewer of the request spikes that trigger them in the first place. Three changes cover most cases: batch what can be batched β€” the embeddings endpoint natively accepts a list of strings in one call instead of one call per item, so loop-and-call patterns are often just a refactor away from a single request; cap concurrency explicitly with something like `asyncio.Semaphore(n)` or a bounded thread pool instead of letting an agent framework fan every tool call out in parallel; and stagger scheduled jobs with a small random jitter so multiple workers or cron triggers firing at the same second don't all hit the API in the same instant.

CaMeL Hub Specifics: Where the Diagnosis Differs

Keys β€” called tokens on CaMeL Hub β€” are created and managed entirely in the console, under Console β†’ Token. A 401 here is almost always one of: the token was deleted, it expired, or it wasn't copied correctly when it was issued (it's shown once, like most providers). Revoking a token invalidates every request signed with it immediately, with no propagation delay to account for.

Per-token model restrictions can produce an auth-style rejection even on a perfectly valid, funded token. Each token can be scoped to an allow-list of models in the console; calling a model outside that list comes back as a 401/403-style error rather than a clearer model-not-permitted message β€” check the token's allowed-models list before assuming the key itself is broken.

Insufficient balance does not return a 429 on CaMeL Hub. It surfaces as a distinct quota error rather than a rate-limit response, so a retry loop that only watches for HTTP 429 and blind-retries everything will spin through its full backoff schedule against a genuinely empty account and still never succeed. Check the error body for a quota-specific message and top up rather than retrying.

Quick Diagnostic Checklist

When something's failing and you need the short version, work through these in order.