PrepGenAICerts

Retriable vs. Terminal: A Complete Two-Set Classification

Core

Identify the type of a Claude application error · Difficulty 2/5

0%
retriableterminalretry-afterrefusal

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-after is 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

Related Concepts

PrepGenAICerts.com is an independent third-party exam-prep platform for the Claude Certified Architect (CCA-F) certification. We are not affiliated with, endorsed by, or acting on behalf of Anthropic PBC.

Note: New premium upgrades are temporarily paused while we resolve an issue with our payment provider. Existing premium members retain full access.