Handle Stop Reasons
What a Stop Reason Is
Every successful Messages API response has a stop_reason field that explains why Claude stopped generating:
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
print(message.stop_reason) # end_turn
A stop reason is not an error. The request succeeded (HTTP 200), and the response contains valid content. The stop reason tells you what that content represents: a finished answer, a cut-off answer, a request to run a tool, a paused operation, or a refusal.
Why it matters: if your code always treats message.content as a finished answer, it will show truncated text as complete, ignore tool requests, and present refusals as normal replies. Checking stop_reason is what turns "it usually works" into reliable code.
All Stop Reasons at a Glance
stop_reason | When it occurs | What your code should do |
|---|---|---|
end_turn | Claude finished its response naturally | Use the response |
max_tokens | The response reached your max_tokens limit | Raise the limit, continue the response, or mark it as truncated |
stop_sequence | Claude produced one of your custom stop_sequences | Read stop_sequence to see which one matched |
tool_use | Claude wants your application to run a tool | Run the tool and send the result back |
pause_turn | A server-side tool loop (such as web search) reached its iteration limit | Send the response back unchanged to let Claude continue |
refusal | Claude declined to respond | Read stop_details, inform the user, or retry on a fallback model |
model_context_window_exceeded | The response filled the model's context window | Treat the response as truncated |
The rest of this lesson explains each one with code.
Stop Reasons vs. Errors
These two concepts are easy to confuse, but they are handled in different places:
| Stop reasons | Errors | |
|---|---|---|
| HTTP status | 200 (success) | 4xx or 5xx |
| Where you see it | message.stop_reason | A raised exception, such as anthropic.RateLimitError |
| Response content | Valid (possibly partial) | No message; an error description |
| Handled with | if / match on the response | try / except |
import anthropic
try:
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}],
)
except anthropic.APIStatusError as error:
print(f"Request failed with HTTP {error.status_code}") # An error
else:
print(f"Request succeeded; stop_reason={message.stop_reason}") # A stop reason
end_turn: A Complete Answer
The most common case. Claude finished what it had to say.
if message.stop_reason == "end_turn":
answer = "".join(block.text for block in message.content if block.type == "text")
print(answer)
The Rare Empty end_turn
Occasionally, a response has stop_reason == "end_turn" but no content. According to the documentation, this typically happens when Claude decides the turn is already complete. The most common causes are:
- adding text blocks immediately after tool results in a user message, which teaches Claude a pattern of ending its turn, and
- sending Claude's already-completed response back without adding anything new.
Retrying the identical request does not help, because Claude already decided it was done. If a fix to the message structure is not enough, add a new user message, such as "Please continue", and send again.
max_tokens: The Answer Was Cut Off
The response reached your max_tokens limit (Lesson 13). The text is incomplete, or missing entirely if thinking used the whole budget.
You have three options.
Option 1: Mark It as Truncated
def text_with_notice(message) -> str:
text = "".join(block.text for block in message.content if block.type == "text")
if message.stop_reason == "max_tokens":
return f"{text}\n\n[Response truncated due to max_tokens limit]"
return text
Option 2: Retry with a Higher Limit
This is the simplest fix when the answer fits within the model's maximum. Lesson 13 showed a complete retry function.
Option 3: Continue from Where It Stopped
You can ask Claude to continue. Send the original prompt, the partial answer as an assistant turn, and a new user message asking it to continue:
def get_complete_response(client, prompt: str, max_attempts: int = 3) -> str:
messages = [{"role": "user", "content": prompt}]
full_text = ""
for _ in range(max_attempts):
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=4096,
messages=messages,
)
full_text += "".join(b.text for b in response.content if b.type == "text")
if response.stop_reason != "max_tokens":
break
messages = [
{"role": "user", "content": prompt},
{"role": "assistant", "content": full_text},
{"role": "user", "content": "Please continue from where you left off."},
]
return full_text
How it works:
- Each response's text is appended to
full_text. - If the response stopped naturally, the loop ends.
- Otherwise, a new conversation is built: the original prompt, everything written so far (as an assistant message), and an instruction to continue. The request ends with a user message, as current models require (Lesson 18).
max_attemptsprevents an endless loop for tasks that are genuinely too large.
Note: The joined text may have an awkward seam where one part ends and the next begins. For long outputs, raising
max_tokenswith streaming (Lessons 51–60) usually gives a cleaner result than continuation.
stop_sequence: A Custom Stop Point Was Reached
When you pass stop_sequences, Claude stops as soon as it produces one of them. The matched sequence is reported in message.stop_sequence, and it is not included in the text:
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
stop_sequences=["END", "STOP"],
messages=[{"role": "user", "content": "Write one sentence about Python, then write END."}],
)
if message.stop_reason == "stop_sequence":
print(f"Stopped at: {message.stop_sequence}") # Stopped at: END
This is treated as a normal, intentional ending. Lesson 48 covers stop sequences in detail.
tool_use: Claude Wants to Run a Tool
When you give Claude tools, it can pause its answer to request one. The response contains one or more tool_use blocks, and your code must run the tools and send back the results:
if message.stop_reason == "tool_use":
for block in message.content:
if block.type == "tool_use":
print(f"Run {block.name} with {block.input} (id={block.id})")
This is not a final answer. It is the middle of a workflow. Lessons 81–90 cover the full loop, and the SDK's tool runner (Lesson 92) can handle it automatically.
pause_turn: A Server-Side Operation Paused
Some tools run on Anthropic's servers, such as web search and code execution. If the server-side loop reaches its iteration limit (10 iterations per request by default), the response ends with pause_turn. To let Claude finish, send the response back unchanged as an assistant message:
if message.stop_reason == "pause_turn":
messages.append({"role": "assistant", "content": message.content})
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=4096,
messages=messages,
tools=tools, # The same tools as before
)
You will only see pause_turn when using server tools (Lesson 155).
refusal: Claude Declined
Current Claude models include safety classifiers that can decline a request. A refusal is a successful response (HTTP 200), not an error. That is why monitoring based only on error rates will not notice refusals.
On a refusal, the stop_details object explains what happened:
{
"content": [],
"stop_reason": "refusal",
"stop_details": {
"type": "refusal",
"category": "cyber",
"explanation": "This request was declined because it could enable cyber harm."
}
}
| Field | Meaning |
|---|---|
category | The policy area that triggered the classifier |
explanation | A human-readable description. The text is not stable, so display it but never parse it. |
Both fields can be null when the refusal does not map to a named category. That is a normal value, not an error. For every other stop reason, stop_details itself is null.
Handling a Refusal
if message.stop_reason == "refusal":
details = message.stop_details
category = getattr(details, "category", None) or "unspecified"
print(f"Claude declined this request (category: {category}).")
getattr(details, "category", None) safely reads the category even if details or the field is missing. or "unspecified" replaces a None value with readable text.
Retrying on a Fallback Model
According to the documentation, a request refused by Claude Fable 5.1, Opus 5.5, Sonnet 5.5, or Haiku 5.5 can usually be served by retrying on another Claude model. There are two ways:
- Server-side fallback (beta): the request lists fallback models, and the API retries automatically. Claude Haiku 5.5 does not support it.
- Client-side fallback: your code sends the same request again with a different model.
A simple client-side version:
PRIMARY_MODEL = "claude-opus-5-5"
FALLBACK_MODEL = "claude-opus-4-8" # Check the "Refusals and fallback" docs for current recommendations
def create_with_fallback(client, **request):
response = client.messages.create(model=PRIMARY_MODEL, **request)
if response.stop_reason == "refusal":
response = client.messages.create(model=FALLBACK_MODEL, **request)
return response
Even after a fallback, the response can still be a refusal, so check stop_reason again.
Note: The official documentation also describes fallback credit, which avoids paying prompt-cache costs twice when you retry, and per-category recommended fallback models. See the "Refusals and fallback" and "Fallback credit" pages before building fallback logic for production.
Refusals in Conversations
A refusal can affect the rest of a conversation. The streaming-refusals guide recommends resetting the conversation context after a refusal rather than continuing to build on it. Lesson 25 shows how.
model_context_window_exceeded: The Context Window Is Full
The response filled the model's context window before reaching max_tokens. The content is valid but truncated, so handle it like max_tokens.
if message.stop_reason in {"max_tokens", "model_context_window_exceeded"}:
print("[Response truncated]")
Note: At the time of writing, the SDK types this value only in its beta namespace (
client.beta.messages). The non-beta response still carries the value at runtime, so comparing strings works, but a strict type checker may warn that the value is not one of the expected literals.
Practical Example: A Complete Response Handler
This handler converts any response into a clear result with a status, text, and an optional notice. It is the kind of function you would place in a shared service module.
"""response_handler.py: turn any Messages API response into a clear result."""
from dataclasses import dataclass
from anthropic import Anthropic
from anthropic.types import Message
@dataclass
class Result:
status: str # "ok", "truncated", "refused", "tool_call", "paused", "empty"
text: str
notice: str = ""
def get_text(message: Message) -> str:
return "".join(block.text for block in message.content if block.type == "text")
def handle_response(message: Message) -> Result:
text = get_text(message).strip()
match message.stop_reason:
case "end_turn" | "stop_sequence":
if not text:
return Result("empty", "", "Claude returned no text. Try adding a follow-up message.")
return Result("ok", text)
case "max_tokens" | "model_context_window_exceeded":
return Result("truncated", text, f"Response truncated ({message.stop_reason}).")
case "refusal":
category = getattr(message.stop_details, "category", None) or "unspecified"
return Result("refused", "", f"Claude declined this request (category: {category}).")
case "tool_use":
tools = [block.name for block in message.content if block.type == "tool_use"]
return Result("tool_call", text, f"Claude requested tools: {', '.join(tools)}")
case "pause_turn":
return Result("paused", text, "A server-side operation paused; send the response back to continue.")
case _:
return Result("unknown", text, f"Unhandled stop reason: {message.stop_reason}")
if __name__ == "__main__":
client = Anthropic()
prompts = [
("Name three Python data types.", 1024),
("Write a detailed history of the Python language.", 60),
]
for prompt, limit in prompts:
response = client.messages.create(
model="claude-opus-5-5",
max_tokens=limit,
messages=[{"role": "user", "content": prompt}],
)
result = handle_response(response)
print(f"[{result.status}] {result.text[:80] or '(no text)'}")
if result.notice:
print(f" {result.notice}")
Example output:
[ok] Three common Python data types are int, str, and list.
[truncated] Python was created by Guido van Rossum and first released in 1991. It b
Response truncated (max_tokens).
How It Works
Resultis a small dataclass. Callers check one simple field,status, instead of knowing every stop reason. Each status maps to a clear user-facing behavior.matchwith|groups stop reasons that are handled the same way:end_turnandstop_sequenceare both normal endings, and the two truncation reasons are both incomplete.- The empty check inside the normal case catches the rare empty
end_turnresponse. - Refusals return no text, even if a fragment exists, so a refusal is never shown as an answer.
case _handles any future stop reason safely instead of crashing.
Common Mistakes
Never Checking stop_reason
What happens: truncated answers, tool requests, and refusals are all treated as normal answers.
Fix: check stop_reason for every response your application depends on, or route all responses through one handler like handle_response().
Treating Refusals as Errors (or Ignoring Them)
What happens: refusals do not raise exceptions, so error monitoring misses them, and users may see an empty reply.
Fix: handle refusal explicitly, show a clear message, and track refusals as their own metric.
Parsing stop_details.explanation
What happens: code that searches the explanation text breaks when the wording changes.
Fix: branch on category if needed, and only display explanation.
Retrying an Empty Response Unchanged
What happens: the retry returns the same empty result.
Fix: fix the message structure, or add a new user message before retrying.
Continuing with a Trailing Assistant Message
What happens: ending the request with the partial assistant text, without a following user message, returns a 400 error on current models, because prefill is not supported.
Fix: always end with a user message, such as "Please continue from where you left off."
Best Practices
- Route every response through one handler that maps stop reasons to clear outcomes.
- Never show truncated or refused output as a complete answer.
- Use generous
max_tokensto make truncation rare, and streaming for long outputs. - Monitor stop reasons in production: count how often
max_tokens,refusal, and empty responses occur. - Include a default branch for stop reasons your code does not handle yet.
- Read the official refusal and fallback guides before building automatic fallback logic.
Exercise
- Use
handle_response()with three requests: a normal question, a long-answer question withmax_tokens=50, and a request withstop_sequences=["###"]asking Claude to write a sentence followed by###. Print each result's status. - Extend
Resultwith astop_reasonfield, and fill it in for every case. - Modify
get_complete_response()to print how many requests were needed to finish the answer. - Write a function
summarize_stop_reasons(responses)that takes a list ofMessageobjects and returns a dictionary counting each stop reason.
Step 3: count the attempts and print the total after the loop:
def get_complete_response(client, prompt: str, max_attempts: int = 3) -> str:
messages = [{"role": "user", "content": prompt}]
full_text = ""
attempts = 0
for attempts in range(1, max_attempts + 1):
response = client.messages.create(
model="claude-opus-5-5", max_tokens=4096, messages=messages
)
full_text += "".join(b.text for b in response.content if b.type == "text")
if response.stop_reason != "max_tokens":
break
messages = [
{"role": "user", "content": prompt},
{"role": "assistant", "content": full_text},
{"role": "user", "content": "Please continue from where you left off."},
]
print(f"Requests needed: {attempts}")
return full_text
Step 4:
from collections import Counter
def summarize_stop_reasons(responses) -> dict[str, int]:
return dict(Counter(response.stop_reason for response in responses))
Counter counts how often each value appears. Calling dict() converts it into a plain dictionary, such as {"end_turn": 8, "max_tokens": 2}.