Streaming, Errors & Resilience

The refusal Stop Reason: HTTP 200 With Content You Cannot Use and a Bill You Still Owe

Your try/except caught nothing, the status code was 200, tokens were billed — and the response is Claude declining to continue. If your pipeline equates "no exception" with "usable output," refusals will flow straight into production data.

Claude 3P 101 · Updated July 2026 · Unofficial guide

Error handling around the Claude API usually focuses on things that arrive as HTTP failures: 400s for malformed requests, 429s for rate limits, 5xx for server trouble. A refusal is none of these. When Claude declines to complete a request — because the task runs into its safety boundaries, or on some models because the prompt tries to extract internal reasoning — the API call itself has succeeded. Anthropic's structured-outputs documentation states it plainly: refusals return HTTP 200 with stop_reason: "refusal", you are billed for the tokens produced, and (when you requested structured output) what was produced may not match your schema. The failure lives in the response body, not the transport.

Why the API is designed this way

A 4xx code means "this request was invalid — fix it and resend." A refusal is different in kind: the request was well-formed, processing began, and the model itself decided partway through that it should not continue. Since generation genuinely ran (and can run for a while before the decision point), the compute happened, which is why the tokens are billed. The stop_reason field is the API's channel for "the request worked; here is why generation ended" — and refusal is one of its values, alongside ordinary endings like reaching a natural stop or hitting max_tokens.

Detecting it is a body check, not a status check

The consequence for your code: every 200 response needs its stop_reason inspected before the content is trusted. Exception handlers never fire for refusals, because there is no exception.

from anthropic import Anthropic

client = Anthropic()
msg = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=2048,
    messages=[{"role": "user", "content": prompt}],
)
if msg.stop_reason == "refusal":
    log_refusal(request_id=msg.id)   # billed, not usable
    handle_refusal(prompt)           # don't parse msg.content as normal output

In streaming responses the same signal arrives through the event flow rather than a single object — top-level message changes such as the stop reason are delivered in message_delta events near the end of the stream. That has an awkward implication for user-facing streaming: you may have already rendered partial text before learning the generation ended as a refusal, so the UI needs a path for retracting or reframing what was shown.

What to do with a refusal

Don't blind-retry. A refusal is a decision about the content of the request, not a transient fault; resending the identical prompt mostly re-buys the same outcome at the same token price. Retry loops should treat stop_reason: "refusal" as non-retryable by default, the way they treat a 400.

Route it as a distinct outcome. Batch pipelines and agent loops should count refusals separately from errors and successes — they matter for both cost reconciliation (billed tokens with no usable output) and prompt quality review. If refusals cluster around a particular template or data slice, that's a prompt-design signal worth investigating.

Know the model-specific variants. On Claude Fable 5, attempts to elicit the model's internal reasoning in response text can be refused with an accompanying stop_details.category of "reasoning_extraction" — useful for distinguishing "user asked for something disallowed" from "our own prompt accidentally asks the model to reveal its thinking."

Guard downstream parsers. If you rely on structured outputs, treat schema validation as mandatory even though the feature normally guarantees valid JSON — the refusal case is a documented exception where output may not conform. The same body-not-status discipline applies to stop_reason: "max_tokens", refusal's truncation cousin, covered in the max_tokens trap.

Rule of thumb: your success predicate for a Claude call should be roughly status 200 AND stop_reason is an expected value AND content parses. Any weaker check will eventually ship a refusal to a customer or into a dataset.

Where to go next

For designing prompts and flows that reduce refusal rates, see refusal handling. The status-code half of the taxonomy is in API error codes, and the streaming delivery details in streaming event types.

Sources