金融システム開発規約
エラーハンドリングと冪等性
決済システムにおけるリトライ設計、二重決済防止、タイムアウト処理
エラーハンドリングと冪等性
決済システムでは「処理が成功したのか失敗したのか分からない」状態が最も危険です。ネットワーク障害やタイムアウトが発生した際に、二重決済や処理漏れを防ぐ設計が不可欠です。
1. 冪等性(Idempotency)とは
同じリクエストを何回送っても、結果が同じになることを冪等性といいます。
POST /api/payments
Idempotency-Key: txn-abc-123
→ 1回目: 決済実行、200 OK
→ 2回目(リトライ): 同じ結果を返す、200 OK(二重決済しない)なぜ冪等性が必要か
実装パターン
async function processPayment(request: PaymentRequest): Promise<PaymentResult> {
const idempotencyKey = request.idempotencyKey;
// 1. 既に処理済みか確認
const existing = await db.findByIdempotencyKey(idempotencyKey);
if (existing) {
return existing.result; // 前回の結果をそのまま返す
}
// 2. 処理中ロックを取得(同時リクエスト防止)
const lock = await db.acquireLock(idempotencyKey);
if (!lock) {
throw new ConflictError('Request is being processed');
}
try {
// 3. 決済処理の実行
const result = await executePayment(request);
// 4. 結果を保存(冪等性キーと紐付け)
await db.saveResult(idempotencyKey, result);
return result;
} finally {
await db.releaseLock(idempotencyKey);
}
}2. タイムアウト処理
3つのタイムアウトパターン
| パターン | 状態 | 対応 |
|---|---|---|
| 接続タイムアウト | 接続自体が確立できない | リトライ可(処理未開始が確定) |
| 読み取りタイムアウト | 接続済みだがレスポンスが返らない | 最も危険 — 処理済みか不明 |
| 処理タイムアウト | サーバー側で処理が長時間化 | ステータス照会で確認 |
読み取りタイムアウトの対処
try {
const result = await callPaymentAPI(request, { timeout: 30000 });
return result;
} catch (error) {
if (error instanceof ReadTimeoutError) {
// 安易にリトライしない!まずステータスを照会する
const status = await checkTransactionStatus(request.transactionId);
switch (status) {
case 'SUCCESS':
return status; // 実は成功していた
case 'FAILED':
// リトライ可能
return await retryPayment(request);
case 'NOT_FOUND':
// 処理が開始されていない → リトライ可能
return await retryPayment(request);
case 'PROCESSING':
// まだ処理中 → 待機して再照会
return await pollStatus(request.transactionId);
}
}
throw error;
}3. ステータス管理
決済のステータスは段階的に遷移します。各状態を正確に管理しましょう。
ルール: ステータス遷移は許可された遷移のみを実装する。不正な遷移(例: REFUNDED → CAPTURED)は拒否する。
4. リトライ設計
Exponential Backoff
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
if (!isRetryable(error)) throw error;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await sleep(delay);
}
}
throw new Error('Unreachable');
}
function isRetryable(error: Error): boolean {
// リトライ可能なエラーのみリトライする
return error instanceof NetworkError
|| error instanceof TimeoutError
|| error instanceof ServiceUnavailableError;
// 400系エラー(バリデーションエラー等)はリトライしない
}リトライすべきでないケース
- ビジネスロジックによる拒否(残高不足、与信枠超過)
- バリデーションエラー(不正なカード番号等)
- 認証エラー
5. 整合性の担保
Saga パターン
複数のサービスにまたがる取引で、一部が失敗した場合に補償トランザクションで元に戻すパターンです。
1. 与信確保 → 成功
2. ポイント付与 → 成功
3. 外部決済 → 失敗
↓
3'. 外部決済の補償(不要)
2'. ポイント取消(補償トランザクション)
1'. 与信解放(補償トランザクション)突合(リコンサイル)
バッチ処理で、自システムのデータと外部システムのデータを照合し、不一致を検出する仕組みです。
- 日次突合: 前日の取引データの照合
- 不一致の分類: 金額不一致、自社のみ、相手のみ
- 調査・修正フロー: 不一致の原因調査と修正処理
ルール: 突合処理は必ず実装する。「データは正しいはず」という前提は決済では通用しません。