生成AI研修
金融システム開発規約

エラーハンドリングと冪等性

決済システムにおけるリトライ設計、二重決済防止、タイムアウト処理

エラーハンドリングと冪等性

決済システムでは「処理が成功したのか失敗したのか分からない」状態が最も危険です。ネットワーク障害やタイムアウトが発生した際に、二重決済や処理漏れを防ぐ設計が不可欠です。


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'. 与信解放(補償トランザクション)

突合(リコンサイル)

バッチ処理で、自システムのデータと外部システムのデータを照合し、不一致を検出する仕組みです。

  • 日次突合: 前日の取引データの照合
  • 不一致の分類: 金額不一致、自社のみ、相手のみ
  • 調査・修正フロー: 不一致の原因調査と修正処理

ルール: 突合処理は必ず実装する。「データは正しいはず」という前提は決済では通用しません。

On this page