- Authors

- Name
- Youngju Kim
- @fjvbn20031
プロローグ — エラーハンドリングは設計だ
ほとんどのコードレビューで、エラーハンドリングは最後の5分に扱われる。「ここに try/catch が抜けてますね」「このエラーにログを足しましょう」 — 機能を作り終えた後に付け足す装飾のように。
しかしプロダクションで壊れるシステムを1時間も眺めれば気づく。障害の原因はほぼ常に「誤って処理された失敗」であって、「処理されなかった成功」ではない。 ハッピーパスはどのみちちゃんと動く。システムを倒すのは、timeoutのない呼び出し、無限にretryするループ、半分だけ適用されたトランザクション、ユーザーに undefined をそのまま吐き出すエラーメッセージだ。
エラーハンドリングは機能を作り終えた後に付け足すものではない。エラーハンドリングこそが設計だ。 関数のシグネチャ、モジュールの境界、呼び出しグラフの形 — すべては「これが失敗したらどうなるか」という問いから生まれる。
この記事はコードレベルでうまく失敗するソフトウェアを設計する方法を扱う。カオスエンジニアリング(インフラをわざと壊して検証すること)は別の記事のテーマだ。ここでは関数1つ、クラス1つ、呼び出し1つをどう書けばシステム全体が優雅に失敗するかを見る。
扱う順序: 失敗の分類 → 例外とエラー値 → Result型 → 境界の原則 → timeout → retryとbackoff → idempotency → circuit breakerとbulkhead → 優雅な性能低下 → エラーメッセージと可観測性。コードはTypeScript、Go、Pythonを混ぜて使う — 言語ではなくパターンが核心だからだ。
1章 · 失敗にも種類がある
すべての失敗を同じに扱うコードは、すべての失敗を誤って扱う。処理戦略を決めるには、まず分類しなければならない。3つの軸がある。
軸1 — 予想された失敗と予想しなかった失敗
予想された失敗は正常な動作の一部だ。ユーザーが存在しないIDで照会すれば「404」、残高が足りなければ「決済拒否」。これはバグではなくドメインの一部だ。制御フローで扱うべきで、例外として投げてはいけない。
予想しなかった失敗は前提が壊れたものだ。nullであってはならない値がnullだったり、絶対に到達してはならない分岐に到達したり。これはバグであり、速く・大きく失敗して(fail fast)開発者に知らせるべきだ。
軸2 — 一時的な失敗と恒久的な失敗
一時的(transient)な失敗はもう一度試せば成功しうる。瞬間的なネットワーク断、DBコネクションプールの枯渇、429 Too Many Requests、503 Service Unavailable。retryに意味がある。
恒久的(permanent)な失敗は百回試しても同じように失敗する。400 Bad Request、401 Unauthorized、404 Not Found、422 Unprocessable Entity。retryは負荷を増やすだけだ。
軸3 — 回復可能な失敗と致命的な失敗
回復可能な失敗は、このリクエスト1つは失敗するがプロセスは生き続けてよい。外部API呼び出しの失敗 → このリクエストだけがエラー応答。
致命的(fatal)な失敗はプロセスの不変条件が壊れたものだ。メモリ破損、設定ファイルのパース失敗(起動時)、閉じたチャネルへの書き込み。このときはむしろクラッシュして再起動するほうが安全だ。ゾンビプロセスよりきれいな再起動のほうがいい。
分類表
| 分類 | 例 | 戦略 |
|---|---|---|
| 予想 + 恒久 | 残高不足、検証失敗 | ドメインエラー値として返す、ユーザーに説明 |
| 予想 + 一時 | 429、503、ロック競合 | backoffでretry、限界到達で諦める |
| 非予想 + 回復可能 | 外部APIの未知の 500 | ログ + このリクエストだけ失敗、アラート |
| 非予想 + 致命 | 設定破損、不変条件違反 | fail fast、クラッシュ、再起動 |
この分類が頭になければ2つのアンチパターンが出る。(1) すべてをretryして恒久的な失敗を無限に繰り返すか、(2) すべてを同じように飲み込んで致命的なバグが静かに埋もれるか。
2章 · 例外とエラー値
失敗をどう表現するか。2つの陣営がある。
例外(exception): 失敗したら投げ、その投げが呼び出しスタックを遡り、誰かが捕まえる。Java、Python、C#、JavaScriptのデフォルトモデル。
値としてのエラー(error as value): 失敗を普通の戻り値として表現する。関数が「結果またはエラー」を返し、呼び出し側が明示的に確認する。GoとRustのモデル。
例外の問題
例外の最大の問題はシグネチャに見えないことだ。
function getUser(id: string): User {
// この関数は投げうるか? シグネチャからは分からない。
// 投げるなら何を? どこで捕まえるべきか?
}
型は User と言うが、実際には「User、またはどこかで投げられる何か」だ。呼び出し側は何を捕まえるべきか分からず、だから2つのうち1つをする。何も捕まえないか(クラッシュ)、すべてを捕まえるか(catch (e) {} — すべてを飲み込む)。
加えて例外は制御フローを非局所的にする。 throw は事実上「ここから未知のどこかへ行くgoto」だ。コードを読むとき、正常フローとエラーフローが分離して見えない。
エラー値の問題
エラー値は正直だが冗長だ。 Goコードのあの有名な風景:
user, err := getUser(id)
if err != nil {
return nil, err
}
account, err := getAccount(user.ID)
if err != nil {
return nil, err
}
balance, err := getBalance(account.ID)
if err != nil {
return nil, err
}
3行ごとに if err != nil。そして忘れたら? Goは戻り値を無視してもコンパイルされる(linterで止めなければならない)。
よい折衷: 両方を混ぜるがルールを置く
実用的な答えは「1つだけ使う」ではなく、それぞれを適した用途に使うことだ。
- 予想されたドメインの失敗 → エラー値・型として。呼び出し側が必ず扱わねばならないので、シグネチャに現れるべきだ。
- 予想しなかったバグ → 例外として(あるいはpanic)。どのみち回復不能なので、スタックを遡って最上段で捕まえられ、ログされ、クラッシュすればよい。
JavaScript/TypeScriptなら: ドメインの失敗は判別可能なユニオン(discriminated union)で返し、本物の例外的状況だけ throw する。Pythonなら: ドメインの失敗は明示的な結果オブジェクトか狭いカスタム例外階層で、システムの失敗は広く捕まえるハンドラを最上段に置く。
3章 · Result型 — 失敗を型にする
エラー値陣営の最も洗練された形がResult型だ。「成功値またはエラー値」のどちらか一方であることを型システムが強制する。
RustのResult
enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_port(s: &str) -> Result<u16, ParseError> {
s.parse().map_err(|_| ParseError::InvalidPort)
}
Rustでは Result を単純に無視できない。中の値を取り出すには、必ず Ok か Err かを確認するコードを通らなければならない。? 演算子が「エラーなら早期return」のボイラープレートを1文字に縮める。
Goの複数戻り値
Goは型レベルの直和(sum type)がないので慣習で解く — 関数が (value, error) を返し、呼び出し側が err を確認する。直和型ほどの強制力はない(無視してもコンパイルされる)が、文化とlinterが埋める。Go 1.13以降の errors.Is / errors.As がエラーのラップと検査を標準化した。
if errors.Is(err, sql.ErrNoRows) {
// 「ない」は予想された失敗 — ドメインフローへ
return defaultUser(), nil
}
TypeScriptの判別可能なユニオン
TypeScriptには組み込みの Result はないが、自分で作ればよい。
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E }
function parsePort(s: string): Result<number, "invalid_port"> {
const n = Number(s)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
return { ok: false, error: "invalid_port" }
}
return { ok: true, value: n }
}
const r = parsePort(input)
if (!r.ok) {
// コンパイラがここで r.value へのアクセスを止める
return reject(r.error)
}
use(r.value) // ここでは r.value は安全だ
Result型の価値 — そしてコスト
価値は明確だ。失敗がシグネチャに現れ、コンパイラが「このエラー処理した?」と問う。忘れようがない。
コストも正直に見よう。(1) ボイラープレート — ? のような糖衣構文が言語になければ冗長だ。(2) 伝染性 — Result を返す関数を呼ぶ関数も通常 Result を返さねばならない。(3) 本物のバグ(null参照、配列範囲外)には合わない — それは例外・panicの領域だ。
ルール: Result型は予想されたドメインの失敗に使う。「バグ」をResultで表現しようとするな。1章の分類に戻れ — 予想された失敗は型で、予想しなかったバグはfail fastで。
4章 · 境界の原則 — 端で検証し、コアを信頼する
最も強力なレジリエンスパターンはライブラリではなく、アーキテクチャのルール1つだ。
入力はシステムの境界で検証せよ。いったん中に入ったデータはコア全体で信頼せよ。
境界とは信頼できないデータがシステムに入る地点だ — HTTPハンドラ、メッセージキューのコンシューマ、CLI引数のパーサ、外部API応答を受ける場所、DBから読んだ行。この地点でちょうど1回厳格に検証する。通過したデータはよく定義された型に変えて中へ渡す。
// 境界: HTTPハンドラ。ここで検証する。
function handleCreateOrder(req: Request): Response {
const parsed = OrderSchema.safeParse(req.body) // zod など
if (!parsed.success) {
return badRequest(parsed.error) // 境界で拒否
}
// parsed.data はもう検証済みの Order 型。
return createOrder(parsed.data) // コアへ渡す
}
// コア: 検証を再び行わない。Order が有効であることを信頼する。
function createOrder(order: Order): Response {
// order.quantity > 0 かをまた確認しない。
// 境界が保証した。ビジネスロジックに集中する。
}
なぜこれが重要か
検証がコア全体に撒き散らされると3つが壊れる。(1) 同じ検証を何度も — どこまでやったか誰も分からない。(2) 一貫性のない検証 — 経路Aは確認し経路Bは抜かす。(3) ビジネスロジックが防御コードに埋もれて読めない。
境界で1回検証すれば、コア関数のシグネチャがそのまま契約になる。createOrder(order: Order) が「有効な Order をくれ」と言えば、それは本物の保証だ — 関数本体が毎回疑う必要がない。
コアの不変条件はassertで守る
コアにも「これは絶対に起きてはならない」という前提はある。それは検証ではなく**表明(assertion)**で表現する。検証は「ユーザーが間違いうる」という前提で、表明は「自分のコードが間違っていたら」という前提だ。表明が壊れたらそれはバグ — fail fastすべきだ。
| 検証 (validation) | 表明 (assertion) | |
|---|---|---|
| 位置 | 境界 | コアのどこでも |
| 前提 | 外部入力は信じられない | 自分のコードの不変条件 |
| 失敗時 | 丁寧なエラー応答 | クラッシュ / panic (バグだ) |
| 対象 | ユーザー・外部システム | 開発者 |
5章 · timeout — すべてのリモート呼び出しに期限が要る
レジリエンスで最も頻繁に抜け落ちるものを1つ挙げるなら: timeout。
プロセス境界を越えるすべての呼び出し — HTTP、DBクエリ、キャッシュ、gRPC、メッセージ発行 — にはtimeoutがなければならない。例外はない。
timeoutがないとどうなるか。ダウンストリームのサービスが遅くなる(死んだのではなく、ただ遅い)。応答を待つスレッド・goroutine・コネクションが積み上がる。プールが枯渇する。今や健全なリクエストもリソースを得られない。1つの遅い依存がサービス全体を止める。 これが障害が連鎖する(cascading failure)最も一般的な経路だ。
デフォルト値はほぼ常に長すぎる
ほとんどのHTTPクライアントはデフォルトのtimeoutがないか(無限待ち)、30秒・60秒のように事実上無限に近い。ユーザー対面のリクエスト内で30秒待つのは「timeoutがない」のとほぼ同じだ — その間リソースは全部掴まれている。
import requests
# 悪い: timeoutなし。永遠に止まりうる。
r = requests.get(url)
# よい: (接続timeout, 読み取りtimeout)
r = requests.get(url, timeout=(1.0, 3.0))
// Go: context で timeout を伝播する
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := httpClient.Do(req.WithContext(ctx))
timeout予算 — 足した値が親を超えてはならない
timeoutは呼び出しグラフ全体で予算として扱うべきだ。ハンドラAの予算が3秒なのに、内部でB(2秒)とC(2秒)を順に呼ぶと合計4秒 — Aはもう終わっている。子のtimeoutの和は親の予算の中に収まらねばならない。context(Go)や AbortSignal(JS)、明示的なデッドライン伝播でこの予算を下へ流す。
timeoutとキャンセルは一対だ
timeoutが発動したのに作業がバックグラウンドで回り続ければ、リソースをそのまま食う。timeoutは必ず実際のキャンセル(context のキャンセル、AbortController.abort()、コネクションの終了)と結ばれねばならない。「待つのをやめる」と「作業を止める」は別物だ — 両方やらねばならない。
6章 · retry、指数backoff、jitter — そしてretryすべきでないとき
一時的な失敗(1章)にはretryが答えだ。ただし正しくやったときだけ。間違ったretryは弱い障害を大きな障害に育てる。
ルール1 — retryしてよいものだけretryせよ
retry OK: 408, 429, 503, 504, 接続拒否, 接続timeout
retry 禁止: 400, 401, 403, 404, 409, 422 (またやっても同じように失敗)
注意: 500 (原因による — デフォルトは保守的に)
400 をretryするのは同じ不正なリクエストを5回送るだけだ。負荷だけ5倍。
ルール2 — 指数backoff
retry間隔を固定するな(毎1秒)。ダウンストリームが過負荷なら、固定間隔のretryはずっと同じ圧力をかける。指数的に間隔を伸ばせ: 1秒、2秒、4秒、8秒。
ルール3 — jitterを入れろ (これが核心だ)
純粋な指数backoffには隠れた罠がある。100個のクライアントが同時に同じ失敗を経験すると、全部1秒後に、また2秒後に、4秒後に — **同期した群れ(thundering herd)**としてretryする。ダウンストリームは回復する隙にまた100個を浴びる。
解法はjitter — ランダム性を混ぜてretryのタイミングを撒き散らす。
function backoffWithJitter(attempt: number): number {
const base = 100 // ms
const cap = 10_000 // 上限10秒
const exp = Math.min(cap, base * 2 ** attempt)
// "full jitter": 0 ~ exp の間の一様乱数
return Math.random() * exp
}
AWSの有名な分析は「full jitter」(0から計算された上限までの一様乱数)が最も安定だと結論づけた。群れが時間軸に均等に広がる。
ルール4 — retry回数と総予算を両方とも制限せよ
「最大5回」だけでは足りない。backoffのせいで5回が30秒を超えうる。retry回数の上限と総時間予算の両方を置く — どちらか一方でも到達したら諦める。
ルール5 — retryを入れ子にするな
最も危険なアンチパターン。AがBを3回retry、BがCを3回retry、CがDを3回retry → Dへの実際の試行は27回。retryはスタックの1つの層でだけやる。普通は最も外側(あるいはクライアントに最も近い)の1か所。
ルール6 — retryとidempotencyは切り離せない
書き込み操作(POST、決済、注文作成)をretryした瞬間、すぐ次の章の問いにぶつかる — そのリクエストがすでに成功していたら? それが7章だ。
7章 · idempotency — 安全に再試行する
idempotent(冪等)な操作は、1回適用しても5回適用しても結果が同じだ。GET は本来冪等で、PUT/DELETE は通常冪等だ。問題はPOST — 「注文作成」「決済」「メッセージ発行」のようなもの。
retryのシナリオを見よう。
クライアント ──POST /payments──▶ サーバー: 決済処理成功 ✅
クライアント ◀────(応答が消失)──── ネットワークが応答を飲み込んだ ❌
クライアント: 「timeoutだ、retryしよう」
クライアント ──POST /payments──▶ サーバー: また決済処理?? 💸💸
クライアントは最初の試行の成否を知れない。 retryすれば二重請求、retryしなければ決済漏れ。両方とも悪い。
解法 — idempotencyキー (idempotency key)
クライアントがリクエストごとに一意なキー(UUIDなど)を作りヘッダに載せる。同じ操作をretryするときは同じキーを使う。サーバーはキーごとに結果を保存する。
最初のリクエスト: Idempotency-Key: abc-123 → キーなし → 実行 → 結果を保存 → 応答
retry: Idempotency-Key: abc-123 → キーあり → 実行しない → 保存された結果を返す
async function createPayment(key: string, body: PaymentBody) {
const existing = await store.get(key)
if (existing) return existing.response // 再生: もう実行しない
// 競合状態に注意: キーを「進行中」として先に確保する
const claimed = await store.claim(key) // 原子的なinsert
if (!claimed) return await store.waitFor(key) // 別のワーカーが処理中
const response = await reallyCharge(body)
await store.complete(key, response) // 結果を永続化
return response
}
idempotencyキーの設計 — ディテールがすべてだ
- キーはクライアントが作る。 サーバーが作るとretryが新しいキーを受け取りidempotencyが壊れる。
- 競合状態を防げ。 2つのretryが同時に到着しうる。キーの確保は原子的でなければならない(
INSERT ... ON CONFLICT、ユニーク制約、分散ロック)。 - リクエストボディをキーに結びつけろ。 同じキー + 異なるボディ = クライアントのバグ。
409で拒否せよ(静かに古い結果を返すな)。 - TTLを決めろ。 キーを永遠に持ち続けることはできない。通常24時間ほどあればretryウィンドウを十分に覆う。
- 部分的な失敗を扱え。 「進行中」でワーカーが死んだら? そのキーは閉じ込められる。timeout後にretry可能にするか、「進行中」状態に失効を置く。
idempotencyはクライアントの責務でもある
サーバーだけの仕事ではない。クライアントがretry全般にわたって同じキーを保持しなければならない。retryループの中でキーを新しく作ればidempotency全体が崩れる。キーはretryループの外で1回作る。
8章 · circuit breaker、bulkhead、fallback — 障害を閉じ込める
timeout・retry・idempotencyは呼び出し1つを扱う。この章のパターンはより大きな絵 — 障害が広がらないように閉じ込めることをする。
circuit breaker
ダウンストリームの依存が確実に死んでいるなら、リクエストごとにtimeoutを待つのは無駄だ。circuit breakerは失敗を追跡し、閾値を超えると回路を開く — それ以降はダウンストリームを呼びもせず即座に失敗させる(fail fast)。死んだサービスを叩くのをやめるので回復する隙を与える。
3つの状態がある。
失敗率が閾値を超える
CLOSED ───────────────────▶ OPEN
▲ │
│ │ クールダウンタイマー失効
│ 試験呼び出し成功 ▼
└──────────────────── HALF_OPEN
試験呼び出し失敗 ──▶ 再び OPEN
CLOSED : 正常。呼び出し通過。失敗を数える。
OPEN : 壊れている。呼び出さない。即座に失敗。クールダウン待ち。
HALF_OPEN : 試験中。呼び出しを数個だけ通して回復したか確認。
class CircuitBreaker {
private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED"
private failures = 0
private openedAt = 0
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "OPEN") {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new CircuitOpenError() // 試しもしない
}
this.state = "HALF_OPEN" // クールダウン終了 — 一度試す
}
try {
const result = await fn()
this.onSuccess() // CLOSED へ復帰、カウンタをリセット
return result
} catch (err) {
this.onFailure() // 閾値を超えたら OPEN へ
throw err
}
}
}
核心の価値: 回路が開くと呼び出し側は30秒ではなく即座に失敗応答を受け取る。その速い失敗がfallback(下記)を発動させる余裕を作る。
bulkhead
船のbulkhead(隔壁)から来た名前だ — ある区画に水が入っても船全体が沈まないように区画を分ける。ソフトウェアではリソースを分離されたプールに分けることだ。
サービスが依存A、B、Cを呼ぶのにコネクションプール1つ(100個)を共有するとしよう。Aが遅くなると100個のコネクションが全部Aを待って掴まれる — BとCは無事なのに呼び出すコネクションがない。A1つの障害がBとCまで引きずり下ろす。
bulkhead: A・B・Cにそれぞれ別のプールを与える(例: 40/30/30)。今やAがプールを食い尽くしてもそれはAの40個だけ — BとCは自分のプールで正常に動作する。障害が1つの区画に閉じ込められる。
fallback
呼び出しが失敗したとき(または回路が開いたとき)、次善の答えを出せるか? それがfallbackだ。
| 第一候補 (失敗) | fallback |
|---|---|
| リアルタイム推薦エンジン | キャッシュされた人気商品リスト |
| ライブ為替レートAPI | 最後に知られたレート + 「遅延」表示 |
| パーソナライズされたホームフィード | 一般的なキュレーションフィード |
| 精密な在庫数量 | 「在庫あり / なし」だけ |
fallbackの鉄則: fallbackは第一候補より単純で依存が少なくなければならない。第一候補が死ぬまさにその理由で一緒に死ぬfallbackはfallbackではない。よいfallbackは通常、キャッシュされたデータ、静的なデフォルト値、または「今は見せられません」という正直な部分応答だ。
9章 · 優雅な性能低下 — やわらかく失敗する
8章のパターンを1つの考え方にまとめると**優雅な性能低下(graceful degradation)**だ。
システムの一部が失敗するとき、0と1の間の何かに落ちられるべきだ。完全動作か完全停止か、2つの状態しかないシステムは脆い。
全か無かは脆い
ECの商品ページを見よう。次を呼ぶ: 商品情報、価格、在庫、レビュー、推薦、パーソナライズバナー。レビューサービスが死んだらどうなるか?
脆い(brittle)設計: ページ全体が 500。ユーザーは買えるはずの商品を買えない — レビューが出なかったという理由で。
レジリエントな(resilient)設計: ページが出る。商品・価格・在庫・購入ボタン — 全部正常。レビューの場所には「レビューを読み込めません」が出る。核心機能(購入)は生きている。 付加機能(レビュー)だけが優雅に抜ける。
機能を核心と付加に分けろ
これをやるには意識的に分類しなければならない。
- 核心(critical): これが死ねばリクエストも死ぬ。商品ページの「商品情報 + 価格」。正直に失敗せよ。
- 付加(enhancement): これが死んでもリクエストは生きねばならない。「推薦 + レビュー」。空の場所、キャッシュ、プレースホルダで代替せよ。
コードの構造がこの分類を反映すべきだ。付加機能の呼び出しは自身のエラーを隔離する — その失敗がハンドラ全体に伝播してはならない。
async function getProductPage(id: string): Promise<ProductPage> {
// 核心: 失敗すれば全体が失敗。正直に。
const [product, price] = await Promise.all([
getProduct(id),
getPrice(id),
])
// 付加: それぞれ隔離。失敗してもページは生きる。
const reviews = await getReviews(id).catch(() => null)
const recs = await getRecommendations(id).catch(() => [])
return {
product,
price,
reviews, // null かもしれない — UI が処理
recommendations: recs, // 空配列かもしれない
degraded: reviews === null, // クライアントに正直に知らせる
}
}
負荷遮断 — 意図的な性能低下
性能低下は部分的な障害にだけ使うものではない。システムが過負荷なら、入ってくる一部を意図的に拒否するほうが全部を遅く殺すよりよい。負荷遮断(load shedding): キューが満杯なら新しいリクエストを速く 503 + Retry-After で拒否する。90%を速く正直に拒否して10%をうまく処理するほうが、100%を受けて全部timeoutを出すよりよい。飽和状態では拒否も機能だ。
10章 · エラーメッセージと可観測性 — 人が行動できるエラー
エラーをうまく扱っても、そのエラーが不透明なら半分しかやっていない。エラーは結局人が読む — デバッグする開発者、画面を見るユーザー。
よいエラーメッセージの3つの聴衆
| 聴衆 | 必要なもの | 悪い例 | よい例 |
|---|---|---|---|
| エンドユーザー | 何をするか | 「Error 500」 | 「決済を処理できませんでした。カードは請求されていません。もう一度お試しください。」 |
| 運用者 / オンコール | どこが壊れたか | 「Something went wrong」 | 「payment-svc → stripe 呼び出しがtimeout (2s)、order_id=789」 |
| 未来の開発者 | なぜ壊れたか | スタックトレースだけ | スタック + 入力コンテキスト + どの不変条件が壊れたか |
3つの聴衆を1つの文字列で満足させることはできない。だからエラーを構造化する。
構造化されたエラー — 文字列ではなくデータ
"user 123 not found in tenant 9" のような文字列を投げるな。検索・集計・ルーティングができない。代わりにフィールドを持つ構造で:
type AppError = {
code: string // 安定・機械可読: "PAYMENT_TIMEOUT"
message: string // 人間用 (開発者)
retryable: boolean // 呼び出し側がretryしてよいか?
context: Record<string, unknown> // order_id, tenant, 試行回数...
cause?: unknown // 元のエラー — チェーンを保存
}
これがあれば: code でメトリクスを集計でき、retryable でretryロジックが分岐でき、context が再現に必要なものを持ち、cause が根本原因までの鎖を保存する。
エラーをラップしつつコンテキストを足せ
エラーがスタックを遡るとき、各層は自分のコンテキストを足しつつ元を保存すべきだ。潰すな、包め。
// 悪い: 元のエラーを捨てる。どこで起きたか永遠に分からない。
if err != nil {
return errors.New("something failed")
}
// よい: コンテキストを足し、原因を %w で保存する
if err != nil {
return fmt.Errorf("charging order %s: %w", orderID, err)
}
// 呼び出し側は errors.Is(err, context.DeadlineExceeded) でまだ検査できる
構造化ロギング — 散文ではなくイベント
log.Error("payment failed for user " + id) のような行は検索・集計ができない。構造化されたキー・値で出力せよ。
logger.error("payment_failed", extra={
"error_code": "PAYMENT_TIMEOUT",
"order_id": order_id,
"downstream": "stripe",
"duration_ms": 2013,
"attempt": 2,
"trace_id": trace_id, # 分散トレーシングとつなぐ
})
これで「過去1時間の error_code=PAYMENT_TIMEOUT を downstream 別に」をクエリできる。trace_id はこのログを分散トレーシングの全リクエストフローに接続する。それが散文ログと可観測なシステムの違いだ。
何を数えるか
エラー処理はエラーメトリクスまで含めて完成だ。最低限これはカウンタで:
- エラー率 —
code別、エンドポイント別 - retry回数 / retry枯渇回数
- circuit breakerの状態遷移 (何回開いたか)
- timeoutの発生 — 呼び出し対象別
- fallbackの発動回数 (付加機能がどれだけ頻繁に抜けるか)
これが見えなければシステムは静かに性能低下する — 誰かが文句を言うまで。よいエラー処理は失敗を目に見えるようにする。
エピローグ — うまく失敗することがうまく設計することだ
最初の命題に戻る。エラーハンドリングは機能を作り終えた後に付け足す装飾ではなく、設計そのものだ。
この記事を貫く1本の糸はこれだ — 失敗を明示的にせよ。 シグネチャで見えるように、型で強制されるように、メトリクスで測られるように、ログで検索できるように。隠れた失敗がシステムを殺す。露わな失敗は扱える。
これらすべてのパターン — 分類、Result型、境界の検証、timeout、backoff、idempotency、circuit breaker、優雅な性能低下 — は同じ1文に圧縮される。
すべての呼び出しについて問え: これが失敗したらどうなるか? そしてその答えをコードに書け — 頭の中ではなく。
14項目のチェックリスト
- すべての失敗を1章の軸(予想/一時/回復可能)で分類したか?
- 予想されたドメインの失敗はエラー値・型で、シグネチャに現れるようにしたか?
- 予想しなかったバグはfail fast(例外・panic)で、飲み込まないようにしたか?
- 入力検証を境界で1回やってコアは信頼するか?
- コアの不変条件は検証ではなく表明で守るか?
- すべてのリモート呼び出しにtimeoutがあるか? (例外なく)
- timeoutがデフォルト値(通常長すぎる)ではなく意図的に決められたか?
- timeoutが実際のキャンセルと結ばれているか?
- retry可能なエラーだけretryするか? (
4xxではない) - retryに指数backoff + jitterがあるか?
- retryが入れ子になっておらず、回数と時間予算の両方が制限されるか?
- 書き込み操作のretryがidempotencyキーで安全か?
- 核心の依存にcircuit breakerが、付加機能にfallbackがあるか?
- エラーが構造化されており(
code/retryable/context)、メトリクスで測られるか?
10のアンチパターン
- 空のcatch —
catch (e) {}。失敗を静かに飲み込む。デバッグ不能の根源。 - すべてをretry —
4xxをretryして恒久的な失敗を無限に繰り返し、負荷だけ増幅。 - jitterのないretry — 同期した群れがダウンストリームを回復する隙ごとにまた叩く。
- 入れ子のretry — 3×3×3 = 27回。1つの層でだけretryせよ。
- timeoutのない呼び出し — 遅い依存1つがプールを枯渇させ全体を止める。
- キャンセルのないtimeout — 待つのをやめたが、作業はバックグラウンドでリソースを食い続ける。
- retryされる非冪等な書き込み — 二重請求、重複注文。idempotencyキーのない決済API。
- 撒き散らされた検証 — 同じ検証をコアのあちこちで繰り返し、一貫性がなく、ビジネスロジックが埋もれる。
- エラーを潰す —
catchの後に原因を捨てて新しい一般的なエラーを投げる。根本原因が追えない。 - 不透明なエラーメッセージ —
"Error 500"。ユーザーも運用者も未来の開発者も行動できない。
次の記事の予告
コードレベルでうまく失敗する方法を見た。しかし1つの前提が残っている — このコードが本当にうまく失敗するかをどうやって知るのか? ハッピーパスだけテストして「timeout処理コード」を一度も実行しないままデプロイすることはよくある。
次の記事は失敗をテストする方法だ。フォールトインジェクション(fault injection)を単体・統合テストに入れること、timeout・部分的な失敗・回路の開きを決定論的に再現すること、そしてそこから自然にカオスエンジニアリング — プロダクションに近い環境で失敗をわざと起こし、システムが本当に優雅に失敗するかを検証すること — へとつながる。うまく失敗するように設計したなら、次はその設計が本物かを証明することだ。