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

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

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

Claude APIのレスポンスを監視する仕組みを作るとき、エラーレートや5xxだけで異常系を監視していると、stop_reason の異常を拾えません。stop_reason はHTTP 200の成功レスポンスの一部であり、4xxや5xxのエラーとはそもそも別のレイヤーの情報だからです。

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

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

くらべる

どちらの層の情報か

成功レスポンスの中

stop_reason

HTTP 200で返るレスポンス本体のフィールドです。生成が止まった理由を示し、refusal もここに入ります。判断するのはアプリケーションの仕事です。

リクエスト処理の失敗

エラー

4xx・5xxのステータスコードと、error オブジェクトを持つJSONで返ります。content は存在しません。

content が空になる成功レスポンスもあります。出力が始まる前に拒否されたrefusalは、content が [] のままHTTP 200で届きます。content の有無だけでエラーかどうかを判定すると、この応答を取りこぼします。

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

tool_use と pause_turn は混同しやすい組み合わせです。クライアント側の tool_use ブロックが応答の中で待っているときの stop_reason は tool_use で、pause_turn にはなりません。pause_turn が返るのは、Web検索のようなサーバーツールの反復ループがデフォルト10回の上限に達したときです。継続のために送り返すのは、tool_use なら tool_result、pause_turn ならレスポンスそのものです。

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

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

主なステータスコードと error.type の対応は次の通りです。

コード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で届く — 形・タイミング・課金

refusal は、安全性の分類器がリクエストを拒否したときに返る値です。通常のHTTP 200の成功レスポンスとして届く点で、他のエラーとは扱いが違います。

出力前に拒否されたときのレスポンスは、次の形です。

{
  "content": [],
  "stop_reason": "refusal",
  "stop_details": {
    "type": "refusal",
    "category": "cyber",
    "explanation": "This request was declined because it could enable cyber harm."
  },
  "usage": { "input_tokens": 412, "output_tokens": 0 }
}

stop_details.category は cyber / bio / frontier_llm / reasoning_extraction / general_harms のいずれかで、どの方針領域が発火したかを示します。explanation の文面は安定しないので、画面に出すだけにして分岐には使いません。分類できない拒否では category と explanation がどちらも null になるため、判定には stop_reason === "refusal"(または stop_details.type)を直接使います。stop_details 自体も、refusal以外の stop_reason では null です。

拒否が届くタイミングで、扱いが変わります。

くらべる

出力前の拒否と途中の拒否

content が空

出力前

output_tokens は0です。課金されるかどうかはカテゴリー次第で、bio・frontier_llm・reasoning_extraction は課金され、cyber・general_harms・null は課金されません。どちらの場合もレート制限には数えられます。

ストリーミング中

出力途中

部分的な出力が届いたあとで拒否されることがあります。部分出力は未完了として捨てます。入力トークンとそれまでに流れた出力トークンは通常料金で課金されます。

課金対象のカテゴリーは今後変わりうる、とされています。「HTTP 200だから課金は成功時と同じ」「拒否だから無料」のどちらも成り立たないので、コストの集計でもrefusalを独立した項目にしておくと食い違いに気づけます。

ストリーミング中のrefusal検出はClaude APIのrefusal stop_reasonを検出してリセットする方法に書きました。リセット手順もそちらにあります。

同じモデルに同じリクエストを送り直しても、再び拒否される可能性が高いとされています。リトライ先はフォールバックモデルにします。

最も簡単な構成は、Claude API上のベータ機能であるサーバー側フォールバックです。fallbacks を "default" に設定すると、拒否されたリクエストをAPIが、その拒否カテゴリー向けにAnthropicが推奨するフォールバックモデルで自動的に再試行します。発動するのは安全性の分類器による拒否だけです。リクエスト先モデルでのレート制限・過負荷・サーバーエラーはそのまま呼び出し側に返るので、ここでもエラーとrefusalは別の経路になります。

ストリーミングで判定が通る3つの関門

ストリーミング(SSE)の応答は、次の順に判定が通ります。

手順

ストリーミング応答を判定する順番

  1. 1

    リクエスト自体の4xx・5xx

    接続の時点で失敗していれば、通常のHTTPエラーです。SDKなら例外として上がってきます。

  2. 2

    ストリーム内のエラーイベント

    200で始まったあとに event: error が届きます。過負荷なら overloaded_error で、非ストリーミングなら529に対応します。

  3. 3

    message_deltaのstop_reason

    message_start では stop_reason が null で、message_delta イベントで初めて値が入ります。ここでrefusalやmax_tokensを判定します。

ストリーミングの途中切断からの復旧は、Claude 4.5以前とClaude 4.6以降で手順が違います。違いはClaude APIストリーミングのエラー復旧がClaude 4.6以降で変わった点にあります。

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

バッチ内の各リクエストは result.type が succeeded / errored / canceled / expired のいずれかに振り分けられますが、refusalは errored ではなく succeeded です。中身の stop_reason が "refusal" になっているだけで、バッチとしては正常に処理が完了した扱いになります。

エラー件数だけを集計する監視ロジックをバッチに流用すると、errored の件数はゼロなのにrefusalが大量発生している、という状態を見逃します。バッチの結果を集計するときは、result.type の分岐とは別に、succeeded の中身の stop_reason を数える処理が要ります。

Message Batchでは fallbacks パラメータが使えず、含めたリクエストは個別に errored 結果として返ります。拒否されたバッチ項目の再送は、次の手順で自前で組みます。

  1. 結果から拒否された項目を集める
  2. Claude Fable 5.1 / Fable 5のthinkingブロックが多ターンの履歴に入っていれば取り除く
  3. フォールバックモデルに、新しいバッチまたは通常のリクエストとして再送する

バッチ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-5",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Hello!"}]
  }' | jq '.stop_reason'

--fail-with-body を付けると、HTTPエラーのときにcurlが非ゼロで終了します。終了コードでエラーを、jq の出力で stop_reason を、それぞれ別に見られます。

Python SDKでは、except でHTTPエラーを処理し、try の後ろで stop_reason を見る形が基本です。

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

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

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

崩れやすいのは、stop_reason の分岐を except(または catch)の中に書くケースです。stop_reason は成功レスポンスの一部なので、except / catch には入ってきません。逆に、エラーを stop_reason の分岐として拾おうとするコードも動作しません。2つの分岐は、独立した処理として書きます。

再試行はどちらの層の仕事か

エラー側には、SDKの自動リトライがあります。接続エラー・レート制限・5xxのような一時的な失敗は、既定で2回まで指数バックオフ付きで再試行され、retry-after ヘッダーがあればそれに従います。実装が自分で書く分岐に届く前に、SDK内部で再試行が済んでいる場合があるということです。

ただし429がすべて待てば直るわけではありません。利用ティアの月間支出上限に達した429には retry-after ヘッダーが付かず、アクセスが再開するまで失敗し続けます。400にも、組織やワークスペースに設定した支出上限の超過が含まれます。この2つはSDKの再試行で解決しないので、アラートの対象にします。

stop_reason 側は、SDKのリトライの対象外です。max_tokens はリクエストが成功した結果であり、SDKの再試行が扱うのは通信・サーバー側の失敗だからです。応答の中身がどう打ち切られたかは、呼び出し側のアプリケーションロジックの責任範囲になります。

エラーは「インフラ・入力・課金の問題」として、リトライやアラートの対象になります。refusalを含む stop_reason は「モデルの応答判断」として、フォールバックやプロンプトの調整の対象です。1本のエラーレートに混ぜると、インフラは正常なのにrefusalだけが急増している変化が埋もれます。

まとめ

refusalは1件ごとにイベントを出し、フォールバックで救われた応答(usage.iterations の fallback_message)の件数との差を警報にする、という運用があります。エラーレートとは別のダッシュボードに置くと、両者が混ざりません。

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