Skip to content

Diagnostic LLM APIs

429 credit_balance_exhausted retried forever as a rate limit

Traffic did not degrade, it stopped. Every request comes back 429, your retry wrapper does what it was built to do, and eight hours later it is still doing it. The status code says slow down. The code field inside the body says the account is out of money, and nothing in the SDK draws a line between the two — RateLimitError is raised for both.

Read-only key Python and Node.js Tests included
Two server racks
Photo by Eric Stoynov on Unsplash
The short answer

Branch on error.code, never on the status. A 429 whose code is insufficient_quota, credit_balance_exhausted, organization_spend_limit_exceeded, project_spend_limit_exceeded or organization_usage_limit_exceeded is a billing wall: it will still be there after a thousand retries. Only a missing code, or rate_limit_exceeded, is worth backing off against.

To see it coming rather than after the fact, read month-to-date spend from GET /v1/organization/costs against your tier's monthly ceiling, and watch num_model_requests in GET /v1/organization/usage/completions for the hour it falls off a cliff.

The problem in plain words

The overload is the whole problem. HTTP 429 means "too many requests", and OpenAI uses it for at least five conditions of which only one is about how many requests you sent. The other four are about money: no prepaid credits, an org spend limit you set, a project spend limit somebody else set, and the monthly ceiling OpenAI assigns your usage tier. All five arrive as the same status, and every official SDK maps that status to one exception class before anything reads the body.

So the retry wrapper — which is correct code, written by someone who read the rate-limit guide — treats a wall as a queue. It sleeps, it jitters, it doubles, and it asks again. Nothing about the response changes, because nothing about the account has changed. The service is not busy; it is closed. Meanwhile the one signal that would have paged somebody, a hard failure, never happens: from the outside the process looks alive and merely slow.

Balance runsoutor a spend cap ishitAPI answers 429codeinsufficient_quotaSDK raisesRateLimitError, asalwaysWrapper backsoffsleeps, doubles,asks againStill 429 athour 8traffic stopped,no pageforever
Nothing here throws. The wrapper is doing exactly what it was written to do, against a condition that no amount of waiting changes.

Why it happens

The status code carries less information than the body. 429 is the transport's opinion. error.code is the platform's. Retry logic written against the first is guessing, and the guess is wrong four times out of five here.

The SDKs collapse the distinction before you see it. openai.RateLimitError is raised for a genuine throttle and for an empty balance alike, because it is keyed on the status. The code is still on the exception, but nothing forces you to look, and the obvious except RateLimitError: backoff() never does.

Anthropic puts the same wall behind a different status. An exhausted Claude balance is a 400 invalid_request_error whose message reads "Your credit balance is too low", not a 429 at all. A cross-provider retry layer that special-cases OpenAI's codes still retries Anthropic's wall, or worse, treats a genuine 429 rate_limit_error from Anthropic as fatal because it has no code field to match on.

You cannot go back and count the damage. Neither API exposes a request log, so there is no endpoint that will tell you how many calls got a 429 yesterday or which code they carried. The evidence available to a read-only script is the shape of the aggregate usage buckets and a live probe, which is why the detection here is a cliff in num_model_requests rather than an error rate.

The fix, as a flow

The classifier branches on the error code before it looks at the status, because the status is the same for a throttle you should wait out and a balance that will still be empty tomorrow.

429 receivedread error.code firstrate_limit_exceededa real throttle, back offinsufficient_quotano balance, add creditsspend_limit_exceededa cap you set, raise itA code nobody knowsfail loudly, do not loop
Four billing codes, four different consoles. Printing one message for all of them sends the on call engineer to the wrong place.

How to fix it

Read error.code in the classifier, not the status

The body is {"error": {"message": ..., "type": ..., "code": ...}}. Pull code out defensively — it is absent on some 429s and on every Anthropic error — and make the retry decision from it. A code you do not recognise should be treated as not retryable until somebody has read it, because the failure mode of guessing wrong in that direction is a slow page instead of an infinite loop.

Give each wall code its own remedy

They are not interchangeable. credit_balance_exhausted wants credits or auto-recharge. organization_spend_limit_exceeded and project_spend_limit_exceeded want a limit raised, and you set those yourself. organization_usage_limit_exceeded is OpenAI's own ceiling for your tier and wants a request to OpenAI. Printing "quota exceeded" for all four sends the on-call engineer to the wrong console.

Compare month-to-date spend against the tier ceiling

Admin key. GET /v1/organization/costs?start_time={month_start}&bucket_width=1d&limit=31, sum results[].amount.value. The monthly usage limits by tier are $100, $500, $1,000, $5,000 and $200,000. Approaching yours predicts organization_usage_limit_exceeded to the day, which is the only one of these you can forecast.

Look for the cliff, because there is no error log

GET /v1/organization/usage/completions?start_time={T-48h}&bucket_width=1h. A wall that has already been hit looks like num_model_requests going to zero mid-cycle and staying there. A bucket with requests but output_tokens of zero is a different finding — calls that failed before generation — and folding the two together is how you end up chasing the wrong outage.

Probe live for the headroom numbers

Rate-limit headroom exists only on response headers; there is no GET that returns it. GET /v1/models with the project key is the cheapest real call there is, and it hands back x-ratelimit-remaining-requests and friends. It does not consume inference quota, so it will usually answer 200 even while inference is walled off — treat it as proof the key still authenticates, not as proof the account can generate.

How to check it worked

Re-run after credits are added or the limit is raised. Spend should sit clear of the ceiling and the hourly buckets should show traffic again.

python3 openai_quota_wall_audit.py --tier 3
# month-to-date $412.80 against a $1,000.00 tier ceiling
# 48 hourly bucket(s) read, traffic flowing, 0 finding(s)

The full code

Three GETs and no writes at all. OPENAI_ADMIN_KEY has to be an organization admin key, because every /v1/organization/* endpoint rejects a project key outright; OPENAI_API_KEY is optional and only used for the live probe, and a Read Only project key is enough for it. The classifier is pure and is the part worth stealing: it is what belongs inside your retry wrapper, and the tests exercise it with no network at all.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 LLM API fixes, free and open source.
openai_quota_wall_audit.py
"""Tell an OpenAI billing wall apart from a real rate limit, before it stops you.

Read only. GET requests and nothing else: OPENAI_ADMIN_KEY is an organization
admin key (sk-admin-...) with read scopes, and OPENAI_API_KEY is an optional
project key set to Read Only, used only for a live probe. The repair is printed,
never performed, because this script holds credentials that can spend money on
inference.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_quota_wall_audit")

API = "https://api.openai.com/v1"

# 429 codes that describe money rather than traffic. None of them clears on
# retry, and each one has a different remedy in a different console.
WALL = {
    "insufficient_quota":
        "no usable balance. This is the older name for the same wall and is "
        "still what many accounts return; add credits or enable auto-recharge.",
    "credit_balance_exhausted":
        "prepaid credits are gone. Add credits or enable auto-recharge.",
    "organization_spend_limit_exceeded":
        "the monthly spend limit you set on the organization was reached. "
        "Raise it, or wait for the interval to reset.",
    "project_spend_limit_exceeded":
        "the spend limit set on this project was reached. Raise it on the "
        "project, not on the organization.",
    "organization_usage_limit_exceeded":
        "the ceiling OpenAI assigns your usage tier was reached. Nothing you "
        "own can raise this; request an increase from OpenAI.",
}

# 429 codes that really are traffic shaping and really do clear on their own.
THROTTLE = ("rate_limit_exceeded", "requests_limit_reached", "tokens_limit_reached")

# The monthly usage limit OpenAI assigns each tier, in dollars.
TIER_LIMIT = {1: 100.0, 2: 500.0, 3: 1000.0, 4: 5000.0, 5: 200000.0}


def error_fields(body):
    """Return (code, type, message) from either provider's error envelope.

    OpenAI nests the useful part under "error"; some proxies and most logged
    exception dumps hand back the inner object on its own. Everything comes
    back as a string, empty when absent, so a caller never has to guard three
    levels of dict access before it can make a decision.
    """
    if not isinstance(body, dict):
        return ("", "", "")
    err = body.get("error")
    if not isinstance(err, dict):
        err = body
    return (str(err.get("code") or ""),
            str(err.get("type") or ""),
            str(err.get("message") or ""))


def classify(status, body):
    """Decide whether an error may be retried. Pure, so the rule is testable
    offline and can be lifted straight into a retry wrapper.

    Returns (state, detail). Only "throttle" and "transient" are safe to retry.
    """
    code, etype, message = error_fields(body)
    low = message.lower()

    if status == 429:
        if code in WALL:
            return ("wall",
                    "%s: %s Retrying cannot clear this, and the SDK still "
                    "raises RateLimitError for it." % (code, WALL[code]))
        if code in THROTTLE:
            return ("throttle",
                    "%s: a real limit on how fast you may send. Back off and "
                    "honour Retry-After." % code)
        if not code:
            if etype == "rate_limit_error":
                return ("throttle",
                        "Anthropic 429 rate_limit_error. It carries no code "
                        "field, so match on type here rather than on code.")
            return ("unclassified-429",
                    "429 with no code and no recognised type. Retry once, then "
                    "fail loudly: an unbounded loop against a wall is worse "
                    "than a page.")
        return ("unclassified-429",
                "429 with unrecognised code %s. Treat as not retryable until "
                "somebody has read it." % code)

    if status == 400 and "credit balance" in low:
        return ("wall",
                "Anthropic reports an exhausted balance as a 400 "
                "invalid_request_error, not a 429. There is no code field to "
                "branch on, so the message is the only signal available; it is "
                "a fragile match and worth an alert of its own when it fires.")

    if status in (401, 403):
        return ("auth",
                "status %d: the key is wrong, revoked, or scoped away from "
                "this endpoint. Retrying will not mint a new one." % status)

    if status >= 500 or status == 408:
        return ("transient", "status %d: server side. Retry with backoff." % status)

    return ("other", "status %d, code %s" % (status, code or "none"))


def headroom(spent, limit):
    """Compare month-to-date spend against a tier ceiling. Pure.

    Returns (state, detail). A missing limit is reported as unknown rather than
    as safe, because the tier is not readable from the API and has to be told
    to the script.
    """
    if limit is None:
        return ("tier-unknown",
                "$%.2f spent this month. Pass --tier to compare it against the "
                "ceiling OpenAI assigns that tier; the API does not expose "
                "which tier you are on." % spent)
    if spent >= limit:
        return ("at-ceiling",
                "$%.2f of a $%.2f monthly ceiling. Inference is returning, or "
                "is about to return, 429 organization_usage_limit_exceeded."
                % (spent, limit))
    if spent >= limit * 0.8:
        return ("approaching",
                "$%.2f of a $%.2f monthly ceiling (%.0f%%). This is the one "
                "wall you can forecast to the day."
                % (spent, limit, spent / limit * 100))
    return ("clear", "$%.2f of a $%.2f monthly ceiling" % (spent, limit))


def stalled(buckets, now, quiet_hours=6.0):
    """Find a cliff in the aggregate usage buckets. Pure, clock passed in.

    Neither provider exposes a per-request log, so a wall that has already been
    hit is not visible as an error rate. It is visible as traffic that stops:
    the most recent bucket carrying num_model_requests, aged against now.

    A bucket with requests but no output tokens is a separate finding -- calls
    that failed before generation -- and is reported as such rather than folded
    into the cliff, because the two have completely different repairs.

    Returns (state, detail).
    """
    rows = []
    for b in buckets:
        start = b.get("start_time")
        reqs = 0
        out = 0
        for r in b.get("results", []) or []:
            reqs += int(r.get("num_model_requests") or 0)
            out += int(r.get("output_tokens") or 0)
        if isinstance(start, (int, float)):
            rows.append((float(start), reqs, out))
    rows.sort()

    if not rows:
        return ("no-data", "no usage buckets returned for this window")

    busy = [r for r in rows if r[1] > 0]
    if not busy:
        return ("no-data",
                "%d bucket(s), none with a single model request. Either nothing "
                "ran, or the wall predates the window." % len(rows))

    barren = [r for r in busy if r[2] == 0]
    if barren:
        return ("failing-before-generation",
                "%d bucket(s) with requests but zero output tokens. Those calls "
                "did not generate: they were rejected before the model ran. "
                "That is an error shape, not a spend shape." % len(barren))

    age = (now.timestamp() - busy[-1][0]) / 3600.0
    if age >= quiet_hours:
        return ("cliff",
                "last model request %.1f hour(s) ago and nothing since. Traffic "
                "stopping dead mid-cycle is what a billing wall looks like from "
                "the usage API, because there is no error log to read." % age)
    return ("flowing", "traffic in the last %.1f hour(s)" % age)


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from OpenAI: /v1/organization endpoints need an "
                         "organization admin key, not a project key"
                         % r.status_code)
    r.raise_for_status()
    return r.json()


def probe(key):
    """Make the cheapest real call there is and read what comes back.

    Rate-limit headroom is only ever attached to a response; there is no GET
    that returns it. GET /v1/models does not consume inference quota, so it
    usually answers 200 even while inference is walled off. Treat it as proof
    the key still authenticates, not as proof the account can generate.
    """
    r = requests.get(API + "/models",
                     headers={"Authorization": "Bearer " + key}, timeout=30)
    try:
        body = r.json()
    except ValueError:
        body = {}
    limits = {k: v for k, v in r.headers.items()
              if k.lower().startswith("x-ratelimit")}
    return r.status_code, body, limits


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--tier", type=int, default=0, choices=[0, 1, 2, 3, 4, 5],
                    help="usage tier, for the monthly ceiling comparison (0 = unknown)")
    ap.add_argument("--hours", type=int, default=48,
                    help="how far back to read hourly usage buckets")
    ap.add_argument("--quiet-hours", type=float, default=6.0,
                    help="hours without a model request before it counts as a cliff")
    args = ap.parse_args()

    admin = os.environ.get("OPENAI_ADMIN_KEY")
    if not admin:
        log.error("set OPENAI_ADMIN_KEY (an organization admin key with read "
                  "scopes; project keys are rejected by /v1/organization/*)")
        return 2

    now = dt.datetime.now(dt.timezone.utc)
    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + admin})

    month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
    costs = get(s, "/organization/costs", start_time=int(month_start.timestamp()),
                bucket_width="1d", limit=31)
    spent = 0.0
    for b in costs.get("data", []):
        for r in b.get("results", []) or []:
            spent += float((r.get("amount") or {}).get("value") or 0.0)

    bad = 0
    state, detail = headroom(spent, TIER_LIMIT.get(args.tier))
    if state in ("clear", "tier-unknown"):
        log.info("%-13s %s", state, detail)
    else:
        bad += 1
        log.warning("%-13s %s", state, detail)
        log.warning("  repair: add prepaid credits, raise the org or project "
                    "spend limit, or ask OpenAI for a higher approved usage "
                    "limit. Which one depends on the error code, not the status.")

    since = now - dt.timedelta(hours=args.hours)
    usage = get(s, "/organization/usage/completions",
                start_time=int(since.timestamp()), bucket_width="1h",
                limit=max(args.hours, 1))
    buckets = usage.get("data", [])
    state, detail = stalled(buckets, now, args.quiet_hours)
    if state == "flowing":
        log.info("%-13s %s", state, detail)
    else:
        bad += 1
        log.warning("%-13s %s", state, detail)

    key = os.environ.get("OPENAI_API_KEY")
    if key:
        status, body, limits = probe(key)
        pstate, pdetail = classify(status, body) if status >= 400 else ("ok", "200")
        if pstate == "ok":
            log.info("probe         GET /v1/models answered 200; headroom %s",
                     limits or "not reported on this response")
        else:
            bad += 1
            log.warning("probe         %s  %s", pstate, pdetail)
    else:
        log.info("probe         skipped: set OPENAI_API_KEY (Read Only) to read "
                 "rate-limit headers from a live response")

    log.info("%d bucket(s) read over %d hour(s), %d finding(s)",
             len(buckets), args.hours, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
openai-quota-wall-audit.mjs
/**
 * Tell an OpenAI billing wall apart from a real rate limit, before it stops you.
 *
 * Read only. GET requests and nothing else: OPENAI_ADMIN_KEY is an organization
 * admin key with read scopes, OPENAI_API_KEY is an optional Read Only project
 * key used for a live probe. The repair is printed, never performed.
 */
const API = 'https://api.openai.com/v1';

// 429 codes that describe money rather than traffic. None clears on retry.
export const WALL = {
  insufficient_quota:
    'no usable balance. This is the older name for the same wall and is still ' +
    'what many accounts return; add credits or enable auto-recharge.',
  credit_balance_exhausted:
    'prepaid credits are gone. Add credits or enable auto-recharge.',
  organization_spend_limit_exceeded:
    'the monthly spend limit you set on the organization was reached. Raise it, ' +
    'or wait for the interval to reset.',
  project_spend_limit_exceeded:
    'the spend limit set on this project was reached. Raise it on the project, ' +
    'not on the organization.',
  organization_usage_limit_exceeded:
    'the ceiling OpenAI assigns your usage tier was reached. Nothing you own ' +
    'can raise this; request an increase from OpenAI.',
};

const THROTTLE = ['rate_limit_exceeded', 'requests_limit_reached', 'tokens_limit_reached'];

export const TIER_LIMIT = { 1: 100, 2: 500, 3: 1000, 4: 5000, 5: 200000 };

/**
 * Return [code, type, message] from either provider's error envelope. Empty
 * strings when absent, so callers never guard three levels of property access.
 */
export function errorFields(body) {
  if (!body || typeof body !== 'object') return ['', '', ''];
  const err = (body.error && typeof body.error === 'object') ? body.error : body;
  return [String(err.code ?? ''), String(err.type ?? ''), String(err.message ?? '')];
}

/**
 * Decide whether an error may be retried. Pure, so it is testable offline and
 * can be lifted straight into a retry wrapper. Returns [state, detail]. Only
 * 'throttle' and 'transient' are safe to retry.
 */
export function classify(status, body) {
  const [code, etype, message] = errorFields(body);
  const low = message.toLowerCase();

  if (status === 429) {
    if (Object.hasOwn(WALL, code)) {
      return ['wall',
        `${code}: ${WALL[code]} Retrying cannot clear this, and the SDK still ` +
        'raises RateLimitError for it.'];
    }
    if (THROTTLE.includes(code)) {
      return ['throttle',
        `${code}: a real limit on how fast you may send. Back off and honour ` +
        'Retry-After.'];
    }
    if (!code) {
      if (etype === 'rate_limit_error') {
        return ['throttle',
          'Anthropic 429 rate_limit_error. It carries no code field, so match ' +
          'on type here rather than on code.'];
      }
      return ['unclassified-429',
        '429 with no code and no recognised type. Retry once, then fail loudly: ' +
        'an unbounded loop against a wall is worse than a page.'];
    }
    return ['unclassified-429',
      `429 with unrecognised code ${code}. Treat as not retryable until ` +
      'somebody has read it.'];
  }

  if (status === 400 && low.includes('credit balance')) {
    return ['wall',
      'Anthropic reports an exhausted balance as a 400 invalid_request_error, ' +
      'not a 429. There is no code field to branch on, so the message is the ' +
      'only signal available; it is a fragile match and worth an alert of its ' +
      'own when it fires.'];
  }

  if (status === 401 || status === 403) {
    return ['auth',
      `status ${status}: the key is wrong, revoked, or scoped away from this ` +
      'endpoint. Retrying will not mint a new one.'];
  }

  if (status >= 500 || status === 408) {
    return ['transient', `status ${status}: server side. Retry with backoff.`];
  }

  return ['other', `status ${status}, code ${code || 'none'}`];
}

/** Compare month-to-date spend against a tier ceiling. Pure. */
export function headroom(spent, limit) {
  if (limit === null || limit === undefined) {
    return ['tier-unknown',
      `$${spent.toFixed(2)} spent this month. Pass --tier to compare it against ` +
      'the ceiling OpenAI assigns that tier; the API does not expose which tier ' +
      'you are on.'];
  }
  if (spent >= limit) {
    return ['at-ceiling',
      `$${spent.toFixed(2)} of a $${limit.toFixed(2)} monthly ceiling. Inference ` +
      'is returning, or is about to return, 429 organization_usage_limit_exceeded.'];
  }
  if (spent >= limit * 0.8) {
    return ['approaching',
      `$${spent.toFixed(2)} of a $${limit.toFixed(2)} monthly ceiling ` +
      `(${((spent / limit) * 100).toFixed(0)}%). This is the one wall you can ` +
      'forecast to the day.'];
  }
  return ['clear', `$${spent.toFixed(2)} of a $${limit.toFixed(2)} monthly ceiling`];
}

/**
 * Find a cliff in the aggregate usage buckets. Pure, clock passed in.
 *
 * There is no per-request log on either API, so a wall that has already been hit
 * is not visible as an error rate. It is visible as traffic that stops.
 * Returns [state, detail].
 */
export function stalled(buckets, now, quietHours = 6) {
  const rows = [];
  for (const b of buckets) {
    let reqs = 0;
    let out = 0;
    for (const r of b.results ?? []) {
      reqs += Number(r.num_model_requests ?? 0);
      out += Number(r.output_tokens ?? 0);
    }
    if (typeof b.start_time === 'number') rows.push([b.start_time, reqs, out]);
  }
  rows.sort((a, b) => a[0] - b[0]);

  if (rows.length === 0) return ['no-data', 'no usage buckets returned for this window'];

  const busy = rows.filter((r) => r[1] > 0);
  if (busy.length === 0) {
    return ['no-data',
      `${rows.length} bucket(s), none with a single model request. Either ` +
      'nothing ran, or the wall predates the window.'];
  }

  const barren = busy.filter((r) => r[2] === 0);
  if (barren.length > 0) {
    return ['failing-before-generation',
      `${barren.length} bucket(s) with requests but zero output tokens. Those ` +
      'calls did not generate: they were rejected before the model ran. That is ' +
      'an error shape, not a spend shape.'];
  }

  const age = (now.getTime() / 1000 - busy[busy.length - 1][0]) / 3600;
  if (age >= quietHours) {
    return ['cliff',
      `last model request ${age.toFixed(1)} hour(s) ago and nothing since. ` +
      'Traffic stopping dead mid-cycle is what a billing wall looks like from ' +
      'the usage API, because there is no error log to read.'];
  }
  return ['flowing', `traffic in the last ${age.toFixed(1)} hour(s)`];
}

async function get(key, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from OpenAI: /v1/organization endpoints need ` +
                    'an organization admin key, not a project key');
  }
  if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
  return res.json();
}

async function main() {
  const admin = process.env.OPENAI_ADMIN_KEY;
  if (!admin) {
    console.error('set OPENAI_ADMIN_KEY (an organization admin key with read ' +
                  'scopes; project keys are rejected by /v1/organization/*)');
    process.exitCode = 2;
    return;
  }

  const argv = process.argv;
  const tier = Number(argv.includes('--tier') ? argv[argv.indexOf('--tier') + 1] : 0) || 0;
  const hours = Number(argv.includes('--hours') ? argv[argv.indexOf('--hours') + 1] : 48) || 48;

  const now = new Date();
  const monthStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) / 1000;

  const costs = await get(admin, '/organization/costs',
    { start_time: Math.floor(monthStart), bucket_width: '1d', limit: 31 });
  let spent = 0;
  for (const b of costs.data ?? []) {
    for (const r of b.results ?? []) spent += Number(r.amount?.value ?? 0);
  }

  let bad = 0;
  {
    const [state, detail] = headroom(spent, TIER_LIMIT[tier] ?? null);
    if (state === 'clear' || state === 'tier-unknown') {
      console.log(`${state.padEnd(13)} ${detail}`);
    } else {
      bad += 1;
      console.warn(`${state.padEnd(13)} ${detail}`);
      console.warn('  repair: add prepaid credits, raise the org or project spend ' +
                   'limit, or ask OpenAI for a higher approved usage limit. Which ' +
                   'one depends on the error code, not the status.');
    }
  }

  const since = Math.floor(now.getTime() / 1000 - hours * 3600);
  const usage = await get(admin, '/organization/usage/completions',
    { start_time: since, bucket_width: '1h', limit: Math.max(hours, 1) });
  const buckets = usage.data ?? [];
  {
    const [state, detail] = stalled(buckets, now);
    if (state === 'flowing') console.log(`${state.padEnd(13)} ${detail}`);
    else { bad += 1; console.warn(`${state.padEnd(13)} ${detail}`); }
  }

  const key = process.env.OPENAI_API_KEY;
  if (key) {
    const res = await fetch(`${API}/models`, { headers: { Authorization: `Bearer ${key}` } });
    if (res.ok) {
      console.log(`probe         GET /v1/models answered 200; headroom ` +
                  `${res.headers.get('x-ratelimit-remaining-requests') ?? 'not reported'}`);
    } else {
      const body = await res.json().catch(() => ({}));
      const [state, detail] = classify(res.status, body);
      bad += 1;
      console.warn(`probe         ${state}  ${detail}`);
    }
  } else {
    console.log('probe         skipped: set OPENAI_API_KEY (Read Only) to read ' +
                'rate-limit headers from a live response');
  }

  console.log(`${buckets.length} bucket(s) read over ${hours} hour(s), ${bad} finding(s)`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests that matter are the ones that pin the branch. insufficient_quota and rate_limit_exceeded arrive with the identical status and have to come back as different states, or the whole note is decorative. A 429 with a code nobody recognises has to be treated as not retryable, because that is the safe direction to be wrong in. And the cliff detector is exercised at a fixed clock, so the boundary between quiet and stalled is a number you can read rather than a function of when the suite ran.

test_openai_quota_wall_audit.py
import datetime as dt

from openai_quota_wall_audit import classify, error_fields, headroom, stalled

NOW = dt.datetime(2026, 8, 30, 12, 0, tzinfo=dt.timezone.utc)


def hours_ago(h):
    return int(NOW.timestamp() - h * 3600)


def bucket(h, requests=10, output=4000):
    return {"start_time": hours_ago(h),
            "results": [{"num_model_requests": requests, "input_tokens": 900,
                         "output_tokens": output}]}


def openai_error(code, status_message="You exceeded your current quota."):
    return {"error": {"message": status_message, "type": "insufficient_quota",
                      "code": code}}


def test_error_fields_reads_nested_and_bare_envelopes():
    assert error_fields(openai_error("insufficient_quota"))[0] == "insufficient_quota"
    assert error_fields({"code": "rate_limit_exceeded"})[0] == "rate_limit_exceeded"
    assert error_fields(None) == ("", "", "")
    assert error_fields({"error": "a string, not an object"})[0] == ""


def test_the_whole_point_two_429s_that_are_not_the_same_thing():
    wall, wall_detail = classify(429, openai_error("insufficient_quota"))
    throttle, _ = classify(429, openai_error("rate_limit_exceeded"))
    assert wall == "wall"
    assert throttle == "throttle"
    assert "RateLimitError" in wall_detail


def test_every_billing_code_is_a_wall_with_its_own_remedy():
    remedies = {}
    for code in ("credit_balance_exhausted", "organization_spend_limit_exceeded",
                 "project_spend_limit_exceeded", "organization_usage_limit_exceeded"):
        state, detail = classify(429, openai_error(code))
        assert state == "wall", code
        remedies[code] = detail
    # Four different consoles. Printing one message for all four sends the
    # on-call engineer to the wrong place.
    assert len(set(remedies.values())) == 4


def test_an_unrecognised_429_code_is_not_retried_blindly():
    state, detail = classify(429, openai_error("some_new_code_2027"))
    assert state == "unclassified-429"
    assert "not retryable" in detail


def test_a_429_with_no_code_at_all_is_still_not_a_free_retry_loop():
    assert classify(429, {"error": {"message": "Too many requests"}})[0] == "unclassified-429"


def test_anthropic_429_matches_on_type_because_it_has_no_code():
    state, _ = classify(429, {"type": "error",
                              "error": {"type": "rate_limit_error",
                                        "message": "Number of requests has exceeded"}})
    assert state == "throttle"


def test_anthropic_puts_the_same_wall_behind_a_400():
    state, detail = classify(400, {"error": {
        "type": "invalid_request_error",
        "message": "Your credit balance is too low to access the Claude API."}})
    assert state == "wall"
    assert "400" in detail


def test_auth_and_server_errors_are_not_confused_with_either():
    assert classify(401, {})[0] == "auth"
    assert classify(503, {})[0] == "transient"
    assert classify(404, {})[0] == "other"


def test_headroom_forecasts_the_one_wall_that_can_be_forecast():
    assert headroom(120.0, None)[0] == "tier-unknown"
    assert headroom(120.0, 1000.0)[0] == "clear"
    assert headroom(850.0, 1000.0)[0] == "approaching"
    assert headroom(1000.0, 1000.0)[0] == "at-ceiling"


def test_stalled_reads_a_cliff_against_the_clock_it_is_given():
    fresh = stalled([bucket(30), bucket(2)], NOW)
    assert fresh[0] == "flowing"
    state, detail = stalled([bucket(30), bucket(20)], NOW)
    assert state == "cliff"
    assert "20.0 hour(s) ago" in detail


def test_requests_with_no_output_is_a_different_finding_from_a_cliff():
    # A bucket that made calls and generated nothing is an error shape. Folding
    # it into the cliff sends you looking for a billing problem that is not there.
    state, _ = stalled([bucket(20, requests=40, output=0), bucket(1)], NOW)
    assert state == "failing-before-generation"


def test_empty_and_silent_windows_do_not_claim_a_wall():
    assert stalled([], NOW)[0] == "no-data"
    assert stalled([bucket(3, requests=0, output=0)], NOW)[0] == "no-data"
openai-quota-wall-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, errorFields, headroom, stalled } from './openai-quota-wall-audit.mjs';

const NOW = new Date('2026-08-30T12:00:00Z');
const hoursAgo = (h) => Math.floor(NOW.getTime() / 1000 - h * 3600);

const bucket = (h, requests = 10, output = 4000) => ({
  start_time: hoursAgo(h),
  results: [{ num_model_requests: requests, input_tokens: 900, output_tokens: output }],
});

const openaiError = (code) => ({
  error: { message: 'You exceeded your current quota.', type: 'insufficient_quota', code },
});

test('error fields reads nested and bare envelopes', () => {
  assert.equal(errorFields(openaiError('insufficient_quota'))[0], 'insufficient_quota');
  assert.equal(errorFields({ code: 'rate_limit_exceeded' })[0], 'rate_limit_exceeded');
  assert.deepEqual(errorFields(null), ['', '', '']);
  assert.equal(errorFields({ error: 'a string, not an object' })[0], '');
});

test('the whole point: two 429s that are not the same thing', () => {
  const [wall, wallDetail] = classify(429, openaiError('insufficient_quota'));
  const [throttle] = classify(429, openaiError('rate_limit_exceeded'));
  assert.equal(wall, 'wall');
  assert.equal(throttle, 'throttle');
  assert.match(wallDetail, /RateLimitError/);
});

test('every billing code is a wall with its own remedy', () => {
  const remedies = new Set();
  for (const code of ['credit_balance_exhausted', 'organization_spend_limit_exceeded',
    'project_spend_limit_exceeded', 'organization_usage_limit_exceeded']) {
    const [state, detail] = classify(429, openaiError(code));
    assert.equal(state, 'wall', code);
    remedies.add(detail);
  }
  assert.equal(remedies.size, 4);
});

test('an unrecognised 429 code is not retried blindly', () => {
  const [state, detail] = classify(429, openaiError('some_new_code_2027'));
  assert.equal(state, 'unclassified-429');
  assert.match(detail, /not retryable/);
});

test('a 429 with no code at all is still not a free retry loop', () => {
  assert.equal(classify(429, { error: { message: 'Too many requests' } })[0],
    'unclassified-429');
});

test('anthropic 429 matches on type because it has no code', () => {
  const [state] = classify(429, {
    type: 'error',
    error: { type: 'rate_limit_error', message: 'Number of requests has exceeded' },
  });
  assert.equal(state, 'throttle');
});

test('anthropic puts the same wall behind a 400', () => {
  const [state, detail] = classify(400, {
    error: {
      type: 'invalid_request_error',
      message: 'Your credit balance is too low to access the Claude API.',
    },
  });
  assert.equal(state, 'wall');
  assert.match(detail, /400/);
});

test('auth and server errors are not confused with either', () => {
  assert.equal(classify(401, {})[0], 'auth');
  assert.equal(classify(503, {})[0], 'transient');
  assert.equal(classify(404, {})[0], 'other');
});

test('headroom forecasts the one wall that can be forecast', () => {
  assert.equal(headroom(120, null)[0], 'tier-unknown');
  assert.equal(headroom(120, 1000)[0], 'clear');
  assert.equal(headroom(850, 1000)[0], 'approaching');
  assert.equal(headroom(1000, 1000)[0], 'at-ceiling');
});

test('stalled reads a cliff against the clock it is given', () => {
  assert.equal(stalled([bucket(30), bucket(2)], NOW)[0], 'flowing');
  const [state, detail] = stalled([bucket(30), bucket(20)], NOW);
  assert.equal(state, 'cliff');
  assert.match(detail, /20\.0 hour\(s\) ago/);
});

test('requests with no output is a different finding from a cliff', () => {
  const [state] = stalled([bucket(20, 40, 0), bucket(1)], NOW);
  assert.equal(state, 'failing-before-generation');
});

test('empty and silent windows do not claim a wall', () => {
  assert.equal(stalled([], NOW)[0], 'no-data');
  assert.equal(stalled([bucket(3, 0, 0)], NOW)[0], 'no-data');
});

FAQ

Is a 429 from OpenAI ever safe to retry?

Only when the code says so. A 429 with code rate_limit_exceeded, or with no code at all on a genuine throttle, clears on its own and deserves backoff. A 429 with insufficient_quota, credit_balance_exhausted, organization_spend_limit_exceeded, project_spend_limit_exceeded or organization_usage_limit_exceeded is a billing state, and no amount of waiting changes it.

Why does the SDK raise RateLimitError for a billing failure?

Because the exception class is chosen from the HTTP status, before anything inspects the body. 429 maps to RateLimitError in every official client. The code is still available on the exception object; nothing forces you to read it, and the obvious except-and-backoff never does.

What is the difference between insufficient_quota and credit_balance_exhausted?

They describe the same wall. insufficient_quota is the older name and is still what many accounts return; credit_balance_exhausted is the newer one. Match both, or a code rename ships an outage into your retry loop.

Can I find out how many requests got a 429 yesterday?

No. Neither OpenAI nor Anthropic exposes a per-request log through the API, so there is no error rate to query. What a read-only script can see is the aggregate usage buckets: traffic falling to zero mid-cycle, or a bucket with num_model_requests above zero and output_tokens at zero, which means calls that were rejected before generation.

Does this apply to Anthropic too?

The same failure, moved. Claude returns 429 with type rate_limit_error for a real throttle, and an exhausted balance comes back as a 400 invalid_request_error whose message mentions the credit balance. A cross-provider retry layer that only knows OpenAI's codes will retry that 400 or fail on the 429, so classify per provider rather than per status.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.