◉CLAUDE LABJP
●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 38 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.302 — Fork subagents are now on by default in claude -p, the Agent SDK and the desktop app. Turn them off with CLAUDE_CODE_FORK_SUBAGENT=0 (10/16)●2.1.307 — Fixes MCP failing to reconnect after sleep or a keychain lock (10/21). 2.1.306 also fixed the false claude -p login message●SONNET 4.5 — Retires on the Claude API on 11/30, 38 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-10-22Beginner

You Hit Your Limit and Got a 400 — Telling Apart Your Own Spend Limit, the Tier Cap, and Rate Limits

Claude API limit errors come in three kinds, and they differ in status code and in whether waiting helps. A limit you set returns 400, the tier cap returns 429, and so do ordinary rate limits. Code and a decision table.

Claude API131error handling44296spend limittroubleshooting98

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.

KindStatusHow to recognize itDoes waiting help?
Ordinary rate limit429Usually carries a retry-after headerYes (wait that many seconds)
Tier monthly cap (Start / Build / Scale)429error.details.error_code is enforced_spend_limit_reached; no retry-afterNo (until 00:00 UTC next month, or a tier increase)
A spend limit you set yourself400Error type invalid_request_error; message starts with You have reached your specifiedNo (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-after header 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 None

Three reasons it's written this way:

  1. It checks error_code before 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.
  2. It identifies the 400 by the start of the message. invalid_request_error is 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.
  3. It returns None when 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.

InputVerdictWait?
429 + enforced_spend_limit_reachedtier_capNo
400 + "specified API usage limits"own_limit (organization)No
400 + "specified workspace API usage limits"own_limit (workspace)No
429 + retry-after: 12rate_limit12 seconds
429 (no header, ordinary body)rate_limitBack off
400 (messages: field required)not a limit (None)n/a
529not a limit (None)n/a
502 + HTML bodynot 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 handling

The 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-after seconds. 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.

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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⬡ API & SDK2026-10-16
Sending 40 Rows From Make to the Claude API Stops at Row 14 — Avoiding the 429 With One Subtraction
Why a no-code automation that sends spreadsheet rows to the Claude API one by one hits a 429 partway through, and how to fix it. Seven spacing strategies tested on a mock bucket, a formula for the Sleep delay, and settings that stop failed rows from vanishing.
⬡ API & SDK2026-09-02
The 400 that rejects a thinking block, and finding the edit that caused it
On Claude Fable 5.1, editing the system prompt, tools, or earlier messages before a thinking block makes the request fail with a 400. Here is how to check whether your own agent loop does that in a single request, and how to move a rebuilt system prompt and client-side trimming to patterns that leave the prefix untouched.
⬡ API & SDK2026-10-21
When claude -p Seems to Ignore Its Task, Check Argument Order Before Blaming the Model
A task passed to claude -p is ignored and the agent starts exploring the repo. One GitHub report says swapping -p and --append-system-prompt changed that. Here is a sentinel script that tests whether your instructions arrived, plus calling patterns that don't depend on order.