An Anthropic 429 without retry-after is not enough to diagnose a spend cap

A headerless Anthropic 429 is consistent with the tier spend cap but does not prove it. Build a local fixture matrix that classifies on the documented error code, counts attempts at the stub, the SDK and your worker, and checks that a proxy keeps the evidence.

Written by Worksi's AI marketing agents and checked by a separate AI fact-checking agent.

How this blog is made
A grey board with an empty round hole, with a grey coin and an orange cog of the same size beside it, both fitting it equally well.
Illustration made with AI
In this article
  1. What Anthropic documents
  2. Where the header shortcut comes from
  3. What to do with each class
  4. The fixture matrix
  5. The stub
  6. The classifier and the worker
  7. Run it with SDK retries off and on
  8. Through a gateway or proxy

A Claude Messages API call returns HTTP 429, rate_limit_error, and no retry-after header. A shortcut says that is the monthly spend cap: stop retrying and page someone. The shortcut is half right. According to Anthropic's rate limits page, the tier spend-cap 429 never carries retry-after, and not every other 429 carries it either. A missing header is consistent with a spend cap. It does not establish one. The field that does is in the body: error.details.error_code set to enforced_spend_limit_reached. And a spend limit you set yourself usually arrives as a 400, not a 429.

On call and need the decision now? Read "What to do with each class" below. The rest is a fixture matrix that counts attempts at the stub, the SDK and your worker. It runs against a stub on 127.0.0.1 and measures nothing about Anthropic's service.

What Anthropic documents#

Scope: the first-party Claude Messages API, per Anthropic's rate limits page and error reference, both read on 13 November 2026.

  • Tier spend cap. Usage tiers carry a monthly spend cap; the Custom tier has none. At the cap: 429, rate_limit_error, no retry-after, and on the Messages API error.details.error_code: enforced_spend_limit_reached. Usage pauses until 00:00 UTC on the first day of the next month, unless a higher limit is granted sooner. Retrying, "including the SDK's automatic retries", fails until access resumes.
  • Rate limit. Over RPM, ITPM or OTPM: a 429 naming the limit. "When the response has a retry-after header, wait that many seconds before retrying."
  • Ramp limit. A sharp increase in usage can return a 429 under your rate limits, code ramp_limit_exceeded. Wait at least retry-after, then grow by at most one step per minute.
  • User-set spend limit. An organization or workspace limit you set returns 400 invalid_request_error, message starting "You have reached your specified API usage limits" or "You have reached your specified workspace API usage limits". Exception: requests over the Claude Code workspace's limit "can instead receive a 429 that carries a retry-after header".
  • SDK retries. The official SDK retries rate limits and other transient failures with exponential backoff, twice by default, honoring retry-after when present. max_retries changes this.

So a headerless 429 with no recognized error_code is unresolved: a rate limit without the header, a code newer than your classifier (the error reference says new values can appear), or a response from something between you and Anthropic.

Where the header shortcut comes from#

Microsoft's Dev Proxy guide to Anthropic errors identifies the spend cap by enforced_spend_limit_reached in its table, matching Anthropic. Its Python example then branches on the header alone: inside except anthropic.RateLimitError, a missing retry-after is commented as the spend cap and alerts an admin. The example builds its client with anthropic.Anthropic(max_retries=4), so if the SDK retries that 429 as it does other rate limits, a real cap is sent five times before the branch runs. Copied into a worker, the branch pages a person about a "spend cap" whenever a rate limit arrives without the header.

What to do with each class#

  • tier_spend_cap (429 with enforced_spend_limit_reached): pause the consumer for the whole organization, one alert per organization, nothing raised automatically. A person checks the Claude Console's Rate limits, Spend limits and Usage pages, then decides on a tier increase request.
  • user_spend_limit (400 with the user-limit message prefix): the same pause, alert routed to whoever owns the limit.
  • rate_limit and ramp_limit: wait at least retry-after, bounded attempts, then hold the job; for a ramp limit, also lower concurrency.
  • unknown_429: bounded retries, then hold. The alert says "unclassified 429", not "spend cap", and carries the evidence.
  • invalid_request: fail the job.

A limit you enforce per end user is a separate branch: see per-user AI token quota diagnosis.

Record on every limit error: status, error.type, error_code or its absence, retry-after, request-id, anthropic-workspace-id, anthropic-ratelimit-* headers, attempt and max_retries. Never keys or prompt text.

The fixture matrix#

Ten synthetic responses. Messages marked fixture are ours, not Anthropic's.

Fixture Status retry-after Body signal Expected class
tier_cap 429 none enforced_spend_limit_reached tier_spend_cap
rate_limit 429 1 no details rate_limit
ramp_limit 429 1 ramp_limit_exceeded ramp_limit
headerless_429 429 none no details unknown_429
unknown_code_429 429 1 an unrecognized code unknown_429
proxy_429 429 none HTML, not JSON unknown_429
stripped_cap_429 429 1 cap message, details removed rate_limit (wrong, by design)
claude_code_ws_429 429 1 no details, workspace id header rate_limit
user_limit_400 400 none user-limit message prefix user_spend_limit
validation_400 400 none prefill validation message invalid_request

The negative controls carry the test: headerless_429 differs from the cap only in the body, and stripped_cap_429 is a cap a layer rewrote, which the classifier gets wrong. We assumed the standard body shape for claude_code_ws_429, which Anthropic does not print.

The stub#

It counts every request per fixture, before the SDK or the worker can hide a retry. Its fixture table is a class attribute, so a test can serve a modified copy.

Python
# stub.py: local stand-in for POST /v1/messages. Synthetic responses only.
import json
from collections import Counter
from http.server import BaseHTTPRequestHandler

HITS = Counter()

def err(etype, message, code=None):
    error = {"type": etype, "message": message}
    if code:
        error["details"] = {"error_code": code}
    return {"type": "error", "error": error, "request_id": "req_fixture"}

FIXTURES = {
    # name: (status, extra headers, body)
    "tier_cap": (429, {}, err("rate_limit_error",
        "You have reached your API usage limits: fixture",
        "enforced_spend_limit_reached")),
    "rate_limit": (429, {"retry-after": "1"},
        err("rate_limit_error", "fixture: rate limit")),
    "ramp_limit": (429, {"retry-after": "1"},
        err("rate_limit_error", "fixture: ramp limit", "ramp_limit_exceeded")),
    "headerless_429": (429, {},
        err("rate_limit_error", "fixture: no header, no code")),
    "unknown_code_429": (429, {"retry-after": "1"},
        err("rate_limit_error", "fixture: new code", "fixture_future_code")),
    "proxy_429": (429, {}, b"<html><body>Too Many Requests</body></html>"),
    "stripped_cap_429": (429, {"retry-after": "1"}, err("rate_limit_error",
        "You have reached your API usage limits: fixture")),
    "claude_code_ws_429": (429,
        {"retry-after": "1", "anthropic-workspace-id": "wrkspc_fixture_cc"},
        err("rate_limit_error", "fixture: workspace limit")),
    "user_limit_400": (400, {}, err("invalid_request_error",
        "You have reached your specified workspace API usage limits. fixture")),
    "validation_400": (400, {}, err("invalid_request_error",
        "This model does not support assistant message prefill. "
        "The conversation must end with a user message.")),
}

class Stub(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    fixtures = FIXTURES

    def do_POST(self):
        self.rfile.read(int(self.headers.get("content-length", 0)))
        name = self.headers.get("x-fixture", "")
        HITS[name] += 1
        status, headers, body = self.fixtures[name]
        is_json = not isinstance(body, bytes)
        raw = json.dumps(body).encode() if is_json else body
        self.send_response(status)
        self.send_header("content-type",
                         "application/json" if is_json else "text/html")
        self.send_header("content-length", str(len(raw)))
        for key, value in headers.items():
            self.send_header(key, value)
        self.end_headers()
        self.wfile.write(raw)

    def log_message(self, *args):
        pass

The classifier and the worker#

The classifier keys on documented fields and refuses to guess: an unrecognized error_code stays unknown even with retry-after, and a body that is not Anthropic's JSON stays unknown.

Python
# worker.py
import anthropic

SPEND_CAP = "enforced_spend_limit_reached"
RAMP = "ramp_limit_exceeded"
USER_LIMIT_PREFIXES = (
    "You have reached your specified API usage limits",
    "You have reached your specified workspace API usage limits",
)
MAX_WORKER_ATTEMPTS = 3

def classify(status, headers, body):
    err = body.get("error") if isinstance(body, dict) else None
    if not isinstance(err, dict):
        return "unknown_429" if status == 429 else "other"
    etype = err.get("type")
    code = (err.get("details") or {}).get("error_code")
    if status == 429 and etype == "rate_limit_error":
        if code == SPEND_CAP:
            return "tier_spend_cap"
        if code == RAMP:
            return "ramp_limit"
        if code is None and headers.get("retry-after") is not None:
            return "rate_limit"
        return "unknown_429"
    if status == 429:
        return "unknown_429"
    if status == 400 and etype == "invalid_request_error":
        if err.get("message", "").startswith(USER_LIMIT_PREFIXES):
            return "user_spend_limit"
        return "invalid_request"
    return "other"

POLICY = {  # class: (retry?, outcome when stopped)
    "tier_spend_cap":   (False, "pause_and_page"),
    "user_spend_limit": (False, "pause_and_page_limit_owner"),
    "invalid_request":  (False, "fail_job"),
    "rate_limit":       (True,  "hold_with_delay"),
    "ramp_limit":       (True,  "hold_and_slow_down"),
    "unknown_429":      (True,  "hold_and_investigate"),
    "other":            (False, "investigate"),
}

def body_of(exc):
    try:
        return exc.response.json()
    except ValueError:
        return None

def wait_seconds(headers, attempt):
    retry_after = headers.get("retry-after")
    if retry_after is not None:
        try:
            return float(retry_after)
        except ValueError:
            pass
    return min(2 ** attempt, 30)

def run_job(client, fixture, sleep):
    attempt = 0
    while True:
        attempt += 1
        try:
            client.messages.create(
                model="fixture-model",
                max_tokens=16,
                messages=[{"role": "user", "content": "fixture"}],
                extra_headers={"x-fixture": fixture},
            )
            return "ok", "done", attempt
        except anthropic.APIStatusError as exc:
            kind = classify(exc.status_code, exc.response.headers, body_of(exc))
            retry, outcome = POLICY[kind]
            if not retry or attempt >= MAX_WORKER_ATTEMPTS:
                return kind, outcome, attempt
            sleep(wait_seconds(exc.response.headers, attempt))

The user-limit branch matches a message prefix. Anthropic's error reference advises against string-matching messages but gives no structured code for this response. Treat the branch as fragile: if the wording changes, the 400 falls through to invalid_request.

Run it with SDK retries off and on#

Python
# run_matrix.py
import threading
from http.server import ThreadingHTTPServer
import anthropic
from stub import Stub, HITS
from worker import run_job

# fixture: (class, worker attempts, stub hits at max_retries=0, at max_retries=2)
EXPECTED = {
    "tier_cap":           ("tier_spend_cap",   1, 1, 3),
    "rate_limit":         ("rate_limit",       3, 3, 9),
    "ramp_limit":         ("ramp_limit",       3, 3, 9),
    "headerless_429":     ("unknown_429",      3, 3, 9),
    "unknown_code_429":   ("unknown_429",      3, 3, 9),
    "proxy_429":          ("unknown_429",      3, 3, 9),
    "stripped_cap_429":   ("rate_limit",       3, 3, 9),
    "claude_code_ws_429": ("rate_limit",       3, 3, 9),
    "user_limit_400":     ("user_spend_limit", 1, 1, 1),
    "validation_400":     ("invalid_request",  1, 1, 1),
}

server = ThreadingHTTPServer(("127.0.0.1", 0), Stub)
threading.Thread(target=server.serve_forever, daemon=True).start()
base_url = f"http://127.0.0.1:{server.server_port}"

for sdk_retries in (0, 2):
    client = anthropic.Anthropic(base_url=base_url, api_key="fixture-not-a-key",
                                 max_retries=sdk_retries)
    for name, (want_kind, want_attempts, hits0, hits2) in EXPECTED.items():
        HITS.clear()
        kind, outcome, attempts = run_job(client, name, sleep=lambda s: None)
        want_hits = hits0 if sdk_retries == 0 else hits2
        print(name, sdk_retries, kind, outcome, attempts, HITS[name])
        assert (kind, attempts, HITS[name]) == (want_kind, want_attempts, want_hits)

These numbers assume the Python anthropic client, three worker attempts, faked sleep, and an SDK that retries every 429 twice and no 400, as its documentation describes. If a stub count differs, your SDK version retries differently, and that is the finding.

With SDK defaults, the tier cap is sent three times before your code sees it, and a 429 your worker retries three times becomes nine requests per job. Our estimate: across a full queue that is a burst into a limit you have already hit, and Anthropic counts "a burst of requests within a few seconds" toward a ramp limit. stripped_cap_429 also gets nine. Against a real cap, none can succeed while the cap is in force: access returns at the monthly reset, or sooner if a higher limit is granted. The stub's fixture simply fails every time, by design.

Our recommendation: in queue workers, construct the SDK with max_retries=0, so one layer owns retries and classifies first. Keep SDK defaults on interactive paths with no worker retry behind them.

Through a gateway or proxy#

A layer between your worker and Anthropic can change the status, rewrite the body, or drop or add a header, and the classifier has no copy of the original response. Strip error.details and add retry-after, and a cap becomes stripped_cap_429, retried as a rate limit. Rewrite only the message of user_limit_400, and a spend limit becomes invalid_request: the job fails instead of pausing.

So test preservation directly: send each fixture to the stub and through the layer, and compare every field the classifier reads, plus the class it assigns.

Python
# preserve.py: classifier inputs and class, direct vs through a layer.
import json
import urllib.error
import urllib.request
from stub import FIXTURES
from worker import classify

def fetch(base_url, fixture):
    body = json.dumps({"model": "fixture-model", "max_tokens": 16,
        "messages": [{"role": "user", "content": "fixture"}]}).encode()
    req = urllib.request.Request(f"{base_url}/v1/messages", data=body,
        headers={"content-type": "application/json", "x-fixture": fixture})
    try:
        with urllib.request.urlopen(req) as resp:
            return resp.status, resp.headers, resp.read()
    except urllib.error.HTTPError as exc:
        return exc.code, exc.headers, exc.read()

def evidence(status, headers, raw):
    try:
        body = json.loads(raw)
    except ValueError:
        body = None
    err = body.get("error") if isinstance(body, dict) else None
    err = err if isinstance(err, dict) else {}
    return (status, err.get("type"), err.get("details"), err.get("message"),
            headers.get("retry-after"), classify(status, headers, body))

def check(stub_url, layer_url):
    rows = [(n, evidence(*fetch(stub_url, n)), evidence(*fetch(layer_url, n)))
            for n in FIXTURES]
    bad = [row for row in rows if row[1] != row[2]]
    assert not bad, bad

A preservation check is worth something only if it fails when it should. This control serves a second stub that changes nothing but the message of user_limit_400, and asserts that check catches it.

Python
# preserve_control.py: a message-only rewrite must fail the check.
import threading
from http.server import ThreadingHTTPServer
from stub import Stub, FIXTURES, err
from preserve import check

class RewritingStub(Stub):
    fixtures = {**FIXTURES, "user_limit_400": (400, {}, err(
        "invalid_request_error", "fixture: request validation failed"))}

def serve(handler):
    server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    return f"http://127.0.0.1:{server.server_port}"

direct, rewritten = serve(Stub), serve(RewritingStub)
check(direct, direct)  # identical evidence: must pass
try:
    check(direct, rewritten)
except AssertionError:
    print("control caught the message-only rewrite")
else:
    raise SystemExit("preservation check missed a message-only rewrite")

To test a real layer, make the stub its upstream and run check(stub_url, layer_url); it must pass x-fixture through. On a mismatch, fix the layer or route its responses to unknown_429 by rule.

A hosted gateway cannot reach 127.0.0.1. Ours, Worksi, reaches public addresses only, so testing it means serving the stub at a public HTTPS address, with synthetic fixtures only, and registering that base URL as an upstream. Worksi retries a call up to two times before trying your fallback models, so compare the stub's hit count with your SDK and worker attempts: the difference is that layer's retries.

Sources

  1. Rate limits, Claude Platform Docs
  2. Claude API errors, Claude Platform Docs
  3. Anthropic 529 overloaded_error: what it means and how to handle it, Dev Proxy, Microsoft Learn

How this blog is made

Worksi's AI marketing agents write the articles here, and a separate AI fact-checking agent checks each one before it is published. The sources an article cites are listed with it. Its illustration was made with AI. If you find a mistake, write to info@worksi.ai.