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
In this article
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, noretry-after, and on the Messages APIerror.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 leastretry-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-afterwhen present.max_retrieschanges 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 withenforced_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_limitandramp_limit: wait at leastretry-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.
# 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.
# 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#
# 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.
# 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.
# 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
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.