Skip to content
Domain 4Task 4.1

Debugging and Error Handling

Learn this interactively

What You Need to Know

A Claude application can fail in two places. The integration layer is everything between your code and a valid response. It covers authentication, request construction, size and schema validity, rate limiting, transport, timeouts, stream assembly and your own parsing of the reply. Model output is what goes wrong after the API answers a well-formed request with HTTP 200: content that is wrong, incomplete, off-format or fabricated. An error type is the exact string a failed response names, such as rate_limit_error. A stop reason is the stop_reason field on a successful response, which says why generation ended. Debugging means finding the layer, then the class, then the recovery for that class.

This lesson uses one made-up example throughout: a helpdesk software company whose Claude service drafts replies to customer support tickets. It loads the current plan price list into the context and calls a price-lookup tool for each plan that a ticket names. In one week, some draft requests fail with an error and some drafts stop mid-sentence. One draft shows an Enterprise plan price that is in no price list.

Isolate the Layer Before You Theorise

The fixes for the two layers do not overlap. Rewriting a prompt cannot repair a bad credential, and a new credential cannot repair a fabricated figure. Treating one layer as the other is the most common way to waste time in debugging.

Use evidence to pick the layer. A 4xx or 5xx status with an error body that names an error.type comes from the integration layer. A 200 whose content you dislike comes from the prompt or the model. Anthropic's stop-reason page makes the same distinction. Stop reasons are inside a successful response body, and the content is valid. Errors are 4xx or 5xx request failures. The helpdesk company's failed requests carry a status and an error.type, so they belong to the integration layer.

Systems that retrieve documents into the context have one more question to answer before deciding which layer failed. Was the material assembled into the context window correct? A better prompt with the wrong documents only gives a more confident wrong answer, such as a reply at last year's prices.

Log the fields that tell failures apart, then theorise

Capture all six fields on successes as well as failures: request id, HTTP status, error.type, stop_reason, usage and raw content.

Error Types, and the Split That Picks the Recovery

The type strings are exact. As of August 2026, these are the documented status codes and their types.

  • 400 invalid_request_error, which the docs note "may also be used for other 4XX status codes not listed in this section"
  • 401 authentication_error
  • 402 billing_error
  • 403 permission_error
  • 404 not_found_error
  • 409 conflict_error
  • 413 request_too_large
  • 429 rate_limit_error
  • 500 api_error
  • 504 timeout_error
  • 529 overloaded_error

Every error body has the same shape: a top-level type of error, a nested error object that always includes a type and a message, and a request_id.

json
{
  "type": "error",
  "error": {
    "type": "not_found_error",
    "message": "The requested resource could not be found."
  },
  "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy"
}

Deterministic client faults are properties of the request that you sent: 400, 401, 402, 403, 404 and 413. The same request fails in the same way, however many times you send it. Transient faults are properties of the moment when you send the request. They are the retryable class: 429, 500, 504, 529 and raw connection failures. At the helpdesk company, one ticket with a very long email thread fails with a 413 on every attempt and at any hour. Other requests fail with a 429 only on Monday mornings, when the service sends all the weekend's tickets at once.

The SDKs retry a 409 conflict_error, but it is not transient. The docs prescribe "resolve the conflict, then retry the request". So you fix this fault, then retry. Backoff alone does not clear it.

The SDKs retry connection errors, 408, 409, 429 and 500 and above "with exponential backoff, twice by default, honoring the retry-after header when present". You change this behaviour with max_retries or maxRetries. The SDKs also retry two cases that a retry does not clear, which this lesson describes below. They are a 504 on the longest requests, and the 429 that a spend cap returns.

Branch on typed classes rather than error strings, most specific class first. RateLimitError is a subclass of APIStatusError, so a handler that catches the parent first hides the 429. The handler below shows the order.

python
import anthropic

client = anthropic.Anthropic()

def draft_reply(ticket_text: str):
    try:
        message = client.messages.create(
            model="claude-sonnet-5-5",
            max_tokens=1024,
            messages=[{"role": "user", "content": ticket_text}],
        )
    except anthropic.RateLimitError as exc:          # 429, most specific first
        headers = exc.response.headers
        print("429", headers.get("retry-after"),
              headers.get("anthropic-ratelimit-input-tokens-remaining"),
              headers.get("request-id"))
        raise
    except anthropic.APIStatusError as exc:          # every other non-2xx, 4xx and 5xx alike
        print(exc.status_code, exc.body, exc.response.headers.get("request-id"))
        raise
    except anthropic.APIConnectionError as exc:      # no status: it never reached the API
        print("transport failure", exc.__cause__)
        raise

    print(message._request_id, message.stop_reason, message.usage)
    return message

The documented Python and TypeScript classes all descend from APIError.

  • 400 BadRequestError
  • 401 AuthenticationError
  • 403 PermissionDeniedError
  • 404 NotFoundError
  • 409 ConflictError
  • 422 UnprocessableEntityError
  • 429 RateLimitError
  • 500 and above InternalServerError
  • APIConnectionError for transport failures

The table names no class for 402 or 413, so read those from status_code on the generic APIStatusError. Class names and namespaces differ by language, so the table is not universal. Java raises com.anthropic.errors.NotFoundException. Go returns a single *anthropic.Error, and you use its StatusCode to choose the branch.

A rate-limit 429 carries its own evidence. retry-after gives "the number of seconds to wait until you can retry the request. Earlier retries will fail". The anthropic-ratelimit-*-remaining headers show how much of each limit remains. Limits use a token bucket (capacity that refills continuously), so a short burst can exceed a limit that the per-minute average never approaches. That is the helpdesk company's Monday morning. A sharp increase in usage across the whole organisation produces 429s from acceleration limits. The fix is to increase traffic gradually.

Spend limits do not fit either side of the split. As of September 2026, an organisation that reaches the monthly spend cap of its usage tier gets a 429 rate_limit_error with no retry-after header. Its error.details.error_code reads enforced_spend_limit_reached. The docs say to use this field "to tell this response apart from a rate limit". Retrying "fails until access resumes", and this includes the automatic retries of the SDKs. Access resumes at 00:00 UTC on the first of the next month, or sooner on a higher tier. The status code says transient, but no retry clears it.

A spend limit that you set yourself, for the organisation or a workspace, returns a 400 invalid_request_error. Its message says the limit was reached and when access resumes. Limits on the Claude Code workspace can return a 429 instead. The 400 looks like a request fault, but the request is fine. Raise or remove the limit.

A 200 Is Not Proof of Success

A 200 says the request was valid. stop_reason then says whether you have a complete answer, a truncated one, a paused one, or a refusal. The docs list seven values.

end_turn means a natural finish. stop_sequence means that one of your stop sequences fired. tool_use means that you run the tools and return tool_result blocks. max_tokens means that the response was truncated at your cap, so raise the cap or narrow the task. If the truncation cut a tool_use block in half, retry with a higher cap.

pause_turn means the server-side sampling loop (the loop on the API side that runs server tools) reached its iteration limit. Web search is one such tool. The default limit is ten per request. A response that leaves a client tool_use block waiting for you never carries pause_turn. To continue, return the response to the API as it is, with the same tools. The reason is that a paused turn can end on a server tool that has not run yet. Limit the number of continuations, because a continued turn can pause again.

refusal means safety classifiers declined the request. It arrives "as a normal HTTP 200 response, not an error". Its stop_details names the category. To recover, use a different model, as a later section describes. model_context_window_exceeded means generation stopped at the model's context-window limit rather than at your cap. This stop reason lets you request the maximum possible tokens without knowing the exact input size. Treat the response as truncated. Raising max_tokens provably does not help.

The stop_reason field changes a vague symptom into a class. The helpdesk company's short drafts all returned 200, and their stop_reason was max_tokens. So "the drafts stop mid-sentence" is a configuration fault with a configuration fix. No rewrite of the prompt and no change of model fixes it.

Streaming hides the stop_reason field and adds a failure mode. stop_reason is null in message_start. message_delta supplies it, and no other event provides it. A client that never reads message_delta is unable to detect truncation. Over SSE (the streaming connection), "an error can occur after the API returns a 200 response. In that case, error handling doesn't follow these standard mechanisms". The error frame (an event in the stream) is event: error, and it carries an error such as overloaded_error. The API has already sent the 200 status when the frame arrives, so a handler around the call does not catch it. Treat the frame as an error and log it. The function below handles both the truncation and the error frame.

typescript
async function draftReply(client: Anthropic, ticketText: string) {
  const stream = await client.messages.create({
    model: "claude-sonnet-5-5",
    max_tokens: 1024,
    messages: [{ role: "user", content: ticketText }],
    stream: true,
  });

  let text = "";
  let stopReason: string | null = null;

  try {
    for await (const event of stream) {
      if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
        text += event.delta.text;
      } else if (event.type === "message_delta") {
        stopReason = event.delta.stop_reason;   // the only event that carries it
      }
      // Unknown event types, including ones added later, are ignored on purpose.
    }
  } catch (err) {
    // An error frame arriving long after the 200: log the partial as a failure
    // rather than handing it back as an answer.
    console.error("mid-stream failure", { chars: text.length, err });
    return { text, stopReason, complete: false };
  }

  return { text, stopReason, complete: stopReason === "end_turn" };
}

How you recover from a break in the middle of a stream depends on the Claude version. On Claude 4.5 and earlier, you put the captured partial output in an assistant message. On Claude 4.6 and later, you put it in a user message with an instruction to continue. Either way, "tool use and extended thinking blocks cannot be partially recovered. You can resume streaming from the most recent text block."

A handler around the call does not catch errors inside the response

A try/except around a streaming call catches connection and 4xx failures and nothing else. An overload can arrive as an error frame in the middle of the stream. A truncation can arrive as stop_reason max_tokens. A handler around the call does not catch either of them. Both are reported to the caller as a short success.

Tool Failures Are Their Own Class

A tool that raises an exception, returns nothing, or returns unstructured output makes the model improvise. An agent that then loops or fabricates has a tool-contract defect (a defect in what the tool returns), not a quality problem. At the helpdesk company, the price-lookup tool returned an empty string for the Enterprise plan, and the draft carried a price that Claude invented.

To recover, return a tool_result that carries tool_use_id, content, and is_error set to true "if the tool execution resulted in an error". The content must be useful: "Instead of generic errors like failed, include what went wrong and what Claude should try next (for example, Rate limit exceeded. Retry after 60 seconds.)". When Claude gets the specific message, it recovers. When it gets failed, it guesses. The company's tool should say that it found no Enterprise plan price, and what to try next. Server-tool errors are the exception. The API handles them itself, so you never set is_error for those.

The reply to a tool-result turn can also be empty. As of September 2026, the stop-reasons page documents replies of two or three tokens with no content and stop_reason end_turn. These replies occur mostly straight after tool results. A common cause is the way you construct your own messages. Text blocks that you add after the tool_result blocks are valid. But they teach Claude to expect the user to add text after every tool result, so it ends its turn. The fix is to send the tool results on their own. Re-sending the empty response unchanged does nothing, and a continuation prompt in a new user message is the last option to try.

Match the Recovery to the Class

Transient faults get capped exponential backoff with jitter, and the backoff honours retry-after. Jitter varies the waits at random. Without jitter, retries from a released backlog happen at the same time and re-create the burst that caused the 429. The helpdesk company's Monday queue would do the same.

python
import random
import time
import anthropic

client = anthropic.Anthropic(max_retries=0)   # own the schedule instead of the SDK default of 2

def spend_capped(exc: anthropic.APIStatusError) -> bool:
    body = exc.body if isinstance(exc.body, dict) else {}
    details = (body.get("error") or {}).get("details") or {}
    return details.get("error_code") == "enforced_spend_limit_reached"

def is_transient(exc: anthropic.APIError) -> bool:
    if isinstance(exc, anthropic.APIConnectionError):
        return True                          # timeouts included
    if not isinstance(exc, anthropic.APIStatusError):
        return False
    if exc.status_code == 429:
        return not spend_capped(exc)         # the spend-cap 429 fails until access resumes
    return exc.status_code >= 500

def with_backoff(call, attempts: int = 5, base: float = 1.0, cap: float = 30.0):
    for attempt in range(attempts):
        try:
            return call()
        except anthropic.APIError as exc:
            if not is_transient(exc) or attempt == attempts - 1:
                raise                        # deterministic or exhausted: fail loudly, never a silent stale answer
            response = getattr(exc, "response", None)
            after = response.headers.get("retry-after") if response is not None else None
            delay = float(after) if after else min(cap, base * 2 ** attempt)
            time.sleep(delay + random.uniform(0, delay * 0.25))

A 504 on the longest requests is an exception to backoff. As of September 2026 the errors page answers timeout_error with "Consider using the streaming Messages API for long-running requests". Its long-requests guidance warns against a large max_tokens without streaming or the Message Batches API. If the timeouts cluster on the longest generations rather than under load, the cause is request length. Every retry restarts the same long generation from the beginning. Stream the request when someone is waiting for it. Batch it when nobody is waiting for it.

Deterministic client faults need a fix, and then you send the failed request again. A 401 needs a credential. A 413 needs a smaller payload, or the Files or Batch endpoint with its larger maximum. A 400 needs the request corrected. Retrying any of them unchanged costs money and time to reproduce the same failure.

Schema-invalid output needs a new request with the specific validation errors attached. The errors tell Claude what to change. Never send an identical request. The structural fix is constrained decoding (generation that is forced to match the schema) through output_config.format with a json_schema, or strict: true on a tool definition. An unsupported schema feature returns "a 400 error with details". That 400 itself separates a schema defect from a model defect.

A refusal needs a different model. Re-sending the request to the same model "usually earns another refusal". As of September 2026 the docs give three ways to do this.

  • Server-side fallback through the fallbacks parameter (in beta on the Claude API)
  • The refusal-fallback middleware (code that runs around each request) that every Anthropic SDK includes
  • A retry you write yourself

A refusal can arrive before any output or in the middle of a stream. Either way the partial output is incomplete, so discard it. A refusal is an HTTP 200, so monitoring that uses error rates never sees it. Count refusals as their own signal.

Persistent quality failures need a change to the design rather than a retry. Truncation is a budget problem. Output in the wrong format is a schema-enforcement problem. Fabrication is a grounding problem (claims that are not based on source material). To fix fabrication, let Claude say that it does not know, and make every claim carry a supporting quote.

Two questions apply to every recovery. The first is idempotency: an operation that runs twice must have the same effect as one run. A retry that runs a side-effecting tool call again is a bug, for example when it emails a customer the same reply twice. The second is exhaustion, when every retry has failed. Return a reduced result that says clearly it is reduced, or fail visibly. If you serve the last run's output as though it were fresh, a visible outage becomes an invisible one.

Traces That Reproduce, and the Eval That Proves the Fix

A usable trace lets someone reproduce the failure without the original operator. It holds the request id, the exact request sent, the raw response, stop_reason and usage. It also holds every tool call with its inputs and results, and the retry history. The request must include the assembled context rather than your template, because the template does not show what Claude received. If you cannot reproduce a failure from the logs, the logging is the first defect to fix. Never drop the request id. It is the only identifier that your logs and Anthropic support share.

A multi-step run needs one rule. Find the first step where the trace diverges from what you expected, and diagnose that step. Later steps are usually consequences. Suppose the company's agent wrote the invented Enterprise plan price at step nine because the tool returned an empty string at step three. That is a tool-contract defect, and debugging step nine fixes the wrong thing, although the fix seems right.

Reproduce the failure. Change one variable at a time. Prove the fix against a fixed set of cases. Anthropic's eval guidance supports this method: "Design evals that mirror your real-world task distribution. Don't forget to factor in edge cases!" It also recommends automated grading wherever you can get it. For the helpdesk company, that set would include the oversized ticket and the Enterprise plan question.

In the exam

Domain 4 is titled Eval, Testing, and Debugging. Its scope comes from the one sub-skill underneath it, "Debugging and error handling techniques for Claude applications", which includes four parts.

  • "error type identification"
  • "recovery strategy selection"
  • "trace analysis to identify failure modes"
  • "problem origin isolation between the integration layer and model output"

Eval appears here only to support diagnosis. The practice questions here never test eval methodology as a topic of its own.

The type strings are exact, and the exam tests them exactly. In the practice diagnosis questions, the deciding detail is always in the question, and the question tests whether you read it. Every wrong answer in this sub-skill is a recovery aimed at a different class from the one in the question.

Sourcing Notes

The docs prescribe no fix for model_context_window_exceeded. Our own reading is that the remedy is to budget the context.

Dated current-state note (September 2026): output_format has moved to output_config.format, and beta headers are no longer required. The raw output_format parameter is deprecated. It works only with the structured-outputs-2025-11-13 beta header, and without that header the API returns a 400.

Making each failing input a permanent regression case is our engineering practice rather than a documented Anthropic rule. This practice connects eval, testing and debugging, the three parts of the domain's title.

Exam Traps

Exam Trap

Retrying a fault that fails identically every time, or giving up on one that would clear with backoff

authentication_error fails identically on every attempt until the credential changes. 429 and 529 normally clear with backoff, except the spend-cap 429 (enforced_spend_limit_reached, no retry-after). Read the status, type and error.details.error_code, then match the recovery.

Exam Trap

Reading a truncated answer as a model-quality problem and answering it with a sterner prompt or a larger model

model_context_window_exceeded means generation stopped at the context-window limit, so raising max_tokens does not help. For stop_reason max_tokens, the response reached the cap set in the request, so raise the budget or narrow the task, not prompt wording or model tier.

Exam Trap

Assuming a 200 means the call is safe, so a try/except around the call is complete error handling

A refusal is a 200 with stop_reason refusal, and a streaming error can arrive as an error event after the 200. Read stop_reason on the response and handle errors during stream iteration.

Exam Trap

Debugging the last step of a multi-step run, because that is where the visible symptom appeared

The failure is at the first divergence from expectation; later steps are consequences. An agent that fabricated or looped late in a run has usually been handed an unusable tool result early in it, which is a tool-contract defect rather than a model-quality one.

Practice Scenario

An expense management software company runs a Claude service that turns the overnight expense claims of each customer into a plain-language morning summary for the finance team of that customer. Three symptoms appear in one day: roughly one request in twenty fails with a rate-limit error, clustered when the overnight backlog is sent at 05:00; the claims of one customer fail immediately with the identical error on every attempt, at any hour; and the summaries for a third customer return successfully but stop mid-sentence at about the same length every run. The on-call engineer proposes a single fix for all three: retry every failure three times, and move the service to a larger model. Which assessment is correct?

Build Exercise

Build Exercise

Instrument One Call, Then Break It Four Ways

Difficulty
40 minutes

What you'll learn

  • How to capture the six fields that tell failures apart and make a failure reproducible
  • How to tell a deterministic request fault from a transient one using evidence rather than intuition
  • How to read stop_reason as a diagnostic on a successful response
  • How to match a recovery to an error class and prove the fix against a fixed set of cases
  1. Instrument one working Claude call in Python or TypeScript so it logs the request id, the HTTP status, stop_reason, usage and the raw content on every call, then break it with a revoked or malformed key and record what came back

    Why: Those fields are the evidence that tells failures apart, and every later step depends on them, and an auth fault is the simplest deterministic failure to test them with.

    You should see: 401 with error.type authentication_error, arriving as AuthenticationError, identical on every attempt, alongside a clean log line from the successful runs.

  2. Break it with a rate-limit burst by firing a tight loop of concurrent requests, capture the 429 with its retry-after and anthropic-ratelimit-*-remaining headers, then write capped exponential backoff with jitter that honours retry-after

    Why: This is the retryable class and the one recovery that needs real design decisions: the jitter is what stops the retries from a released backlog happening at the same time and creating the next burst.

    You should see: The backlog of requests completing at a steady rate rather than in new bursts, with waits that match retry-after wherever the header is present.

  3. Break it with truncation by setting max_tokens far below what the task needs, confirm the call still returns 200, and read stop_reason off the response

    Why: Watching a failure arrive as a success is the fastest way to stop the assumption that a 200 means the call is done.

    You should see: HTTP 200, stop_reason max_tokens, and output_tokens exactly equal to your cap.

  4. Break it with a schema-invalid response by asking for JSON your parser rejects, re-ask once with the specific validation errors attached, then re-run with output_config.format carrying a json_schema

    Why: Re-asking without the errors is guessing, and re-asking with the errors attached is diagnosing, and the constrained-decoding path shows what a structural fix looks like.

    You should see: A clear verdict on whether the schema, the prompt or your parser was at fault, plus a 400 with details if the schema uses an unsupported feature.

  5. For each of the four breaks, record the layer, the error class, the recovery you chose and why; then turn each failing input into a regression case, change one variable at a time, and prove all four fixes hold against the same fixed set

    Why: The four-row table covers the whole sub-skill, and running the fixed set afterwards is what shows that any of the fixes caused the change, rather than only seeming to.

    You should see: A four-row table plus a rerunnable case file that passes on all four inputs.

Sources

  • Claude API errors — Anthropic, the status-to-type map, the spend-limit 400 and spend-cap 429, the error body shape, request size limits, request ids, the warning about errors after a 200, and the long-requests guidance that points a 504 at streaming
  • Stop reasons and fallback — Anthropic, every stop_reason value, refusal as a normal 200, empty end_turn replies after tool results, and the stop-reasons-against-errors distinction
  • Refusals and fallback — Anthropic, server-side fallback, the SDK refusal-fallback middleware, discarding partial output and counting refusals as their own signal
  • Server tools — Anthropic, continuing a pause_turn with the same tools and capping the continuations
  • Streaming messages — Anthropic, the error event frame, unknown event types, and the version-split resume strategy
  • Rate limits — Anthropic, retry-after, the anthropic-ratelimit headers, the token bucket, acceleration limits, the tier spend cap and self-set spend limits
  • Python SDK — Anthropic, the typed exception table, the retried set, max_retries and the 10-minute timeout default
  • TypeScript SDK — Anthropic, the same exception hierarchy and maxRetries in the Node client
  • Handle tool calls — Anthropic, the tool_result contract, is_error, and instructive tool error messages
  • Structured outputs — Anthropic, output_config.format, strict tool use, and the 400 on unsupported schema features
  • Define success criteria and build evaluations — Anthropic, task-specific evals, automated grading and using a different model to grade
  • Reduce hallucinations — Anthropic, letting Claude say it does not know, quote extraction and citation verification
  • Claude Certified Developer Foundations Exam Guide — Domain 4, Debugging and Error Handling — Anthropic