そのエラー、待てば直りますか? — 分類・伝達・非常口を間違えて「静かにデータを失う」11の罠の完全地図
半年のあいだに、こんな3つの事故を踏みました。
300件のバッチが、毎回きれいに後半だけ消える
毎分リトライしていたのに、3週間ずっと同じ場所で止まっていた
「安くするため」に足したフォールバックが、毎回いちばん高い経路を叩いていた
技術も担当もバラバラです。Pythonのバッチ、シェルスクリプトのcron、Node.jsの画像生成パイプライン。直した場所も全部違います。
でも原因を並べ直したとき、背筋が冷えました。どれもエラー処理は「書いてあった」のです。 try/except もある。リトライもある。フォールバックもある。レビューで「ちゃんと例外処理されてますね」と言われそうなコードでした。
問題は、書いてあったエラー処理が間違った質問に答えていたことでした。
統一のメンタルモデル:エラー処理は「記録」ではなく「次の一手」を決める仕事
エラー処理を「エラーをログに書く仕事」だと思っていると、必ず転びます。エラー処理の正体は、次の一手を決めるための分類器です。
そして分類器が答えるべき問いは、たった3つしかありません。
問1. 待てば直りますか? → 一時的 / 恒久的 / 不明
問2. もう一度やって安全ですか? → 副作用が起きたか、冪等か
問3. これは本当に「失敗」ですか? → 空・0件・無反応は、失敗とは限らない
この3問に答えられない形でエラーが手元に届いたとき、コードは必ずどちらかに倒れます。「全部失敗扱い」にしてデータを捨てるか、「全部正常扱い」にして壊れたまま走り続けるか。どちらも、エラーメッセージは1行も出ません。
そして分類器が壊れる場所は、次の4層に整理できます。
第1層・分類:何を根拠に分けているか(文字列の一致か、構造化されたコードか)
第2層・伝達:本当の原因が、判断する場所まで届いているか(ラッパー、/dev/null、返り値の形)
第3層・対処:分類に対して、正しい行動をとっているか(リトライ、スキップ、記録)
第4層・非常口:フォールバックとエラーハンドラ自体が、本当に動くか
上の層が壊れていると、下の層をどれだけ丁寧に書いても無駄になります。順番に見ていきます。
【無料公開・罠1】429 を「終わり」に分類したせいで、バッチの後半だけが毎回消えていた
外部サービスに12件まとめて生成を依頼する処理を作りました。結果はこうです。
1〜10件目 : 成功(30.7秒。1件あたり2.6秒)
11・12件目: HTTP 429
コードはこの 429 を「終端エラー(もう無理)」として扱っていました。だから11件目と12件目は、そのまま捨てられていました。長いバッチを流すたびに、後半だけが静かに欠ける。 気づくのに時間がかかったのは、前半が毎回ちゃんと成功していたからです。
調べたら、このサービスの 429 はこうでした。
同時に処理できる件数の上限が10件
11件目以降は「先の生成が終わるまで待ってください」というメッセージ付きで429
2分ほど後に再送したら、両方とも普通に成功した
アクセス禁止でも、1日の枠切れでも、不正検知でもない。「今は満員なので、少ししたらまた来て」という順番待ちの案内でした。
🔰 用語メモ:レート制限/同時実行数の上限/一時的・恒久的・不明 レート制限=「一定時間に何回まで」という回数の制限。同時実行数の上限=「同時に何件走らせてよいか」という並列度の制限。同じ 429 でも、前者は時間が経てば回復、後者は先の処理が終われば回復します。 そして分類は3つ必要です。一時的(待てば直る)、恒久的(待っても直らない)、そして不明(知らないコード)。2択にすると、知らないエラーが必ずどちらかに丸め込まれます。

再試行してよいかは、文面ではなく「副作用」で決まる
「これは再試行してよいエラーか?」を判断するとき、いちばん確実な材料はメッセージの文面ではありません。外の世界の状態が変わったかどうかです。
今回はサービス側の残り回数を確認しました。
実行前: 9710
実行後: 9610 ← ちょうど成功した10件分だけ減っている
429で弾かれた2件は、1回分も消費していない。 つまり再送しても二重に請求されない。これで再試行の安全性が確定します。
逆に、もし失敗したのに消費だけされていたら、そのまま再送するのは危険です。その場合は「受付番号を保存しておいて、投げ直すのではなく結果を問い合わせる」という設計に変える必要があります。
🔰 用語メモ:冪等(べきとう)/副作用 冪等=同じ操作を何回やっても、結果が1回やったときと同じになる性質。副作用=処理が外の世界に残す変化(お金が減る、データが作られる、メールが飛ぶ)。 再試行してよいか=副作用が発生していないか、または冪等か。エラーメッセージの文面ではなく、この2つで判断します。残り回数やレコード件数を実行前後で1回測るだけで、だいたい決着します。

最後にもう1つ、この件で明確に決めたルールがあります。429 が返ってきたときに、別の手段(画面を自動操作する経路)へ切り替えない。 上限に達しているサービスは、画面から操作しても同じように拒否するからです。フォールバックは、原因が「経路の問題」のときだけ意味がある。 原因が「相手の状態」なら、待つのが唯一の正解です。
【無料公開・罠2】「知らないエラー」が、そのまま「正常」として通過していた
もう1つ、無料枠のアカウントを自動でローテーションするCLIツールでの話です。クォータ(利用上限)の検出は、ハードコードされた正規表現リストに頼っていました。
QUOTA_PATTERNS = [
r"RESOURCE_EXHAUSTED",
r"MODEL_CAPACITY_EXHAUSTED",
r"\b429\b", # HTTP 429 = Too Many Requests
]
ところが実際に返ってきた無料枠エラーは、こうでした。
Individual quota reached. You have used up your free credits.
This account is not eligible for the free tier.
どのパターンにもマッチしません。 結果、本当のクォータ到達が「非クォータエラー」に誤分類され、ローテーションが発火しませんでした。
さらに悪いことに、非クォータエラーを記録する関数はアカウントのステータスを触らない実装でした。「一時的なネットワークエラーならログだけ残して再試行すればいい」という善意の設計です。そこに本物のクォータエラーが落ちてくると、こうなります。
ログには「非クォータエラー」と残る
アカウントは healthy のまま
次のリクエストでも同じアカウントが再利用される
何度要求しても、同じエラーが繰り返される
最初のアカウントでリクエストが止まっているのに、システム全体は「全員正常」と報告し続ける。 これが「知らないエラーを正常扱いにする」ことの正体です。
緊急の直し方は「実際の文言をパターンに追加する」ですが、これは対症療法です。別の文言、別のサービス、多言語化——どれか1つで、また見逃します。構造的な解決は2つあります。
ステータスコードやエラー型など、構造化されたシグナルで判定する(HTTP 429、error.code = "quota_exceeded" など)
「認識できないエラー」を「正常」ではなく「要注意」として扱う(未知は degraded に落として、人が見るまで回さない)
「想定した失敗」をカバーするだけでは、エラー処理は完成しません。「知らない失敗」をどう扱うかを決めて、初めて堅牢になります。
この2つだけでも、知らなければ何日か溶かす話でした。ですが、分類器が壊れる場所は「分け方」だけではありません。 本当の原因が判断する場所まで届いていない、分類は正しいのに対処が間違っている、そして最後の非常口そのものが死んでいる——同じ形の事故が、この先に9つ待っています。
ここから先は、4層 × 全11罠の完全プレイブックです。「そのエラー、待てば直るの?」を一瞬で疑えるようになる地図として書きました。
ここから先:全11罠の完全プレイブック
第1層:分類 — 何を根拠に分けているか
罠3:UI文言の完全一致で判定したら、誤ったフェイルオーバーが永久に走り続けた
Webアプリを自動操作するツールで、新しいデータを作ろうとするたびに失敗する現象がありました。ログには「タイムアウト」と記録され、一時的な不調と判断してアカウントを切り替える処理(フェイルオーバー)が自動で走ります。しかし切り替えても切り替えても、また同じように失敗する。
原因は、「新規作成の上限に達しました」というエラー状態を検知するコードが、画面に表示される特定の2つの文言と、一字一句完全に一致するかどうかだけで判定していたことでした。アカウントは本当に上限(500件)に達していただけです。しかし表示の言い回しが少しでも違うと、何にもマッチしない。
そして判定漏れは create_timeout という別のエラー種別として扱われました。 ここが最悪です。「分からない」が「一時的な不調」に丸め込まれたせいで、間違った対処が自動で走り続けた。本当に必要だったのは「古いデータを1件消してから作り直す」という、まったく別の対処でした。
修正は3段構えにしました。
完全一致ではなくキーワード一致にする(「上限」「制限」「limit」「maximum」を含めば検知)
表示テキストだけでなく、ボタンの状態(無効化されているか)も見る——「新規作成」ボタンが押せない状態なら、文言に関係なく上限だと判定できる
どちらにも当てはまらない失敗は、タイムアウトと混ぜず、専用の「判定不能」種別として隔離する
「分からない」を既存のエラー種別に丸めると、間違った対処ロジックを引き寄せます。 未知には未知用の箱を用意する——罠2と同じ結論に、まったく違う現場からたどり着きました。
罠4:「結果が空」を失敗判定に使ったら、フォールバックが毎回いちばん高い経路を叩いた
画像生成のフォールバックチェーンを組んでいました。1段目は定額サブスクのサービス、それが駄目なら2段目は1枚ごとに課金されるAPI。うまくいけば従量課金が減る、お財布に優しい設計……のはずでした。
実際は逆でした。1段目がほとんど毎回「空振り」判定になり、全部が有料の2段目に流れていた。 しかも1枚あたり4秒〜10分の待ち時間が丸ごと上乗せされた上で、です。犯人はたった1行でした。
if (result.images.length === 0) {
// 画像が無い → 次の手段へ
}
落ち着いて考えると、images.length === 0 になる場面は1つではありません。
そもそも画像を頼んでいない。 要約を頼んだだけなら0枚で当たり前。これは正常
頼んだけれど、まだ出来上がっていない。 生成が非同期で走っていて、結果が返ってきていない。これは異常、少なくとも「未完了」
この2つが、呼び出し側からはまったく同じ顔をしていました。1番目なら素通りが正解、2番目なら有料経路に落としてはいけない(もう一度、定額のほうで粘るべき)。真逆の対応が必要なのに、区別する材料が無かったわけです。
さらに手前にも問題がありました。サービス側が画像をまだ作っている最中なのに「完了」と表示していたのです。
生成中:状態フラグが 5、中身のリストは空っぽ
完了後:状態フラグが null になり、画像の置き場所を指すポインタが入る
5 → null の切り替わりは、画面上の「完了」から約25秒後でした。画面の完了サインを信じて読みに行くと、まだ何も入っていない箱を開けていることになる。正しい完了シグナルを2秒間隔で最大3分待つように直すと、以前は841秒粘って失敗していたケースが、43秒で取得成功に変わりました。
そして本題は、3分待って駄目だったときに何を返すかです。空配列を返したら振り出しに戻ります。そこで返り値を「結果」だけでなく「診断」も含む形にしました。
imageCapture: {
settle: 'not_needed' | 'settled' | 'timed_out',
candidates: 0, // 完了後の中身に見つかった画像の数
captured: 0, // 実際にダウンロードできた数
failed: 0 // ダウンロードに失敗した数
}
呼び出し側の条件は、こう書き換わります。
captured === 0 && settle === 'not_needed' → 本当に画像は不要だった。 素通りしてよい
settle === 'timed_out' または failed > 0 → 何かが壊れている。 有料経路に落とさない
🔰 用語メモ:トライステート(3つの状態を持たせる)/診断つき返り値 「ある/ない」の2択ではなく、「成功/失敗/そもそも不要」の3つを区別できるようにすること。「ない」を1種類しか表現できないと、「無くて正常」と「無くて異常」が同じ値に潰れます。 診断つき返り値=結果の値だけでなく、「どう終わったか」(settled / timed_out / not_needed)と件数を一緒に返す形。呼び出し側が推測しなくて済むようになります。

「結果が空」は結果であって、診断ではありません。 フォールバックの発火条件は「結果が無い」ではなく、「正常に終わって、その上で結果が無い」にする。失敗の判定を間違えると、エラーではなく請求書として跳ね返ってきます。
第2層:伝達 — 本当の原因が、判断する場所まで届いているか
分類のルールが正しくても、判断材料が途中で消えていたら意味がありません。ここが第2層です。
罠5:ORMの汎用ラッパーが、本当の犯人を包み隠していた
ある日、チャットに自動アラートが飛んできました。
Failed query: ... params: ...
それだけ。読んでも、何が起きたのか誰にも分かりません。この文言は、Drizzle(TypeScriptのORM)自身が用意している汎用のエラー包み紙でした。
問題は、ログ・アラート用のコードが error.message——一番外側の、当たり障りのない包み紙の文言だけを記録していたことです。ラッパーの中には、接続ライブラリが吐いた本当のエラーが .cause や .stack にちゃんと入っていたのに、誰もそこを見ていませんでした。
手がかりを追うと、犯人はコネクションプールの一時的な枯渇でした。問題のクエリは複数ページ共通のドロップダウン読み込み処理から呼ばれていて、そのうち1ページは開くたびに3つ以上のクエリを同時並行で投げます。設定ファイルには「1画面が複数クエリを同時に投げるとプールが埋まる」という理由で最大接続数を10から30に引き上げた、という過去の調整メモまで残っていました。
プールが埋まっている間、新しいクエリは空きを待ちます。connectionTimeoutMillis を過ぎると諦めてエラーになり、Drizzleがそれを汎用ラッパーに包んで投げ、ログには包み紙の文言だけが残る——これがアラートの正体でした。
なおこの現象はその後2週間、一度も再発しませんでした。単発の混雑が偶然重なっただけと判断できたので、プール設定はいじらず、原因を突き止めて記録に残すところまでで対応を終えています。設定をむやみに動かさずに済んだのは、.cause までたどれたからです。
罠6:2>/dev/null が、永続エラーを「よくある一時エラー」に偽装していた
文字起こしを要約するスクリプトがありました。失敗するとcronによって毎分自動リトライされる、一見堅牢な設計です。
気づいたら、処理が21件も滞留していました。しかも数週間。原因はこれです。
result=$(call_backend "$input" 2>/dev/null)
if [ -z "$result" ]; then
echo "backend call failed, will retry" >> "$LOGFILE"
fi
バックエンドが返していたのは HTTP 429 —— ただし「利用枠そのものが尽きていて、プラン変更をしない限り絶対に直らない」種類の429でした。コード上は同じ429でも、中身は永続的エラーです。
2>/dev/null によって、「429であること」「利用枠の上限であること」が完全に消えていました。ログに残るのは backend call failed, will retry の一文だけ。これは一時的な瞬断でも、致命的な課金上限でも、まったく同じ見た目になります。
毎分のリトライは、瞬断には有効な対処です。しかし待っても直らないエラーに対しては、同じ失敗を無限に繰り返すだけの空回りでしかありません。いちばん怖いのは「動いていないことは分かるが、なぜ動いていないか分からない」状態が数週間放置されたことです。
直し方は、標準エラー出力を捨てずにログへ運ぶことでした。
if ! result=$(call_backend "$input" 2>&1 >/dev/null); then
echo "backend call failed: $result" >> "$LOGFILE"
fi
これだけで、次に同じことが起きたときは「429」「利用枠の上限」という具体的な文字列が残り、一目で永続的エラーと分かります。あわせて、バックエンド呼び出しにタイムアウトも追加しました。応答が返らずハングすると、cronの flock を握ったまま止まり、次以降の実行も全部ブロックされるからです。
🔰 用語メモ:例外のラッピングと .cause /標準エラー出力(stderr) ラッピング=ライブラリが元のエラーを自分専用の入れ物に包み直して投げること。外側のメッセージはたいてい「クエリに失敗しました」程度の一般論で、本当の原因は .cause や .stack の中にいます。 stderr=プログラムがエラーメッセージを出すための、通常の出力とは別の出力先。2>/dev/null と書くとその中身は永久に失われます。ログに残すなら 2>&1 で拾い直す。捨ててよいのは、読んだあとだけです。

第3層:対処 — 分類は正しいのに、行動が間違っている
罠7:300件中1件の失敗で全部止まり、直したら今度は二度と再実行されなくなった
300件のタグ付与バッチで、1件が非ゼロ終了を返した瞬間、例外が呼び出し元まで伝播してパス全体がキルされ、62件で止まりました。残り238件は手つかずです。「1件の失敗」が「プロセス全体の失敗」として扱われていたからです。
第一感の解決はこれです。
for item in items:
try:
result = process(item)
except Exception:
continue # 失敗したら飛ばして次へ
一見正解ですが、ここに2つ目の罠があります。飛ばした行に「空」を書き込むと、それが「処理済み」と見なされて再訪キューに戻らない。レート制限やネットワーク断のような一時的失敗が、永続的な未処理に化けます。しかも今度はエラーすら出ません。
正しい形は、失敗時は「未処理」の状態を維持したままスキップすることです。
skipped_count = 0
for item in items:
try:
result = process(item)
# 成功した場合のみ「処理済み」を書き込む
mark_as_processed(item)
except Exception as e:
# 失敗したら何も書かずにログだけ取る
log_error(item, e)
skipped_count += 1
continue
print(f"Processed: {len(items) - skipped_count}, Skipped: {skipped_count}")
バッチの頑健性は、「1件の失敗で止まらない」と「スキップしたものが後で再実行できる」の両方を満たして初めて成立します。片方だけだと、止まらない代わりに静かにデータが欠けます。
🔰 用語メモ:再訪キュー/「空書き=完了扱い」 再訪キュー=「まだ処理していない」「失敗したのでやり直す」対象を溜めておく待ち行列。DBの列でも、ファイルの有無でも、実体は何でもかまいません。 危険なのは、失敗したときに空の値を書き込んでしまうこと。次に走ったコードは「値が入っている=処理済み」と読むので、その行は二度と再訪されません。書き込みは「成功した証拠」であって、「試した証拠」にしてはいけません。

罠8:「成功」を返しながら、ほぼ0バイトを出力していた
文書コーパスからテキストを抽出するツールで、「成功しているのに中身が空っぽ」という現象に遭いました。Rust製の抽出ツールは大半のPDF/EPUBを処理してくれますが、隙間がありました。.azw / .azw3 / .mobi をエラーも出力ファイルも無く黙ってスキップし、一部PDFでパニックし、有効なEPUBを拒否する。
最悪だったのは画像のみのスキャンEPUBです。各ページがJPEG1枚で、HTMLは <img> ラッパーだけ。抽出ツールもcalibreも、ほぼ0バイトを出力しながら「成功」を返していました。そしてサマリーの Errors: N は、黙ってスキップされたフォーマットをエラーに数えないので、過少報告します。
検出方法はシンプルでした。EPUBをunzipして画像数と全HTMLバイト数を比較する。画像約75枚に対してHTMLが80KB未満なら「スキャン本」だと分かります。
対処は順序適用の3段フォールバックです。
Rust抽出をまず試す
calibre ebook-convert(拒否・スキップされたフォーマットを回復。正しい拡張子が必要)
tesseract OCR(画像のみの書籍用)
そして第3段が必要かどうかの唯一の信号は、「不自然に小さい .txt 出力」でした。ここが本質です。入力と出力を突き合わせて、異常な成功を失敗として扱う。 罠4の「空は診断ではない」と同じ構造が、ファイル出力側で顔を出しています。
第4層:非常口 — フォールバックとハンドラ自体が、本当に動くか
ここまでの3層を全部直しても、まだ落とし穴があります。いちばん最後に頼る道が、そもそも死んでいる場合です。
罠9:フォールバック先の候補が、2つとも実在しない名前だった
あるパイプラインには、LLMを呼び出す処理が3段階に分かれて入っていました。どのモデルをどう呼ぶかを決める「モデル解決」という共通処理があり、そこには一見しっかりしたフォールバックが書かれていました。「メインのラッパースクリプト名で駄目なら、別の名前を試す」という2段構えです。
後で調べたら、恐ろしい事実が分かりました。この3段階は、実際に動かしていたらどれも一発目から「コマンドが見つかりません」で即死する状態でした。1つ目のラッパー名はこの環境にはインストールされていない別環境向けの名前で、そして2つ目のフォールバック先も、そもそもどこにも存在しない名前だったのです。
誰かが「念のため」と書き足したのであろうこの2段構えは、「失敗する方法その1」から「失敗する方法その2」へ切り替わるだけの、二重の失敗でした。コードを読む限りでは、防御的で頑丈に見えます。レビューでも「フォールバックがあって安心ですね」と言われかねない見た目でした。
ここから2つの教訓が出ます。
フォールバックは、少なくとも1つの分岐が「実際にこの環境に存在する」と確認されて初めて安全網になる。 確認されていないフォールバックは、安全網の顔をしたもう1つの失敗パターンです
防御的に見えるコードほど、人を安心させて確認を怠らせる。 フォールバックは正常系ではまず通らない道なので、平時にいちばん実行されず、壊れていても気づかれません
直し方は、分岐を増やすことではなく、実在するパスを直接調べて確実に解決させることでした。そして「たぶんこの名前で動くはず」で終わらせず、実行可能な状態で存在するかを推測ではなく確認してから組み込みました。分岐を意図的に強制通過させるテストを書いて、初めて安心できるコードになります。
罠10:復旧処理が、たった1つの「証拠」フィールドを運び忘れていた
ブラウザ自動操作のパイプラインに、「セレクタが変わってブラウザが迷子になったとき、状態を立て直して結果を回収する」復旧処理がありました。その修正のためにリグレッションテストを書いたら、思わぬ形で落ちました。
def test_recovery_preserves_cost():
# セレクタが変わった状況を再現し、復旧処理を発動
row = recover_stalled_generation(job_id)
assert row.status == "generated" # ← 通る
assert row.result_url is not None # ← 通る
assert row.cost > 0 # ← 落ちる! cost == 0
status も result_url も正しい。なのに cost だけが0。サーバーログには課金が発生した記録が残っているのに、DBの値はゼロでした。
原因は「復旧関数がコストフィールドだけ渡し忘れていた」——バグとしては地味です。しかしこのフィールドには特別な意味がありました。「その生成が本当に行われたのか、一度も行われなかったのか」を見分けるために、別の検証ロジックが唯一の手がかりとして使っていた値だったのです。
つまり復旧処理は、検査官が「一度も起きていない怪しいレコード」を弾くために置いたフィルタに対して、まさに引っかけるべきあいまいなレコードを自ら量産していたことになります。良かれと思って動いた非常口が、偽物を作っていたわけです。
def recover_stalled_generation(job_id):
log_entry = fetch_server_log(job_id)
return update_record(
job_id,
status="generated",
result_url=log_entry.result_url,
cost=log_entry.charged_amount, # ← 抜けていた1行
)
すでに影響を受けた2件は再測定できない(生成は終わっている)ので、サーバーログの課金額を転記し、「これは実測ではなくログから復元した値である」と明記してバックフィルしました。
ここで効く観点は、冪等性そのものではありません。「復旧した後の状態が、正常系が作る状態とフィールド単位で同じ形をしているか」です。冪等でも、フィールドが1つ欠けていれば形は一致しません。非常口は、本線と同じ形のものを出力して初めて非常口です。
罠11:エラーハンドラ自身が、別の種類のエラーでクラッシュする
最後は、非常口の中でもいちばん内側の話です。セッション切れを検知して安全に終了する処理を書いたら、静的型チェッカーの Pyright に Variable "response" is possibly unbound と警告されました。
try:
response = session.post(url, data=payload)
response.raise_for_status()
except SessionExpiredError:
print("セッションが切れました。再ログインしてください。")
return
except Exception as e:
# ここでresponseのステータスコードをログに出そうとした
print(f"エラー発生: {e}, ステータス: {response.status_code}")
raise
response は try の中で代入されています。もし session.post(...) 自体が失敗したら——ネットワークエラーなど——代入は一切行われません。つまり失敗パスは2種類あります。
失敗の種類 response の状態 session.post(...) 自体が例外を投げた 未代入(存在しない) response.raise_for_status() が例外を投げた 代入済み(存在する)
汎用の except Exception は両方をまとめて受け取るので、前者で response.status_code を参照すると NameError が飛びます。セッション切れを安全に処理しようとしたコードが、別の種類のエラーが起きたときに自分で死ぬ。 しかも通常のリクエストは成功するか raise_for_status() で弾かれるかなので、response はたいてい代入済みです。ネットワーク障害が起きたときだけ、本番で一番焦るタイミングに限って壊れます。
直し方は単純で、response を try の外で None に初期化し、ハンドラで None チェックを入れるだけです。大事なのは、この種のバグは実行中にはほぼ踏まないので、静的解析がないと見つからないということ。エラーハンドラは「めったに通らない道」なので、テストでもカバー率が最後まで残ります。型チェッカーの possibly unbound を黙らせる前に、そのハンドラが本当に通れるか一度考えてください。
🔰 用語メモ:未束縛変数(unbound variable)/コードパスの網羅 未束縛変数=まだ値が代入されていない変数。Pythonでは代入前に参照すると NameError が発生します。「名前は書いたが、中身がまだない」状態です。 コードパスの網羅=if や try/except の組み合わせで生まれる全経路を漏れなく考えること。人間は「よくある経路」を目で追いがちで、稀なパスを見落とします。Pyrightのような静的解析はすべてのパスを機械的に辿るので、人間が見落とした穴を実行前に見つけられます。

エラー処理を書き終える前に通す、5つの検算
11個の罠を踏まないための、実行可能なチェックリストです。
1. その分類、構造化シグナルで書けていますか? エラーメッセージの文字列一致で分類を書くと、相手の文面変更・多言語化・UI改修で静かに壊れます。使うのは HTTP ステータス、error.code のような構造化フィールド、あるいは「ボタンが無効化されているか」のような状態。文面はいつでも変わりますが、意味は比較的安定します。
2. 「不明」の箱を用意しましたか? 一時的/恒久的の2択にすると、知らないエラーは必ずどちらかに丸め込まれます。恒久扱いに丸めればデータが消え、一時扱いに丸めれば永久に空回りします。 未知は未知として隔離し、degraded にして人が見るまで回さない。罠2・罠3は、この箱があれば両方防げました。
3. 再試行の可否を、副作用で判断していますか? 文面ではなく「外の世界が変わったか」。残り回数、レコード件数、課金額——実行前後で1回測れば決着します。測れないなら、投げ直すのではなく受付番号で結果を問い合わせる設計に変える。
4. 「空」「0件」「無反応」を、失敗の証拠にしていませんか? 空になる理由は複数あり、必要な対応は正反対です。返り値には結果と一緒に診断を積む(settled / timed_out / not_needed と、試行・成功・失敗の件数)。同じく、不自然に小さい成功は失敗として扱う。入力と出力を突き合わせてください。
5. その非常口、一度でも意図的に通しましたか? フォールバック先が実在するか。復旧経路が本線とフィールド単位で同じ形を出力するか。エラーハンドラ自身が全コードパスで生き残るか。普段通らない道は、壊れていても誰も気づきません。 強制的に発火させるテストを書くまで、それは安全網ではありません。
まとめ:11の罠を貫く、3つの問い
11個の罠は、Python・シェル・TypeScript・ブラウザ自動化と、技術も現場も全部バラバラでした。でも、次の3つの問いを持っておくだけで、踏む前に気づけます。
問1:そのエラー、待てば直りますか? 同じ 429 でも、順番待ちなら待てば直り、課金上限なら永久に直りません。同じ「タイムアウト」でも、瞬断なら再試行が効き、上限到達なら何回やっても同じです。「待てば直るか」を答えられない分類は、分類ではありません。 そして答えは、文面ではなく構造化シグナルから読む。
問2:その判断材料、ちゃんと手元まで届いていますか? .cause に包まれた本当の原因、2>/dev/null に消えた429、空配列に潰れた「未完了」。分類器がどれだけ賢くても、材料が途中で失われていたら何も分けられません。 アラートが鳴っているのに原因が分からないなら、監視ではなくエラーの運び方を疑ってください。
問3:その非常口、本当に通れますか? 存在しない名前へのフォールバック、証拠フィールドを落とす復旧処理、自分で NameError を出すハンドラ。非常口は正常系では通らないからこそ、壊れたまま何ヶ月でも「安心」の顔をして居座ります。 書いたら一度、意図的に火をつけて通してみる。
エラー処理のコードは、書いた量ではなく、正しい問いに答えているかで効きます。 「1件失敗したので終了しました」というログを見たら、今日のうちに一度だけ確かめてみてください——それは本当に終わりだったのか、待てば済んだのか。 バッチの後半が毎回消えている、という気持ち悪い事故は、だいたいここから始まります。
