Batch APIの拒否は成功扱いになる落とし穴
Message Batches APIで拒否されたリクエストはresult.type: succeededで返るため、errored監視だけでは検知できない。判定条件と再送手順を整理する。
Batch APIの拒否は静かに「成功」として返ってくる
大量のリクエストを非同期でまとめて処理できるMessage Batches APIには、通常のリクエストと同じく利用ポリシーによる拒否が発生します。送ったリクエストが利用ポリシーに触れて拒否されても、結果は失敗として返ってきません。result.type は succeeded のままで、stop_reason が refusal になるだけです。
拒否理由を示す category と explanation は stop_details に入りますが、これも succeeded な結果の中身の一部として扱われます。バッチのレスポンス形式を素直に信じて result.type だけで成否を判定する実装は、この時点で拒否を取りこぼす設計になっています。
同期リクエストの拒否はHTTP 200 + stop_reason: "refusal" として届きますが、バッチも同じ構造を引き継ぎます。バッチ結果は4種類の result.type(succeeded / errored / canceled / expired)のいずれかで返り、拒否はこの中の succeeded に分類されます。Claude Batch APIの使い方で扱った基本の結果構造に、この分類がそのまま乗っています。
1つのMessage Batchには最大で10万件のリクエスト、または256MBのどちらか早く到達したほうまで積めます。件数が多いバッチほど拒否の絶対数も増えますが、結果全体に対する拒否の割合はごくわずかにとどまることが多く、成功率のようなサマリー指標だけを見ていると変化として現れません。
結果ファイルの1行は、成功したリクエストも拒否されたリクエストも同じ succeeded の形をしています。
{"custom_id":"req-042","result":{"type":"succeeded","message":{"id":"msg_01XFUDYJgAACzvnptvVoYEL","type":"message","role":"assistant","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}}}}content が空配列で stop_reason が refusal になっている点を除けば、通常の成功レスポンスと構造は同じです。.result.type だけを見て集計するツールは、この行を他の成功レスポンスと区別できません。テキストを生成した成功結果と、この拒否結果を同じフィルタ条件で扱っている限り、両者はいつまでも混在したままになります。
| result.type | 意味 | 課金 |
|---|---|---|
| succeeded | 意味リクエストが成功(拒否もここに含まれる) | 課金通常どおり課金 |
| errored | 意味無効なリクエストやサーバーエラー | 課金課金なし |
| canceled | 意味バッチキャンセルで未送信 | 課金課金なし |
| expired | 意味24時間の期限切れで未送信 | 課金課金なし |
出力開始前の拒否は課金対象外です。usage フィールドにはトークン数が表示されますが、これは請求には反映されない表示専用の値です。出力開始前と開始後のどちらの拒否かは usage.output_tokens が 0 かどうかで判別でき、先ほどのサンプル行も output_tokens":0 なので出力開始前に拒否された例だとわかります。
ただし、課金されない場合でもリクエスト自体はレート制限の消費対象に数えられます。拒否だからといってレート制限枠が無傷で戻ってくるわけではありません。すでに出力が始まってから拒否された場合は、そこまでの入力トークンと出力トークンが通常どおり課金されます。バッチの1件ごとの結果でもこの区別は同じです。
バッチのリクエストは通常のAPI価格の50%で課金されるため、出力開始後に拒否された分もこの割引価格の対象になります。
バッチのリクエストには stream: true を含められません。バッチ結果は処理が終わったあとに単一のファイルとしてまとめて返るため、クライアント側で部分的な出力を受け取りながら拒否を検知するという状況は起こりません。届く結果は常に完成した1件のオブジェクトです。
stop_detailsが示す拒否の種類
拒否した結果には stop_details オブジェクトが必ず入り、category と explanation を持ちます。category はどのポリシー領域が引っかかったかを示します。
| category | 意味 |
|---|---|
| cyber | 意味マルウェアやエクスプロイト開発などサイバー攻撃に使える可能性がある要求(善意のセキュリティ作業でも該当することがある) |
| bio | 意味危険な実験手法など生物学的な害につながる可能性がある要求(善意のライフサイエンス作業でも該当することがある) |
| frontier_llm | 意味競合AIモデルの開発を助ける可能性がある要求。Anthropicの商用利用規約で制限されている領域 |
| reasoning_extraction | 意味モデル内部の推論過程をそのまま出力させようとする要求 |
| general_harms | 意味上記4つに当てはまらない利用ポリシー領域 |
category と explanation は該当カテゴリが無いとき null になります。これは異常値ではなく正常な値であり、パース処理で例外にする必要はありません。大量のバッチ結果を集計するときは、category別に件数を出すと、どのポリシー領域で拒否が偏っているかが見えます。
explanation はモデルが生成する説明文で、バージョン間で文言が変わる可能性があります。バッチ結果を自動処理する分岐条件には使わず、人が確認するログや画面に表示する用途にとどめます。自動化された分類のロジックには、文言ではなく category の値を使います。
stop_details.recommended_model というフィールドもありますが、これはサーバー側フォールバック(fallbacks パラメーター)を設定したリクエストにのみ値が入ります。バッチでは後述のとおり fallbacks パラメーター自体が使えないため、バッチの拒否結果ではこのフィールドに値が入らない構造になっていると考えられます。
エラー監視だけでは拒否に気づけない理由
result.type == "errored" だけを数える単純なアラートは、拒否を一件も検知できません。拒否は succeeded の中に埋もれているからです。
# 拒否を見落とす例: errored だけを数えている
curl https://api.anthropic.com/v1/messages/batches/msgbatch_01.../results \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
| jq -s '[.[] | select(.result.type == "errored")] | length'拒否を検知するには、succeeded の中身まで見て stop_reason を確認する必要があります。
# 拒否を正しく検知する例
curl https://api.anthropic.com/v1/messages/batches/msgbatch_01.../results \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
| jq -s '[.[] | select(.result.type == "succeeded" and .result.message.stop_reason == "refusal")] | length'検知の分岐条件は必ず stop_reason または stop_details.type の値で判定します。stop_details.category と explanation は該当カテゴリが無いとき null になる正常値なので、この2つのフィールドを分岐条件に使うと拒否の一部を取りこぼします。
リトライ予算をバッチ全体の成功率だけで管理しているケースも見落としやすい落とし穴です。数万件規模のバッチに数十件の拒否が混ざっていても、成功率という集計値だけを見ていると個々の拒否は埋もれます。予算やアラートはリクエスト単位で持たせ、バッチ全体の集計値だけに頼らない設計にします。
なぜバッチだけ手動の再送になるのか
拒否を別モデルへ再送する方法は、同期リクエストなら3通りあります。サーバー側フォールバック(fallbacks パラメーターを付けて1往復で完結させる方法)、SDKに組み込まれたミドルウェア(クライアントに1回設定すれば以降の呼び出しで自動的に再送する方法)、そして自前で組む手動リトライです。
バッチでは最初の2つが使えません。サーバー側フォールバックはバッチ項目に含めるとエラーになりますし、SDKミドルウェアは client.beta.messages の個々の呼び出しをラップする仕組みなので、結果ファイルを事後にパースするバッチのワークフローにはそのまま乗りません。したがって、バッチの拒否再送は最初から手動リトライの手順で設計することになります。
fallbacks を含めたバッチ項目は、拒否とは違って result.type: "errored" になります。この場合は通常の errored 監視で検知できるので見落としにくい一方、その項目はそもそも処理されずに終わる点が拒否とは異なります。「errored になるパラメーターエラー」と「succeeded に埋もれる拒否」は原因も検知方法も別物として扱います。
同期リクエストのサーバー側フォールバックは、拒否のカテゴリーに応じてAnthropicが推奨するモデルへ自動で切り替わります。バッチではこの自動選択も使えないため、category の値を見ながらどのモデルへ再送するかを自分で決める設計にします。
拒否したリクエストをどう再送するか
拒否したリクエストを検知したあと、別モデルへ再送するときは、次の3つの手順を順番に踏みます。
- 結果から拒否された項目を集める(
result.type == "succeeded"かつstop_reason == "refusal") - 複数ターンの履歴に含まれるthinkingブロックを取り除く(Claude Fable 5.1やClaude Fable 5で送っていた履歴の場合)
- 別モデルを指定して新しいバッチとして、または個別リクエストとして再送する
バッチの結果は元のリクエスト順で返る保証がありません。どのリクエストがどの custom_id に対応するかを結果側の custom_id で突き合わせる前提で実装します。次のスクリプトは、結果ファイルから拒否項目を抜き出し、別モデル宛ての新しいバッチ入力ファイルを組み立てる最小限の例です。
import json
FALLBACK_MODEL = "claude-opus-4.8"
refused_requests = []
with open("results.jsonl") as f:
for line in f:
result = json.loads(line)
r = result["result"]
if r["type"] == "succeeded" and r["message"]["stop_reason"] == "refusal":
refused_requests.append(result["custom_id"])
# 元のリクエスト本文と突き合わせてモデルを差し替え、新しいバッチ入力を作る
with open("original_requests.jsonl") as f, open("retry_batch.jsonl", "w") as out:
for line in f:
req = json.loads(line)
if req["custom_id"] in refused_requests:
req["params"]["model"] = FALLBACK_MODEL
out.write(json.dumps(req) + "\n")Claude Fable 5.1・Claude Fable 5が拒否した場合、許可されているフォールバック先はClaude Opus 4.8とClaude Opus 5に限られます。任意のモデルへ自由に再送できるわけではなく、拒否したモデルごとに許可された再送先が決まっているため、スクリプトの FALLBACK_MODEL はこの制約に沿って選びます。
custom_id は各リクエストで一意である必要があります。同じ値を複数のリクエストで使い回すと、結果を突き合わせる段階でどちらの拒否かを区別できなくなります。バッチを分割して流している場合は、バッチをまたいでも一意になる命名にしておくと再送の追跡が崩れません。
手順2でthinkingブロックを取り除くのは、フォールバック先のモデルがClaude Fable系のthinkingブロックを解釈できないためです。残したまま送っても無視されるだけで、入力トークンを無駄に消費します。同期リクエストでの検出とリセットの手順はClaude APIのrefusal stop_reasonを検出してリセットする方法にまとめています。
バッチの拒否はfallback credit(再送時のキャッシュ書き込みコストを相殺する仕組み)を発行しません。しかも、バッチのリクエストに fallback_credit_token を含めてもエラーにはならず、単に無視されます。同期用に書いたリトライコードをそのままバッチへ転用すると、トークンが黙って効かなくなっていることに気づきにくい構成です。
再送した新しいバッチの結果にも、同じ検知ロジックをもう一度かけます。フォールバック先のモデルでも拒否される可能性はゼロではないため、1回再送して終わりにせず、succeeded かつ stop_reason == "refusal" の判定を再送後の結果にも同じコードで適用します。
複数ターンの会話をバッチで送っているアプリケーションでは、フォールバックしたモデルへの固定も自分で管理します。同期リクエストのSDKミドルウェアは1つの状態オブジェクトを会話全体で共有し、一度フォールバックしたモデルにその後のターンも固定できますが、バッチにはこの仕組みがありません。あるターンで別モデルへ再送した会話は、後続のターンも同じモデルへ送り続けるロジックを呼び出し側で持たせておく必要があります。
まとめ
Message Batches APIの拒否は result.type: "succeeded" として返り、errored を監視するだけの仕組みでは検知できません。拒否の判定は stop_reason または stop_details.type で行い、category / explanation のnull判定に依存しないことが要点です。バッチでは fallbacks パラメーターとfallback creditが使えず、fallback_credit_token を送っても黙って無視されるため、拒否したリクエストの再送ロジックは最初から自前で組む前提で設計します。再送先はモデルごとに許可された範囲に限られ、custom_id で結果と元のリクエストを突き合わせる実装が土台になります。