TL;DR
- エラーは「一時的(transient)」と「永続的(persistent)」で分類し、retryするかどうかを最初に決める
- exponential backoff + jitter でリトライストームを防ぎ、idempotency key で重複実行の副作用を排除する
- circuit breaker で連鎖障害を遮断し、fallback(graceful degradation)でサービス継続性を担保する
- 設計チェックリストを用いてレビュー前に自己検証することで、障害時の対応速度が上がる
はじめに
こんにちは、みねです。
分散システムでは「エラーは必ず起きる」という前提で設計しなければなりません。ネットワーク遅延・タイムアウト・依存サービスの瞬断・外部APIのレート制限——これらは防ぎきれないため、どう「受け流すか」がシステムの信頼性を決定します。
ところが、実務でのエラーハンドリングを見ると「とりあえず3回リトライする」「エラーをログに吐いて終わり」という実装が多く見られます。これでは一時障害の吸収もできず、重複実行による二重課金・二重登録が発生し、最悪の場合は連鎖障害(カスケード障害)でシステム全体が止まります。
本記事では、Backend・SREが実務で使える「エラーハンドリングの判断基準と実装パターン」を体系化します。agent loop の安定化でも触れた retry/saga の概念をより根本から整理し、どの場面でどのパターンを選ぶべきかを明確にします。
エラーの分類
エラーハンドリングの設計は、エラーの分類から始まります。分類を間違えると「リトライすべきでないエラーをリトライしてシステムを悪化させる」という最悪の事態になります。
一時的エラー vs 永続的エラー
| 分類 | 説明 | 対処 |
|---|---|---|
| 一時的(transient) | ネットワーク瞬断・タイムアウト・一時的な過負荷 | リトライ可能 |
| 永続的(persistent) | バグ・不正入力・リソース不足・権限エラー | リトライ不可 |
最初の判断は「この失敗はリトライすれば成功する可能性があるか?」です。可能性がないならリトライは無駄であり、むしろシステムに余計な負荷をかけます。
HTTP ステータスコードによる判断
function isRetryable(statusCode: number): boolean {
// 4xx は原則リトライ不可(クライアント側の問題)
// ただし 429 (Too Many Requests) と 408 (Request Timeout) は例外
if (statusCode === 429 || statusCode === 408) return true;
if (statusCode >= 400 && statusCode < 500) return false;
// 5xx は原則リトライ可能(サーバー側の一時的問題)
// ただし 501 (Not Implemented) は永続的
if (statusCode === 501) return false;
if (statusCode >= 500) return true;
return false;
}
重要: 400 Bad Request や 422 Unprocessable Entity は、何度リトライしても成功しません。リクエスト自体を修正しない限り無意味であり、リトライループは禁止です。
ネットワークエラーの分類
function isNetworkRetryable(error: Error): boolean {
// DNS 解決失敗は一時的な場合がある
if (error.message.includes('ENOTFOUND')) return true;
// 接続リセット・タイムアウトはリトライ可能
if (error.message.includes('ECONNRESET')) return true;
if (error.message.includes('ETIMEDOUT')) return true;
// 接続拒否は一時的過負荷の可能性
if (error.message.includes('ECONNREFUSED')) return true;
return false;
}
retry 設計
エラーが「リトライ可能」と判断したら、次はどうリトライするかを設計します。ナイーブなリトライ(固定間隔で即再試行)は、依存サービスが障害中の場合に大量のリクエストを集中させてしまいます(リトライストーム)。
exponential backoff
基本パターンは「待ち時間を指数的に増やす」です。
async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
} = {}
): Promise<T> {
const { maxAttempts = 3, baseDelayMs = 100, maxDelayMs = 30_000 } = options;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxAttempts) throw error;
if (!isRetryable(error)) throw error;
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
await sleep(delay);
}
}
throw new Error('unreachable');
}
待ち時間の推移例(baseDelayMs=100):
- 1回目失敗 → 100ms 待機
- 2回目失敗 → 200ms 待機
- 3回目失敗 → 400ms 待機
jitter の必要性
exponential backoff だけでは、同時に多数のリクエストが失敗した場合、全クライアントが同じタイミングで再試行するthundering herd(群れの暴走)問題が発生します。解決策は**jitter(ランダムなゆらぎ)**の追加です。
function calcBackoffWithJitter(attempt: number, baseMs: number, maxMs: number): number {
const exponential = baseMs * 2 ** attempt;
const capped = Math.min(exponential, maxMs);
// full jitter: 0 〜 cap の間でランダムに選択
return Math.random() * capped;
}
AWS の研究では、full jitter を使うことで平均完了時間が大幅に短縮されることが実証されています。固定間隔リトライは使わないでください。
リトライ回数の上限設計
| ユースケース | 推奨上限 | 理由 |
|---|---|---|
| 同期 API 呼び出し(ユーザー待機中) | 2〜3回 | レイテンシへの影響が直接的 |
| 非同期バッチ処理 | 5〜10回 | 許容時間が長い |
| 決済など副作用の大きい操作 | 1〜2回(idempotency 必須) | 重複実行リスクが高い |
Dead Letter Queue(DLQ): 最大リトライを超えた失敗は DLQ に退避し、アラートを上げて手動対応またはオフライン処理に回す設計が堅牢です。
idempotency 設計
リトライを実装したとき最も怖いのが重複実行です。「注文が2回登録された」「決済が2回引き落とされた」は深刻なバグです。これを防ぐのが**冪等性(idempotency)**の設計です。
冪等とは「同じ操作を何度実行しても結果が変わらない」性質です。
idempotency key の実装
外部 API に対して冪等性を保証する最もシンプルな方法は、idempotency key(一意な識別子)をリクエストに付与し、サーバー側が重複を検出する仕組みです。
import { randomUUID } from 'crypto';
async function createOrderIdempotent(
orderData: OrderInput,
idempotencyKey: string = randomUUID()
): Promise<Order> {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey, // 冪等キーをヘッダに付与
},
body: JSON.stringify(orderData),
});
if (!response.ok) {
if (response.status === 409) {
// Conflict: 同じキーでの重複リクエスト → 前回の結果を返す
return response.json();
}
throw new Error(`Order creation failed: ${response.status}`);
}
return response.json();
}
サーバー側の実装(DB upsert):
// PostgreSQL + Prisma の例
async function handleOrderCreate(idempotencyKey: string, data: OrderInput): Promise<Order> {
// idempotency_key テーブルで重複チェック
const existing = await prisma.idempotencyRecord.findUnique({
where: { key: idempotencyKey },
});
if (existing) {
// 既に処理済み → キャッシュした結果を返す
return JSON.parse(existing.responseBody) as Order;
}
// 新規処理
const order = await prisma.$transaction(async (tx) => {
const newOrder = await tx.order.create({ data: mapToOrder(data) });
// 冪等レコードを同一トランザクションで保存
await tx.idempotencyRecord.create({
data: {
key: idempotencyKey,
responseBody: JSON.stringify(newOrder),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24時間保持
},
});
return newOrder;
});
return order;
}
DB の upsert による冪等性
自前のデータベース操作でも、upsert(INSERT OR UPDATE)を使うことで冪等性を確保できます。
// 冪等な upsert(同じ external_id なら更新、なければ挿入)
await prisma.payment.upsert({
where: { externalId: payment.externalId },
update: { status: payment.status, updatedAt: new Date() },
create: {
externalId: payment.externalId,
amount: payment.amount,
status: payment.status,
},
});
ポイント: ビジネスキー(外部システムのID)をユニーク制約にしておくと、アプリケーション層での重複チェックなしに DB 側で冪等性が保証されます。
circuit breaker パターン
依存サービスが長時間障害中の場合、リトライを続けることは資源の無駄であり、タイムアウト待ちの蓄積でシステム全体が応答不能になります(カスケード障害)。これを防ぐのが**circuit breaker(サーキットブレーカー)**パターンです。
状態遷移
Closed(通常) → エラー率が閾値超過 → Open(遮断)
Open → 一定時間後 → Half-Open(試験)
Half-Open → 成功 → Closed
Half-Open → 失敗 → Open に戻る
type CircuitState = 'closed' | 'open' | 'half-open';
class CircuitBreaker {
private state: CircuitState = 'closed';
private failureCount = 0;
private lastFailureTime: number | null = null;
constructor(
private readonly threshold: number = 5, // 失敗回数の閾値
private readonly recoveryTimeMs: number = 60_000 // 回復待機時間(1分)
) {}
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
const elapsed = Date.now() - (this.lastFailureTime ?? 0);
if (elapsed < this.recoveryTimeMs) {
throw new Error('Circuit breaker is OPEN — request rejected');
}
this.state = 'half-open';
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
this.state = 'closed';
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.threshold) {
this.state = 'open';
}
}
}
実務での閾値設計
| 指標 | 典型的な値 | 調整方針 |
|---|---|---|
| エラー率閾値 | 50%(10リクエスト中5失敗) | 重要度の低いサービスは高め |
| 最小リクエスト数 | 10〜20件 | 低トラフィック時の誤動作を防ぐ |
| Open → Half-Open の待機 | 30秒〜5分 | 依存サービスの回復時間に合わせる |
注意: circuit breaker は observability と組み合わせてください。状態変化(Open/Closed)をメトリクスとして記録し、アラートを設定することで運用時の可視性が確保されます。詳細はAI observability と eBPFの観点も参考にしてください。
fallback 設計(graceful degradation)
circuit breaker が Open になった、あるいはリトライが尽きた——そのとき、システムはどう振る舞うべきか。理想は完全に止まらず、機能を縮退させながら継続する「graceful degradation(優雅な劣化)」です。
fallback の選択肢
| 戦略 | 説明 | 適用例 |
|---|---|---|
| キャッシュ返却 | 最後に成功した値を返す | レコメンド・設定値 |
| デフォルト値 | 安全な固定値を返す | フィーチャーフラグ・表示設定 |
| 別エンドポイント | バックアップサービスに切り替え | マルチリージョン・CDN |
| 機能の省略 | 非必須機能をスキップ | レコメンドブロック非表示 |
| キューへの退避 | 非同期で後処理 | メール送信・通知 |
async function getRecommendations(userId: string): Promise<Recommendation[]> {
try {
// プライマリ: リアルタイムレコメンドサービス
return await recommendationBreaker.call(() =>
recommendationApi.get(userId)
);
} catch {
// fallback 1: Redis キャッシュから古い結果を返す
const cached = await redis.get(`recommendations:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// fallback 2: 人気記事の固定リストを返す(機能縮退)
return POPULAR_ARTICLES_FALLBACK;
}
}
fallback の設計原則
- 副作用のない fallback を選ぶ: キャッシュ返却・デフォルト値は安全。「別サービスへの書き込み」は fallback には不向き
- ユーザーに分かる形で通知する: 「現在、一部機能が制限されています」というメッセージを表示
- fallback の品質も監視する: fallback が発動した回数・割合をメトリクスで追跡する
- fallback が永遠に続かないようにする: TTL を設定し、プライマリへの復帰を自動化する
設計チェックリスト
実装前・レビュー前に以下の項目を確認してください。
エラー分類
- エラーを一時的 / 永続的に分類しているか
- HTTP 4xx を不用意にリトライしていないか
- リトライ不可エラーは即時 throw しているか
retry 設計
- exponential backoff を使用しているか(固定間隔は NG)
- jitter(ランダム揺らぎ)を追加しているか
- 最大リトライ回数を設定しているか
- 最大リトライ超過後の処理(DLQ など)を設計しているか
idempotency 設計
- 副作用を伴う操作(INSERT / 決済 / メール送信)に冪等性があるか
- idempotency key を外部 API に渡しているか
- DB のユニーク制約 / upsert で重複を防いでいるか
- 冪等キーの有効期限(TTL)を設定しているか
circuit breaker
- 依存する外部サービスに circuit breaker を設定しているか
- 閾値(エラー率・リクエスト数・回復時間)を環境に合わせて調整しているか
- circuit breaker の状態変化をメトリクス / アラートで監視しているか
fallback
- circuit breaker が Open になったときの fallback を定義しているか
- fallback の品質を監視しているか
- fallback がユーザー体験を著しく損なわないか確認しているか
FAQ
Q1: retry と circuit breaker はどちらを先に実装すべきですか?
A: 一般的には retry → circuit breaker の順で導入します。単純なリトライで吸収できる一時障害の多くは解決できます。circuit breaker は「リトライしても回復しない長時間障害」への対策であり、より高度な設計が必要です。まずリトライ + backoff を安定させ、障害パターンを観測してから circuit breaker を追加するのが実務的なアプローチです。
Q2: idempotency key はどこで生成すべきですか?
A: **クライアント側(呼び出し元)**で生成し、リトライ時も同じキーを使い回すことが重要です。サーバー側で生成すると、ネットワーク障害でレスポンスが届かなかったとき、クライアントは異なるキーで再試行してしまい、冪等性が機能しません。UUID v4 などのランダム ID をリクエスト開始時に生成し、成功するまで保持してください。
Q3: fallback キャッシュはどのくらいの期間保持すべきですか?
A: サービスの性質によりますが、プライマリサービスの SLO(回復目標時間)より長く設定するのが原則です。例えば SLO が「障害は30分以内に回復」なら、fallback キャッシュは1時間保持する設計が安全です。古すぎるデータが有害な場合(在庫数・価格など)は TTL を短く設定し、キャッシュが切れたら「一時的に表示できない」として機能を省略する設計を選びます。
まとめ
エラーハンドリング設計の要点を整理します。
- 分類が先: エラーを「一時的 / 永続的」で分け、リトライする/しないを最初に決める
- retry は exponential backoff + jitter: 固定間隔リトライはリトライストームを引き起こす
- idempotency key で重複実行を排除: 副作用のある操作は必ず冪等性を確保する
- circuit breaker で連鎖障害を遮断: 長時間障害は早期に検知して後続リクエストを保護する
- fallback で機能を縮退させながら継続: 完全停止より優雅な劣化を選ぶ
この5つのパターンを組み合わせることで、一時障害を自動回復し、重大障害でもサービスを継続できる堅牢なシステムになります。
DB 事故パターンと予防策も合わせて読むと、障害対応の全体像をさらに深めることができます。retry / idempotency / circuit breaker で吸収できる page を増やすことは、オンコール疲弊の直接的な対策にもなります。詳細は オンコール疲弊を防ぐ運用設計の手順 で扱っています。
