Study guides / CCDV-F / Domain 8

Eval, Testing & Debugging · Lesson 1 of 2

8.1 - Debugging Claude API Errors

Read an error response's status code and type correctly, apply the right retry logic for each, and stop guessing at fixes that don't match the actual cause.

Every non-2xx response from the Messages API returns the same JSON envelope: a top-level type: 'error', a nested error object with its own type (the precise category) and a human-readable message, plus a request_id you should log every time. For example: {type: 'error', error: {type: 'invalid_request_error', message: "messages: roles must alternate between 'user' and 'assistant'"}, request_id: 'req_011CSHoEeqs5C35K2UUqR7Fy'}. The HTTP status code narrows the category, error.type names it precisely, and the message almost always identifies the exact field or condition at fault. Debugging starts by reading all three together - not by pattern-matching the status code in isolation.

The Full Error Code Reference

Eight status codes cover essentially every failure mode you'll see:

request_id is your debugging anchor

Every response, success or failure, carries a request_id (in Python, response._request_id - public despite the underscore). Log it on every failure. It's the first thing Anthropic support asks for, and it's what lets you correlate a user-reported failure with a specific server-side event when the error message alone isn't enough to diagnose.

Retryable vs Non-Retryable - and What the SDK Already Does for You

The official SDKs auto-retry connection errors, 408, 409, 429, and any 5xx with exponential backoff and jitter, by default up to two retries (configurable via max_retries). When a 429 response includes a retry-after header, the SDK honors it instead of guessing a delay. This means a custom retry loop wrapped around the SDK is usually redundant work - and if you do build one, you must not also let the SDK retry underneath it, or a single failure produces a multiplicative retry storm.

The 4xx codes other than 429 - 400, 401, 403, 404, 413 - are deliberately excluded from that automatic retry. They're deterministic: resending an identical malformed request produces an identical failure every time. The only correct response to one of these is to change the request (fix the schema, fix the key, fix the model ID) before sending again, not to resend the same bytes and hope.

Typed Exceptions, Not String Matching

Every SDK maps each status code to its own exception class - in Python, BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), RateLimitError (429), and InternalServerError (5xx), all subclasses of the base APIStatusError, with APIConnectionError covering network failures before any response arrives. Catch from most specific to least specific: except anthropic.NotFoundError: ... except anthropic.RateLimitError: ... except anthropic.APIStatusError as e: ... except anthropic.APIConnectionError: .... Every APIStatusError subclass also exposes a .type property carrying the exact error.type string, useful when two different types share one status code (for example, both permission_error and billing_error can surface as 403).

Common exam distractor

An answer that catches one broad exception class - a single except APIStatusError or catch (AnthropicApiException) - and treats every failure identically is a trap. It discards exactly the distinction that matters: which failures are safe to retry (429, 5xx, connection errors) and which need the request itself fixed (400, 401, 403, 404, 413). Reject any answer that doesn't branch by status or exception type.

401 vs 403 - Authentication vs Authorization

These two get confused constantly. A 401 means the request never proved who it is - the key is missing, malformed, or revoked. A 403 means the request proved exactly who it is, and that identity is real, but it isn't allowed to do the specific thing it asked for - call a model it hasn't been granted, use a beta feature it lacks access to, or touch a resource outside its organization. Seeing a 403 on a model you called successfully five minutes ago (rather than a 401) tells you the key itself is fine; something about access to that specific resource changed.

429 vs 529 - Two Different Sources of 'Slow Down'

Both are retryable, and both mean 'try again later' - but the cause, and therefore the right response, differs. A 429 rate_limit_error means your account has exceeded its own requests-per-minute, tokens-per-minute, or tokens-per-day allowance; check the x-ratelimit-remaining-* and retry-after response headers, and the durable fix is pacing your own request rate or requesting a higher limit. A 529 overloaded_error (or a 500) means Anthropic's infrastructure is capacity-constrained or has a transient fault, entirely independent of your usage - there's no account-side fix, only backoff, and possibly routing to a different model if the workload allows it.

Debugging a 400 on a Tool-Calling Request

A 400 on a request that includes tools is very often a schema problem, not anything to do with the model's behavior: a required field missing from input_schema, an input_schema whose root isn't type: 'object', an unsupported JSON Schema keyword, or (if strict: true is set) a schema missing additionalProperties: false. Because the model never runs before a 400 is raised, nothing about the model's output is implicated at all - the fastest fix comes from reading the exact field the message names, not from re-prompting or guessing based on the status code alone.

Exam traps

Practice question

An application starts receiving 403 permission_error responses on a specific model it previously had no trouble calling. What does this specifically indicate, as distinct from a 401 or a 429?

  • A The request is malformed and needs its JSON fixed.

    That's the signature of a 400, not a 403 - a malformed request is a different failure category.

  • B The API key is missing or invalid entirely.

    A missing or invalid key produces a 401 authentication_error, not a 403 - the key here is valid, just lacking access to something specific.

  • C The key is valid but doesn't have access to the specific resource or model being requested. Correct

    This is precisely what distinguishes a 403 permission_error from a 401 - authentication succeeded, but authorization for this particular resource failed.

  • D The account has been rate-limited and should back off.

    Rate limiting produces a 429, not a 403 - these signal different underlying problems and call for different fixes.

Build exercise: Trigger, diagnose, and correctly handle five different error types

Beginner · 35 minutes

You'll practice:

  1. Deliberately trigger a 400 (send a request missing max_tokens or with non-alternating message roles) and a 401 (use an obviously fake API key), and print the full error envelope for each - status code, error.type, message, and request_id.

    Seeing the real response shape for each category, including request_id, is more durable than memorising a table of codes.

    You should see: Two distinctly different error.type values (invalid_request_error, authentication_error) with messages that clearly explain each specific problem, plus a logged request_id for each.

    Hints
    1. Never test invalid credentials against a key you actually use elsewhere - use an obviously fake string for the 401 case. What SDK call surfaces the error object's fields directly?
    2. Wrap each call in a try/except, catch the SDK's typed exception, and read e.status_code, e.type, e.message, and e.request_id (or the response envelope directly if you're using raw HTTP).
    3. import anthropic
      client = anthropic.Anthropic(api_key="sk-ant-obviously-fake")
      try:
          client.messages.create(model="claude-opus-5", max_tokens=100, messages=[{"role": "user", "content": "hi"}])
      except anthropic.AuthenticationError as e:
          print(e.status_code, e.type, e.message, e.request_id)
  2. Write a most-specific-first exception chain (NotFoundError, RateLimitError, generic APIStatusError, then APIConnectionError) around a real API call, and log a different message for each branch.

    The exam tests whether you branch on typed exceptions in the right order, not whether you can catch 'an error' - a single broad catch discards the retryable/non-retryable distinction.

    You should see: A try/except block with at least four ordered except clauses, each printing a distinguishable log line, with the most specific exception types listed before the general ones.

    Hints
    1. What happens if you put the base APIStatusError clause before RateLimitError in the chain? Does RateLimitError ever get reached?
    2. Order matters: since RateLimitError is a subclass of APIStatusError, it must be caught first or the more general clause swallows it silently.
    3. try:
          client.messages.create(model="claude-opus-5", max_tokens=100, messages=[{"role": "user", "content": "hi"}])
      except anthropic.NotFoundError as e:
          print("bad model/resource:", e.request_id)
      except anthropic.RateLimitError as e:
          print("back off, retry-after:", e.response.headers.get("retry-after"))
      except anthropic.APIStatusError as e:
          print("other status error:", e.status_code, e.type)
      except anthropic.APIConnectionError as e:
          print("network failure, no response received")
  3. Trigger a 404 by using a deliberately mistyped model ID, and confirm the SDK does NOT auto-retry it (measure elapsed time or add a print inside a retry hook).

    404, like 400/401/403/413, is excluded from the SDK's automatic retry logic because it's deterministic - retrying it wastes calls and can hide the real typo.

    You should see: A NotFoundError raised on the first attempt with no delay before it - confirming the SDK didn't spend time retrying a request that could never succeed unchanged.

    Hints
    1. Compare how long the call takes to fail versus how long a 429/5xx retry sequence would take. What should the difference tell you?
    2. Time the call with a simple before/after timestamp. A 404 should fail almost immediately; a retried 429/5xx would show multi-second delays from the exponential backoff.
    3. import time
      start = time.time()
      try:
          client.messages.create(model="claude-sonnet-4-99-typo", max_tokens=100, messages=[{"role": "user", "content": "hi"}])
      except anthropic.NotFoundError as e:
          print(f"failed in {time.time() - start:.2f}s -- no retry delay, as expected")
  4. Implement a custom exponential backoff wrapper (base delay, jitter, respecting retry-after when present) around a function, but only apply it to RateLimitError and 5xx errors - never to 400/401/403/404.

    This is the core exam distinction in code form: retryable transient failures get backoff, deterministic client failures get re-raised immediately so the caller can fix the actual problem.

    You should see: A function that retries up to N times with increasing delay on RateLimitError/InternalServerError, but immediately re-raises (does not retry) BadRequestError, AuthenticationError, PermissionDeniedError, or NotFoundError.

    Hints
    1. Which exceptions should hit 'raise' immediately inside your except block, and which should sleep and loop again?
    2. Catch the non-retryable types first and re-raise without delay. Catch RateLimitError and APIStatusError with status_code >= 500 last, and only those branches sleep before looping.
    3. import time, random
      def call_with_backoff(client, max_retries=4, base=1.0, **kwargs):
          for attempt in range(max_retries):
              try:
                  return client.messages.create(**kwargs)
              except (anthropic.BadRequestError, anthropic.AuthenticationError,
                      anthropic.PermissionDeniedError, anthropic.NotFoundError):
                  raise  # non-retryable -- fix the request, don't resend it
              except anthropic.RateLimitError as e:
                  retry_after = e.response.headers.get("retry-after")
                  delay = float(retry_after) if retry_after else base * (2 ** attempt) + random.uniform(0, 1)
                  time.sleep(delay)
              except anthropic.APIStatusError as e:
                  if e.status_code < 500:
                      raise
                  time.sleep(base * (2 ** attempt) + random.uniform(0, 1))
          raise RuntimeError("exhausted retries")
  5. Send a request with a deliberately invalid tool input_schema (e.g. a required field name that doesn't exist in properties) and confirm you can pinpoint the exact schema problem from the error message alone, without reading the model's output.

    Tool-calling 400s are one of the most common real-world debugging scenarios, and the exam specifically tests whether you know the model never ran, so nothing about model behaviour explains the failure.

    You should see: A BadRequestError whose message names the specific schema defect (an unknown key, a malformed type, or a required field with no matching property) rather than anything about the model's reasoning.

    Hints
    1. Where does the model appear in the request lifecycle relative to schema validation - before or after? What does that imply about who caused the 400?
    2. Schema validation happens before the model is invoked at all, so the message will point at the schema definition itself, not at any generated content.
    3. bad_tool = {
          "name": "get_weather",
          "description": "Get the weather",
          "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["location"]}
      }
      try:
          client.messages.create(model="claude-opus-5", max_tokens=100, tools=[bad_tool],
              messages=[{"role": "user", "content": "weather in Paris?"}])
      except anthropic.BadRequestError as e:
          print(e.message)  # names the schema problem directly

Sources