Claude Media
StopFailureフックでAPIエラー終了時のフォールバックを書く

StopFailureフックでAPIエラー終了時のフォールバックを書く

ターンがAPIエラーで終わったときだけ発火するStopFailureフック。decision controlを持たない仕様と、エラー種別ごとの復旧レシピを解説します。

StopFailureフックとは

StopFailureは、ターンがレート制限や認証エラーなどのAPIエラーで終わったときに発火するフックです。正常にターンを終えたときはStopが発火し、APIエラーで終わったときはStopの代わりにStopFailureが発火します。ユーザーが割り込んで止めたときはどちらも発火しません。

最大の特徴は、ブロックも継続もできないことです。Claude Codeはこのフックの出力とexit codeを完全に無視します。唯一の例外はデスクトップ通知用のterminalSequenceフィールドで、これだけは処理されます。つまりStopFailureはセッションの流れに介入する仕組みではなく、記録・通知・復旧アクションの起点として使うフックです。

このイベントはv2.1.78で追加されました。それまでAPIエラーで終わったターンはStopと見分けがつかず、成功終了と失敗終了を同じフックで処理しようとすると、Claudeの返答本文とエラー文字列を判別するロジックを自分で書く必要がありました。専用イベントとして分離されたことで、その判別処理は不要になっています。

Stopが継続をブロックできるのは、Claudeがまだ応答できる状態でターンを終えているからです。一方StopFailureが発火する時点ではAPI呼び出し自体が失敗しており、Claudeへ差し戻す会話の続きがありません。継続させる相手が存在しない以上、このフックが観測専用になっているのは自然な設計です。

Stopとの違い

観点StopStopFailure
発火条件Stopターンが正常終了StopFailureAPIエラーでターンが終了
decision controlStopあり(decision: "block"で継続可能)StopFailureなし。出力もexit codeも無視
last_assistant_messageの中身StopClaudeの会話出力の本文StopFailureAPIエラー文字列(例:"API Error: Rate limit reached")
ユーザー割り込み時Stop発火しないStopFailure発火しない
主な用途Stopテスト通過までの継続、/goalの基盤StopFailure失敗の記録・通知・復旧アクション

Stopは「Claudeがどう答えたか」を見るフックですが、StopFailureは「Claudeが答えられなかった」ことを見るフックです。last_assistant_messageに入る文字列の意味が正反対なので、同じフィールド名でも同じ処理を書き回せません。

エラー種別とmatcherの落とし穴

StopFailureerrorフィールドの値でmatcherを絞り込めます。取りうる値は10種類です。

errorの値意味
rate_limit意味レート制限に達した
overloaded意味APIが過負荷状態
authentication_failed意味認証に失敗した
oauth_org_not_allowed意味OAuth組織の許可設定に引っかかった
billing_error意味課金上のエラー
invalid_request意味リクエストが不正
model_not_found意味指定モデルが見つからない
server_error意味サーバー側のエラー
max_output_tokens意味出力トークンの上限に達した
unknown意味上記以外

ここで一つ、他の大半のイベントとは違う仕様があります。StopFailureFileChangedだけは、matcherの完全一致判定に使える文字種が英数字・アンダースコア・|だけに絞られています。ハイフンやスペース、カンマを混ぜると正規表現として評価される側に倒れます。"rate_limit|overloaded"は複数のerror値を完全一致で拾えますが、"rate_limit, overloaded"のようにカンマとスペースを入れると、文字列全体を1個の正規表現として解釈しようとして意図通りに動きません。他のイベントのmatcherをコピーしてカンマ区切りで書くと、ここでだけ壊れます。

復旧レシピ — エラー種別ごとに対応を分ける

エラーの性質によって、必要なアクションはまったく違います。rate_limitoverloadedは待てば直る一過性のエラー、authentication_failedbilling_errorは人間の対応が要る恒久的なエラーです。matcherで振り分けて別々のスクリプトを割り当てます。

#!/bin/bash
# authentication_failed / billing_error 向け: 対応者に即通知
input=$(cat)
error=$(echo "$input" | jq -r '.error')
message=$(echo "$input" | jq -r '.last_assistant_message // "unknown error"')
 
curl -s -X POST "$SLACK_WEBHOOK_URL" \
  -H 'Content-Type: application/json' \
  -d "$(jq -nc --arg text "🚨 [$error] セッションが停止しました: $message" '{text: $text}')"
 
seq=$(printf '\033]777;notify;%s;%s\007' "Claude Code" "$error で停止")
jq -nc --arg seq "$seq" '{terminalSequence: $seq}'

settings.json側はmatcherでこのスクリプトの対象を絞ります。

{
  "hooks": {
    "StopFailure": [
      {
        "matcher": "authentication_failed|billing_error",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify-hard-failure.sh"
          }
        ]
      }
    ]
  }
}

外部への通知が遅いネットワーク越しになる場合は、async: trueを付けてセッション終了処理を待たせないようにしておくと安心です。decision controlが元から無いので、非同期にしても失うものはありません。

一過性のエラー側は通知の温度感を落とします。rate_limitoverloadedは時間を置けば解消するケースが大半なので、Slackへ毎回アラートを飛ばすと運用が荒れます。ログファイルへの追記だけにして、同じエラーが短時間に何度も続いたときだけ通知するくらいが実用的です。

#!/bin/bash
# rate_limit / overloaded 向け: ログにだけ残す
input=$(cat)
error=$(echo "$input" | jq -r '.error')
echo "$(date -Iseconds) StopFailure: $error" >> "$CLAUDE_PROJECT_DIR/.claude/logs/stopfailure.log"
exit 0

これをmatcher: "rate_limit|overloaded"に、先の通知スクリプトをmatcher: "authentication_failed|billing_error"に割り当てれば、1つのイベントの中で温度感の違う2系統の対応を共存させられます。

/goalとの関係

/goalコマンドは「条件を満たすまで作業を続けさせる」ための、セッション単位で組んだStopフックのショートカットです。継続の判断はStop側で行うので、StopFailureはその範囲外にあります。ターンが認証切れや課金エラーのような復旧不能なエラーで終わったとき、/goalは待ち続けずに自身を解除し、通知を出す仕様になっています。継続の仕組みを自作するときも同じ発想が要ります。Stopフックだけでリトライループを書くと、StopFailureが絡む致命的なエラーのときに無限に待ち続けかねないので、両方のイベントを組で設計します。

Stop hookでテスト駆動開発を締めるレシピは「テストが通るまでタスクを完了させない」という成功パス側の設計です。この記事のStopFailureレシピは、その手前で足を止めてしまう失敗パス側を埋める形になります。継続を制御するStopと、継続できない状態を観測するStopFailureは役割が分かれているので、どちらか一方だけを作り込んでも運用の穴は残ります。

unknownと汎用ハンドラーの二重発火に注意する

errorunknownのケースを拾う設計にするなら、matcherを省略した汎用ハンドラーを別に用意したくなります。ただしmatcherを省略したStopFailureハンドラーは、フィルタなしなので対象イベントが起きるたびに毎回実行されます。特定のerror値にmatcherを絞ったハンドラーと、matcherを省略した汎用ハンドラーを両方登録すると、rate_limitのような値でも両方が発火し、通知が二重に飛びます。想定外のエラーだけを拾いたいなら、汎用ハンドラー側のスクリプトの先頭で、既に個別ハンドラーが処理する値かどうかをerrorフィールドで判定して早期returnするのが確実です。

タイムアウトの既定値

コマンドフックのタイムアウトは既定で600秒です。StopFailureもこの既定に従うので、通知先のAPIが多少もたついても途中で打ち切られる心配はほとんどありません。ただし同期実行のまま重い処理(外部システムへのチケット起票など)を書くと、その間はセッションの後続処理が止まります。Notificationフックを使った軽い通知と、StopFailureでの重い復旧アクションとで、同期・非同期を使い分けるのが実務的です。

よくあるつまずき

  • exit 2やJSON出力でブロックしようとする: StopFailureは出力とexit codeを丸ごと無視するので、decisioncontinueをどう書いても何も起きません。ロジックは通知や記録の副作用だけに絞ります
  • matcherにカンマやハイフンを使う: StopFailureは完全一致がパイプ区切り限定です。カンマ・スペース・ハイフンを含めると正規表現として評価され、他のイベントで通っていた書き方が壊れます
  • last_assistant_messageを会話ログとして扱う: このフィールドの中身はAPIエラー文字列であって、Claudeの返答ではありません。Stop用に書いたテンプレートをそのまま流用すると意味不明な通知になります
  • ユーザー割り込みでも発火すると思い込む: Stopと同じく、ユーザーが手動で止めたときはどちらのフックも発火しません。中断の検知が必要なら別の仕組みを組む必要があります
  • terminalSequence以外のフィールドで何か制御できると期待する: systemMessageadditionalContextも、StopFailureでは処理対象から外れています

まとめ

StopFailureはAPIエラーで終わったターンをフックする唯一の入口です。ブロックも継続もできない代わりに、エラー種別ごとの通知・記録・アラートに徹する設計と割り切れます。matcherの文字種制限だけは他のイベントの感覚で書くと確実にはまるので、パイプ区切りで統一しておくのが安全です。

一過性のエラーはログだけ、恒久的なエラーは即時通知という温度差を最初からmatcherで分けておくと、運用が始まってから通知疲れで見なくなる事態を避けやすくなります。無人で長時間走らせるセッションほど、この仕分けの効果が大きく出ます。

CIやスケジュール実行のように人が張り付いていない環境ほど、StopFailureの価値は上がります。ターミナルを見ていれば気づけるエラーも、無人実行では気づく手段がフック以外に無いからです。まずはログ記録だけの最小構成で入れておき、実際にどのerror値が多いかを見てから通知先を広げていく順番が、過剰なアラートを避けながら運用に定着させやすい進め方です。

よくある質問

overloadedrate_limitはどう違いますか

rate_limitはそのアカウント・APIキー固有の利用上限に達した状態です。overloadedはAnthropic側のAPI全体が混雑している状態を指し、こちらは特定のアカウントに限らず起こります。どちらも時間を置けば解消することが多い点は共通していますが、原因が自分の使用量にあるかAPI側の負荷にあるかが違うので、通知文言を分けておくと状況把握が早くなります。

StopFailureでリトライを自動化できますか

フック自体にはターンをやり直す手段がありません。decision controlを持たないため、できるのは通知や記録などの副作用だけです。自動リトライが必要なら、外部スクリプトからclaude -pを再実行するような、フックの外側の仕組みを組む必要があります。

エラーの種類ごとに違う通知先へ振り分けられますか

できます。matcherでerror値をパイプ区切り(rate_limit|overloadedのように)に指定し、種別ごとに別のフックハンドラーを登録します。カンマ区切りは正規表現として評価されるため使わないでください。

Notificationフックとの違いは何ですか

Notificationは権限プロンプトやアイドル状態など、Claude Codeがユーザーの注意を必要とする場面全般で発火します。StopFailureはAPIエラーでターンが終わったときだけの専用イベントで、対象がより狭い分、エラー種別ごとの分岐がしやすくなっています。

async: trueは必要ですか

decision controlがそもそも無いので、同期実行にする理由はほとんどありません。外部APIへの通知が絡むならasync: trueにしておくと、セッションの後続処理を待たせずに済みます。

サブエージェントのAPIエラーでも発火しますか

StopFailureに相当する専用イベントはサブエージェント側にはありません。サブエージェントの終了はSubagentStopが担いますが、これはAPIエラーかどうかを区別するフィールドを持たない、正常終了扱いのイベントです。サブエージェントの失敗を捕捉する余地があるとすれば、呼び出し元セッションでAgentツールを対象にしたPostToolUseFailure側です。

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