Claude Media
stop_reasonとエラーの違い — Claude APIの成功と失敗を混同しない設計

stop_reasonとエラーの違い — Claude APIの成功と失敗を混同しない設計

stop_reasonは常にHTTP 200の一部で、4xx/5xxのエラーとは別レイヤーです。両者を同じ監視ロジックで扱うと、refusalのような重要な停止理由を見落とします。

Claude APIのレスポンスを監視する仕組みを作るとき、多くの実装が「異常系はステータスコードで判定する」という前提から出発します。この前提のまま stop_reason を扱うと、監視のすき間が生まれます。stop_reason はHTTP 200の成功レスポンスの一部であり、4xxや5xxのエラーとはそもそも別のレイヤーの情報だからです。

stop_reasonとエラーは別レイヤーの情報

stop_reason はMessages APIのすべての成功レスポンスに含まれるフィールドで、Claudeが生成をなぜ止めたかを示します。一方でエラーは、リクエストの処理自体が失敗したことを示すHTTPステータスコード(4xx・5xx)です。両者は発生する層が違います。

  • stop_reason: レスポンス本体の一部。生成は正常に完了しており、content に有効な中身が入っている
  • エラー: リクエスト処理の失敗。error オブジェクトが返り、content は存在しない

この違いを踏まえると、「レスポンスをそのまま使ってよいか、続きを送るべきか、リトライすべきか、フォールバックすべきか」を判断する材料は stop_reason にあり、「リクエスト自体をやり直すべきか」の判断材料はステータスコードにある、と役割が分かれます。

7つのstop_reason値と対応表

Messages APIが返す stop_reason は次の7種類です。

stop_reason発生する場面取るべき対応
end_turn発生する場面Claudeが自然に応答を終えた取るべき対応そのまま使う
max_tokens発生する場面max_tokens の上限に達した取るべき対応上限を上げるか続きを取得する
stop_sequence発生する場面指定した stop_sequences を出力した取るべき対応stop_sequence フィールドでどれが発火したか確認
tool_use発生する場面ツールを呼び出している取るべき対応ツールを実行し結果を返す
pause_turn発生する場面サーバーツールのループが反復上限に達した取るべき対応アシスタントの内容をそのまま送り返して継続
refusal発生する場面Claudeが応答を拒否した取るべき対応stop_details を確認しフォールバックモデルへリトライ
model_context_window_exceeded発生する場面コンテキストウィンドウの上限に達した取るべき対応レスポンスを打ち切りとして扱う

stop_sequencemax_tokens はどちらも「打ち切り」に見えますが、意味が違います。stop_sequence は指定した文字列が出力された時点での意図的な停止で、応答は完結した内容として扱えます。max_tokens はトークン数の上限による強制的な打ち切りで、応答が文の途中で切れている可能性があります。この2つを同じ「打ち切り」として一括処理すると、stop_sequence で正常に終わった応答まで「続きを取得する」処理に回してしまう実装ミスにつながります。

tool_usepause_turn は混同しやすい組み合わせです。クライアント側の tool_use ブロックが未処理のまま応答が止まる場合、stop_reason は必ず tool_use になります。pause_turn が返るのは、Web検索のようなサーバーツールの反復ループがデフォルト10回の上限に達したときだけで、継続のために送り返すものが「ツール結果」か「レスポンスそのもの」かが変わります。

model_context_window_exceeded は、入力サイズが事前にわからないまま最大限のトークンを引き出したいときに使うベータ機能です。Sonnet 4.5以降は追加ヘッダーなしで有効になり、それより前のモデルでは model-context-window-exceeded-2025-08-26 ベータヘッダーが必要です。この値を使った実装パターンは入力サイズ不明でも最大出力を引き出す実装パターンで扱っています。

エラーは何が違うのか — HTTPステータスコード一覧

エラーは常にJSON形式の error オブジェクトとして返り、content は存在しません。主なステータスコードは次の通りです。

コードtype意味
400typeinvalid_request_error意味リクエストの形式・内容の問題、または支出上限の超過
401typeauthentication_error意味APIキーが不正・失効・期限切れ
402typebilling_error意味支払い情報の問題
403typepermission_error意味APIキーに権限がない
404typenot_found_error意味リソースが見つからない
409typeconflict_error意味リソースの状態と競合している
413typerequest_too_large意味リクエストサイズの上限を超過
429typerate_limit_error意味レート制限または支出上限に到達
500typeapi_error意味Anthropic側の予期しない内部エラー
504typetimeout_error意味処理中にタイムアウト
529typeoverloaded_error意味APIが一時的に過負荷

error.type はこの一覧に限定されません。バージョニングポリシー上、type の値は将来増える可能性があるため、文字列の完全一致だけに頼った分岐は壊れやすくなります。公式SDKはこれらをタイプ付き例外に変換して返すため(Pythonなら anthropic.NotFoundError、Rubyなら Anthropic::Errors::NotFoundError など)、文字列比較よりSDKの例外クラスで捕捉するほうが安全です。エラーコードとSDK例外クラスの言語別対応はClaude APIのエラー形式とSDK例外クラスの言語別対応表にまとめています。

refusalが紛らわしい理由 — 200なのに拒否

stop_reason の7種類の中で最も誤解されやすいのが refusal です。安全性の分類器がリクエストを拒否したときに返る値ですが、これは通常のHTTP 200の成功レスポンスとして届きます。エラーレートやステータスコードを軸にした監視は、refusalを一切検知しません。

refusalはエラーではありません。コンテンツの安全性判定という、リクエスト処理の成否とは別のレイヤーの結果です。stop_details.category にどの方針領域が発火したかが入りますが、categoryexplanation は分類できないケースで null になることがあるため、判定条件には stop_reason === "refusal"(または stop_details.type)を直接使います。ストリーミング中のrefusal検出とコンテキストのリセット手順はClaude APIのrefusal stop_reasonを検出してリセットする方法に詳しく書きました。

ストリーミングでは監視の分岐点がもう一段複雑になる

ストリーミング(SSE)を使うと、200応答が返ったあとにエラーが発生することがあります。この場合のエラーは標準のHTTPステータスコードでは表現されず、ストリーム内のイベントとして届きます。つまりストリーミング実装では、「リクエスト自体の4xx/5xx」「ストリーム内のエラーイベント」「成功レスポンス内のstop_reason」という3層を分けて扱う必要があります。v4.6でこのストリーミングエラー復旧の挙動が変わった経緯はClaude APIストリーミングのエラー復旧がv4.6で変わったを参照してください。

Message Batchesでも境界は同じ位置にある

この区別はストリーミングだけでなく、Message Batchesの結果判定にも同じ形で現れます。バッチ内の各リクエストは result.typesucceeded / errored / canceled / expired のいずれかに振り分けられますが、refusalは errored ではなく succeeded です。中身の stop_reason"refusal" になっているだけで、バッチとしては正常に処理が完了した扱いになります。

エラー件数だけを集計する監視ロジックをバッチに流用すると、errored の件数はゼロなのにrefusalが大量発生している、という状態を見逃します。バッチの結果を集計するときは、result.type の分岐とは別に、succeeded の中身の stop_reason を数える処理が要ります。もう1点、Message Batchではfallbacks パラメータ自体が使えず、含めたリクエストは個別に errored 結果として返ります。バッチのrefusalをリトライするなら、フォールバックモデルへの再送を自分で組む必要があります。バッチAPIのエラー結果全般(errored の中の invalid_request_error 判定や24時間・29日の期限)はClaude APIのエラーハンドリング設計で扱っているので、そちらとあわせて読むと全体像がつながります。

実装パターン — stop_reasonとエラーを1つの分岐で扱わない

curl --fail-with-body -sS https://api.anthropic.com/v1/messages \
  -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -H "content-type: application/json" \
  -d '{
    "model": "claude-opus-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Hello!"}]
  }' | jq '.stop_reason'

Python SDKでは、try/except の外で stop_reason を見て、except の中でHTTPエラーを見る、という2段構えの分岐が基本形になります。

try:
    response = client.messages.create(
        model="claude-opus-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": "Hello!"}],
    )
    if response.stop_reason == "max_tokens":
        print("応答が上限で打ち切られました")
except anthropic.APIStatusError as e:
    if e.status_code == 429:
        print("レート制限に到達")
    elif e.status_code == 500:
        print("サーバーエラー")

TypeScriptでも構造は同じで、try/catch の外に stop_reason の分岐、catch の中に Anthropic.APIError の分岐を置きます。

try {
  const response = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: 1024,
    messages: [{ role: "user", content: "Hello!" }]
  });
  if (response.stop_reason === "max_tokens") {
    console.log("応答が上限で打ち切られました");
  }
} catch (err) {
  if (err instanceof Anthropic.APIError) {
    if (err.status === 429) console.log("レート制限に到達");
    else if (err.status === 500) console.log("サーバーエラー");
  } else {
    throw err;
  }
}

この形が崩れやすいのは、stop_reason の分岐をエラーハンドリングの except(または catch)ブロックの中に書いてしまうケースです。stop_reason は成功レスポンスの一部なので、except / catch には絶対に入ってきません。逆に、エラーを stop_reason の分岐として拾おうとするコードも動作しません。2つの分岐は独立した2つの if / try として書く必要があります。

Anthropicはなぜこの2レイヤーを分離したのか

この設計は、監視の責務を分けるための意図的な選択と見ることもできます。エラーは「インフラ・入力・課金の問題」を表し、リトライやアラートの対象です。一方でrefusalを含む stop_reason は「モデルの応答判断」を表し、コンテンツ面の対応(フォールバックモデルへの切り替えや、プロンプトの調整)を必要とします。この2つを1本のエラーレートに混ぜてしまうと、インフラは正常なのにrefusalだけが急増している、といった変化が埋もれます。refusal率を専用の指標として監視に加えるのは、この設計を踏まえた対応です。

ただし、この分離を厳密に維持できるかは実装側の規律に依存します。except ブロックの中で stop_reason を読むコードは、テスト環境の正常系リクエストでは決して顕在化しません。本番でrefusalやmax_tokensが実際に発生して初めて、分岐が動いていないことに気づく形になります。

もう1つの見落としが、429と529です。この2つはインフラ側の一時的な過負荷に近く、公式SDKが既定で2回まで自動リトライします。つまり実装が自分で書く分岐に届く前に、SDK内部で一度リトライが完了しています。ここに stop_reason の分岐を混ぜて考えると、「なぜmax_tokensだけ自分でリトライを書く必要があるのか」が見えにくくなります。答えは単純で、max_tokens はリクエストが成功した結果であり、SDKのリトライ機構が対象にしているのは通信・サーバー側の失敗だけだからです。応答の中身がどう打ち切られたかは、呼び出し側のアプリケーションロジックの責任範囲になります。

まとめ

stop_reason は成功レスポンスの一部、エラーはリクエスト処理の失敗という、発生するレイヤーが異なる2つの情報です。実装では両者を1つの分岐にまとめず、try/except(またはステータスコード判定)の外側で stop_reason を、内側でHTTPエラーを扱います。とくに refusal はHTTP 200で返るため、エラーレートを軸にした監視では検知できません。

この境界は同期のMessages APIに限らず、ストリーミングのイベント分離やMessage Batchesの result.type 判定にも同じ形で貫かれています。プロトコル全体を通して「リトライで直る層(エラー)」と「アプリケーション側の判断が要る層(stop_reason)」が一貫して分けられている、という前提で監視とハンドリングを設計すると、実装のたびに同じ分岐ロジックを再発明せずに済みます。refusal率とmax_tokens率をエラーレートとは別の指標として持てば、インフラが正常なままrefusalだけが増えた変化を検知できます。

この記事を共有:XはてブLinkedIn