As an indie developer, I was adding error handling to a small backend job behind one of my apps one evening, and I wrote it on a hunch: when I hit my usage limit, the API returns a 429. So I watched for 429s and nothing else. If I set a monthly limit myself, I figured, the 429 would be how I'd find out.
Then I reread the rate limits page in the official docs, and the hunch turned out to be only half right. A limit you set yourself returns a 400, not a 429. And even among the 429s, some clear up if you wait, while others won't until the month rolls over.
What I'd like to put first is this: limit failures fall into three kinds, and only one of them is worth waiting for. Below is how I tell them apart, and the code I wrote to do it.
Three things get called "a limit"
Here are the look-alikes side by side, from what the official docs say, in my own words.
| Kind | Status | How to recognize it | Does waiting help? |
|---|---|---|---|
| Ordinary rate limit | 429 | Usually carries a retry-after header | Yes (wait that many seconds) |
| Tier monthly cap (Start / Build / Scale) | 429 | error.details.error_code is enforced_spend_limit_reached; no retry-after | No (until 00:00 UTC next month, or a tier increase) |
| A spend limit you set yourself | 400 | Error type invalid_request_error; message starts with You have reached your specified | No (until you raise or remove the limit) |
The docs list the monthly cap as $500 for Start, $1,000 for Build, and $200,000 for Scale. Custom arrangements have no monthly cap and are worked out with your account team.
The awkward part is that the tier cap and an ordinary rate limit both come back as rate_limit_error. If you branch on the type string alone, you can't tell those two apart.
Why watching for 429 isn't enough
The SDK's automatic retries cover connection errors, rate limits, and 5xx responses, with exponential backoff and two retries by default. If a retry-after header is present, it follows it. For an ordinary 429, that is exactly the right behavior.
For the tier-cap 429, though, there is no retry-after, and the docs say plainly that retrying, SDK retries included, keeps failing until access resumes. Waiting just buys you nothing.
The 400 from your own limit isn't retried at all. And because it says invalid_request_error even though nothing is wrong with the request, it's easy to lose an hour picking apart your prompt and parameters.
A note: the docs say limits on a Claude Code workspace are checked separately, and a request over that limit can get a 429 with a
retry-afterheader instead of a 400. If you run Claude Code in the same organization, keep that exception in mind so it doesn't muddy your logs.
A classifier using only the standard library
What it solves: one function call answers "is this error worth waiting for?" For errors unrelated to limits it returns None, and your existing handling takes over.
"""Triage Anthropic API limit errors (standard library only)."""
from __future__ import annotations
import json
from dataclasses import dataclass
@dataclass
class Verdict:
kind: str # tier_cap / own_limit / rate_limit
retry: bool # could waiting help?
note: str
def classify(status: int, headers: dict[str, str], body: str) -> Verdict | None:
"""Return None for errors that are not limit-related."""
try:
err = json.loads(body).get("error", {})
except (ValueError, AttributeError):
return None # e.g. an HTML error page from a gateway
message = err.get("message", "")
code = (err.get("details") or {}).get("error_code", "")
h = {k.lower(): v for k, v in headers.items()}
if status == 429 and code == "enforced_spend_limit_reached":
return Verdict("tier_cap", False, "Monthly tier cap. Blocked until 00:00 UTC next month or a tier increase")
if status == 400 and message.startswith("You have reached your specified"):
scope = "workspace" if "workspace" in message else "organization"
return Verdict("own_limit", False, f"Your own {scope} limit. Blocked until you raise or remove it")
if status == 429 and "retry-after" in h:
return Verdict("rate_limit", True, f"Retry after {h['retry-after']} seconds")
if status == 429:
return Verdict("rate_limit", True, "No retry-after. Back off exponentially and watch")
return NoneThree reasons it's written this way:
- It checks
error_codebefore anything else on a 429. The type string (rate_limit_error) is shared with ordinary rate limits, so it can't be the deciding signal. - It identifies the 400 by the start of the message.
invalid_request_erroris also used for genuinely malformed requests. The opening phrase the docs show, "You have reached your specified", is the one handle I trust to avoid false positives. - It returns
Nonewhen the body isn't JSON. A gateway's HTML error page shouldn't crash the triage function itself.
What I checked
I built eight mock responses shaped like the ones in the docs and ran them through the function. This was not a call to the real API; the bodies are hand-assembled to match the documented format.
| Input | Verdict | Wait? |
|---|---|---|
429 + enforced_spend_limit_reached | tier_cap | No |
| 400 + "specified API usage limits" | own_limit (organization) | No |
| 400 + "specified workspace API usage limits" | own_limit (workspace) | No |
429 + retry-after: 12 | rate_limit | 12 seconds |
| 429 (no header, ordinary body) | rate_limit | Back off |
400 (messages: field required) | not a limit (None) | n/a |
| 529 | not a limit (None) | n/a |
| 502 + HTML body | not a limit (None) | n/a |
All eight came out as expected. Still, the exact wording of real messages may change, so I treat the message text as a supporting clue and rely on error_code first and the status code second.
Wiring it to the SDK
In a real call, pull the status code, headers, and body out of the exception the SDK raises.
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
try:
msg = client.messages.create(
model="claude-sonnet-5",
max_tokens=256,
messages=[{"role": "user", "content": "Hello"}],
)
except anthropic.APIStatusError as e:
v = classify(e.status_code, dict(e.response.headers), e.response.text)
if v and not v.retry:
# Waiting won't help. Don't retry; tell a person and stop.
print(f"STOP: {v.kind} / {v.note} / request_id={e.response.headers.get('request-id')}")
raise SystemExit(1)
raise # everything else goes to your existing handlingThe request-id header is what support will ask for, so I log it at the moment I stop. It saves me time later.
The SDK will still run its default two retries on a tier-cap 429. I can't prevent that, but it ends after two, so all it wastes is a short wait. Decide whether an error is worth waiting for before you retry. That's the line I drew with this code.
What to do once you've hit one
Once you can tell them apart, the next step follows from the kind.
- Tier monthly cap (429): Check your tier on the Rate limits page in the Console and, if you need more, use "Request tier increase". The docs say moving to a higher tier restores access.
- Your own limit (400): Raise or remove it under Spend limits on the Billing page. Workspace limits can bite separately from the organization-wide one, and the default workspace can't have a spend limit set at all.
- Ordinary rate limit (429): Wait the
retry-afterseconds. Sharp jumps in usage can also trigger acceleration limits, which return 429 too, so the docs recommend ramping traffic up gradually.
I've written separately about measuring rate-limit headroom from the response headers, if that's the next thing you need.
- When Claude API Suddenly Starts Returning 429 in Production — Field Notes on Measuring Rate-Limit Headroom
- A 429 With retry-after: 120 Makes the Anthropic SDK Try to Wait 120 Seconds
One next step
Find one place in your own code that catches a 429. Add a one-line check of error_code there, and watch for the 400 message as well. That alone should spare you the end-of-month evening when something "just won't go through" and you can't tell why.
Decide whether an error is worth waiting for before you retry. I try to keep that rule even on the days I'm tired.