Retriable vs. Terminal: A Complete Two-Set Classification
CoreIdentify the type of a Claude application error · Difficulty 2/5
Explanation
One Question Decides Everything Downstream
Every failure a production Claude application hits starts with a single question: would waiting and retrying the exact same request plausibly succeed? If yes, the failure is retriable. If no, it's terminal. That's the entire test -- not the status code itself, but what the status code implies about whether time alone fixes the problem.
RETRIABLE = {429, 529, 500, 502, 503, 504} # rate limit, overload, transient server fault
TERMINAL = {400, 401, 403, 404} # bad request, auth, permissions, not found
def is_retriable(status):
return status in RETRIABLE # everything else fails fast- RETRIABLE -- the cause is transient and external to the request itself: momentary overload, a per-minute limit briefly exceeded, a dropped connection. Time resolves it, so a later identical attempt is likely to succeed.
- TERMINAL -- the cause lives inside the request: a malformed body, an expired key, a permission the caller doesn't have. Time changes nothing; the identical request will fail identically forever until something about the request itself changes.
Classifying a Status You Haven't Seen Before
The two sets above cover the common cases, but a new or unfamiliar status code needs the same underlying test applied fresh: would retrying plausibly help? If genuinely unsure, default to treating it as terminal. The asymmetry is deliberate -- a request wrongly classified as terminal fails loudly and gets noticed and fixed quickly. A request wrongly classified as retriable does something worse: it silently hammers a service that may already be struggling, burning retry budget and adding load without ever surfacing that anything is wrong. When in doubt, fail fast and loud rather than retry quietly and wrong.
The Case a Status-Code Classifier Will Miss Entirely: Refusal
One case falls completely outside this classification, and it's the one most likely to slip through unnoticed: a refusal. When Claude declines to complete a request on content-policy or safety grounds, that arrives as `stop_reason == "refusal"` -- riding on an ordinary 200 HTTP status. A classifier that only inspects the HTTP status code will see 200 and treat the response as an unremarkable success. It will not catch a refusal, because a refusal isn't a transport-layer event at all -- it's a content decision the model made, wrapped in a perfectly successful-looking response envelope.
# A refusal rides on an ordinary 200 -- the retriable/terminal
# classifier above never sees it, since it never inspects stop_reason.
if response.stop_reason == "refusal":
raise ApplicationRefusalError("Claude declined this request -- flag for manual review, do not retry.")A refusal must be checked for explicitly -- typically by raising an application-level exception the moment `stop_reason == "refusal"` is observed -- and it should be logged for review. It must never be silently retried: retrying an identical request the model just refused is not a transient-failure recovery, it's repeating the same content decision and expecting a different answer, and it also risks masking a genuine input problem that needs human review rather than automated persistence.
Retry-After: The Authoritative Wait Time
When a response is retriable via 429 or 529, it commonly arrives with a retry-after header telling you exactly how long the server wants you to wait before trying again. Treat that value as authoritative -- the service is telling you precisely when capacity is expected to return, which is more precise than any generic backoff schedule you could guess. Only fall back to exponential backoff (with jitter) when retry-after is absent from the response. Reading retry-after first, and treating your own backoff math as the fallback rather than the default, is what separates a retry loop that cooperates with the service from one that guesses at it.
Common exam traps
- Classifying by memorized status code alone instead of applying the underlying test ("would retrying plausibly help?") to an unfamiliar status -- the test is what generalizes, not the specific numbers in the two sets.
- Defaulting an unfamiliar error to retriable "to be safe." The safe default is terminal -- a loud failure gets fixed; a wrongly-retried one hammers a struggling service quietly.
- Assuming a 200 status always means a normal, retry-safe (or already-successful) response. `stop_reason == "refusal"` is a 200 that is neither -- it's a content decision that must be raised explicitly and never retried.
- Falling back to exponential backoff by default even when
retry-afteris present. The header is authoritative; backoff is the fallback for when it's missing, not a co-equal alternative.
Key Takeaways
- RETRIABLE = {429, 529, 500, 502, 503, 504}; TERMINAL = {400, 401, 403, 404} -- the underlying test is 'would waiting and retrying the exact same request plausibly succeed?'
- When unsure how to classify an unfamiliar error, default to terminal -- a loud failure gets fixed; a wrongly-retried one silently hammers a struggling service
- retry-after, present on 429/529 responses, gives the authoritative wait time -- use it first and fall back to exponential backoff only when it's absent
- A refusal (stop_reason == "refusal") arrives with an ordinary 200 status, so a status-code-only classifier will not catch it
- A refusal must be checked explicitly, raised as an application-level exception, logged for review, and never silently retried
Glossary Terms
The five recognizable buckets a Claude application failure falls into -- transport/HTTP (429/529/5xx), request error (400/401), parsing/validation (a code-level exception reading the response), model-output error (well-formed but wrong content), and tool-loop error (wrong tool, malformed arguments, or a mis-fed tool_result). The bucket a failure falls into dictates the correct fix, and misclassifying it sends the fix to the wrong layer.
A field in the Claude API response indicating why the model stopped generating. Values: 'end_turn' (natural completion), 'max_tokens' (hit limit), 'stop_sequence' (hit custom stop), 'tool_use' (wants to call a tool). The primary signal for controlling agentic loops.
The practice of returning structured, actionable error information from tools rather than generic error strings. Well-designed error responses include: error type, what went wrong, what Claude should try next. Prevents Claude from retrying the same failing approach repeatedly.
Related Concepts
Error-Type Taxonomy: Transport, Request, Parsing, Model-Output, Tool-Loop
Five recognizable failure buckets: transport/HTTP, request error, parsing/validation, model-output, and tool-loop
Matching Recovery Strategy to Failure Type
Retry with exponential backoff + jitter for transient 429/529/5xx failures