はじめに

リトライは、失敗に強いシステムを作るための基本手段です。
ただ、副作用を伴う処理にそのまま再試行をかける実装は、良かれと思って入れたはずの仕組みが、あとで厄介な事故の入口になることがあります。

この記事は、忙しいPRレビューの中で「この実装は止めるべきか」を短時間で判断したいレビューアー向けです。
ここではライブラリ選定論ではなく、どこで立ち止まるべきかの線引きに絞って整理します。

特に危険なのは、次のようなケースです。

  • 外部API呼び出しの成功可否がタイムアウトで曖昧になる
  • 呼び出し後にDB更新を行っている
  • その関数全体にデコレーターやループでリトライをかけている

この構造では、「失敗したから再実行した」のではなく、すでに成功していた処理をもう一度打ってしまうことがあります。
レビューアーが見るべきなのは「retryがあるか」ではなく、どの副作用境界をまたいで再試行しているかです。

まず立ち止まりたい実装

以下のコードは、一見するとよく整って見えます。
例外を補足し、指数バックオフ付きで再試行し、成功時にはDB状態も更新しています。

危険なリトライ実装
import time
import requests


class PaymentGatewayError(Exception):
    pass


def retry(max_attempts: int = 3, sleep_sec: float = 1.0):
    def wrapper(fn):
        def inner(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception:
                    if attempt == max_attempts:
                        raise
                    time.sleep(sleep_sec * attempt)
        return inner
    return wrapper


@retry(max_attempts=3)
def charge_order(order_id: str, amount: int, db):
    response = requests.post(
        "https://payment.example.com/charge",
        json={"order_id": order_id, "amount": amount},
        timeout=3,
    )
@Reviewer
この時点で外部副作用は発生しています。タイムアウトや接続断の扱い次第では、アプリ側が失敗と見なしても外部側では成功済み、という状態が起こりえます。
if response.status_code != 200: raise PaymentGatewayError("payment failed")
@Reviewer
status_codeだけで失敗/成功を二値化していますが、レビューでは「成功」「失敗」「不明」の3状態で読めるかを確認したいところです。
db.execute( "UPDATE orders SET status = %s, charged_amount = %s WHERE id = %s", ("paid", amount, order_id), ) db.commit()
@Reviewer
外部課金と内部状態更新を同じ再試行単位に入れているため、関数全体にretryをかける設計だと重複課金と状態不整合の両方を招きやすくなります。

やっかいなのは、コードが雑だから問題なのではないことです。
むしろ「ちゃんとしていそう」に見えるので、レビューでもそのまま通りやすくなります。

事故シーケンス:なぜ二重課金が起きるのか

問題は、関数の行数ではなく時間順序にあります。

UML Diagram

レビューで見たい本質は、
「結果不明な副作用」をまたいで再試行していることです。

ここで起きているのは、単純な通信失敗ではありません。
App から見るとタイムアウトでも、PaymentAPI 側では処理済みかもしれません。
この「不明状態」に対して同一リクエストを再送している時点で、retryは回復策というより、事故を広げる動きになりやすいです。

なぜレビューで通りやすいのか

この種の実装は、責めるというより、かなり自然に見落としやすいものです。

  • retry が可用性改善として善意で導入されている
  • 失敗系テストでは「3回目で成功する」ケースしか見ていない
  • レビューがコード断片中心で、外部APIの意味論まで追えていない
  • DB更新の有無には気づいても、外部副作用の結果不明状態まで考え切れていない
  • レビューアー自身が決済APIや配送APIの仕様を知らず、踏み込みにくい

現場ではさらに、次の誤解が重なりがちです。

  • retry ライブラリを使えば安全性も上がる、という思い込み
  • タイムアウトを「失敗確定」と読んでしまう短絡
  • 「外部APIの仕様確認は実装者がやっているはず」という委譲

その結果、レビューコメントが
「例外型を限定したほうがよい」
「backoffライブラリを使ったほうがよい」
で止まってしまうと、本質を外してしまいます。

この論点でレビューアーが先に見たいのは、リトライ実装のうまさではなく、そもそも再送してよい操作かどうかです。

レビューで見たい3つの判断線

1. 再試行単位が副作用境界をまたいでいるなら止める

charge_order() 全体が再試行対象になっている時点で、かなり慎重に見たほうがよいです。
なぜなら、関数内に以下が同居しているからです。

  • 外部課金という不可逆な副作用
  • DB状態更新という内部状態遷移
  • エラー判定
  • 再試行制御
Comment
@Reviewer: retry対象の単位が大きすぎます。外部課金・DB更新・再試行制御が同一責務に混在しており、結果不明な副作用を含んだまま再送しています。再試行可能な処理単位の切り分けが必要です。

ここはレビューアーが遠慮せず線を引いてよいところです。

  • 純粋な読み取りや接続確立の再試行なら許容余地がある
  • 送金、課金、通知送信、在庫引当など副作用の確定操作なら、そのままの再試行は止める

2. 外部APIに冪等性キーがないなら止める

副作用付きリトライを成立させる最低条件は、
同じ要求を再送しても外部側で重複実行されない仕組みがあることです。

典型例は以下です。

  • Idempotency-Key
  • 決済要求ID
  • 一意な業務トランザクションID

これがない状態で再試行しているなら、コメントはかなり強めで問題ありません。

Comment
@Reviewer: 外部課金APIに重複防止キーが渡されていません。同一注文の再送が区別不能なため、通信失敗時に二重課金を防げません。冪等化戦略が確認できるまで、このリトライはマージ不可です。

ここは「できれば改善してほしい」ではなく、止める理由になる条件として扱うほうが実務的です。

3. 不明結果の扱いが「失敗」に丸められているなら止める

特に気をつけたいのは、タイムアウトや接続断をそのまま失敗扱いしているケースです。

本来、外部副作用の呼び出し結果は少なくとも3状態あります。

  • 成功が確定している
  • 失敗が確定している
  • 成否が不明である

この3番目を持たず、

  • except Exception: retry
  • timeout == failure

としている実装は、後でかなり苦しくなりやすいです。

Comment
@Reviewer: タイムアウト時の状態が「失敗確定」として扱われていますが、外部側で処理済みの可能性があります。不明結果を failure に丸める設計だと再送判断を誤るため、unknown 状態を明示した状態遷移に見直してください。

改善方針:再試行より「重複しない実行」に寄せる

重要なのは、retryを消すことではありません。
副作用の直前と直後で責務を分離し、再試行の条件を狭めることです。

改善後の考え方は次の通りです。

  1. 先に内部DBへ支払い試行レコードを作る
  2. 一意な payment_request_id を発行する
  3. 外部APIにはそのIDを冪等性キーとして送る
  4. タイムアウト時は即再送せず、unknown として保留する
  5. 照会APIや非同期整合処理で結果を確定する

ここで大事なのは、
unknown を記録しただけでは、まだ半分しか直っていないということです。
結果確定の責務まで入れないと、「unknownが残るだけ」で現場が困ります。

したがって、改善例は最低でも以下を満たす必要があります。

  • 支払い試行の作成
  • 課金リクエスト送信
  • unknown への遷移
  • 後続の照会
  • 注文状態と支払い試行状態の一貫更新
最低限必要な改善後フロー
import requests
from contextlib import contextmanager


class PaymentResultUnknown(Exception):
    pass


@contextmanager
def transaction(db):
    try:
        yield
        db.commit()
    except Exception:
        db.rollback()
        raise


def create_payment_attempt(db, order_id: str, request_id: str):
    with transaction(db):
        db.execute(
            """
            INSERT INTO payment_attempts(order_id, request_id, status)
            VALUES (%s, %s, %s)
            """,
            (order_id, request_id, "processing"),
        )
@Reviewer
先に支払い試行を記録しておくと、あとで「このrequest_idで何が起きたのか」を追跡しやすくなります。
def request_payment(order_id: str, amount: int, request_id: str): try: response = requests.post( "https://payment.example.com/charge", json={"order_id": order_id, "amount": amount}, headers={"Idempotency-Key": request_id}, timeout=3, ) except requests.Timeout as exc: raise PaymentResultUnknown from exc
@Reviewer
タイムアウトを即 failure にせず、unknown として持ち上げている点が重要です。ここで二値化しないことが、この設計の出発点になります。
if response.status_code == 200: return "succeeded" if response.status_code == 409: return "already_processed" if response.status_code == 422: return "rejected" raise RuntimeError("gateway error") def mark_attempt_unknown(db, request_id: str): with transaction(db): db.execute( "UPDATE payment_attempts SET status = %s WHERE request_id = %s", ("unknown", request_id), )
@Reviewer
unknown を記録するだけでは不十分ですが、少なくとも「結果不明だった」という事実を消さない構造にはなっています。
def finalize_payment_success(db, order_id: str, amount: int, request_id: str): with transaction(db): db.execute( "UPDATE orders SET status = %s, charged_amount = %s WHERE id = %s", ("paid", amount, order_id), ) db.execute( "UPDATE payment_attempts SET status = %s WHERE request_id = %s", ("succeeded", request_id), )
@Reviewer
orders と payment_attempts を同じトランザクションで更新しているので、片方だけ進んで状態がずれるリスクを下げられます。
def reconcile_unknown_payment(db, gateway_client, order_id: str, amount: int, request_id: str): result = gateway_client.fetch_payment_result(request_id)
@Reviewer
記事としてはここが一番大事です。unknown の後続確定処理が存在しないなら、「unknown を記録しただけ」で設計が止まってしまいます。
if result == "succeeded": finalize_payment_success(db, order_id, amount, request_id) return if result == "not_found": with transaction(db): db.execute( "UPDATE payment_attempts SET status = %s WHERE request_id = %s", ("retryable", request_id), ) return with transaction(db): db.execute( "UPDATE payment_attempts SET status = %s WHERE request_id = %s", ("failed", request_id), ) def charge_order(db, gateway_client, order_id: str, amount: int, request_id: str): create_payment_attempt(db, order_id, request_id) try: result = request_payment(order_id, amount, request_id) except PaymentResultUnknown: mark_attempt_unknown(db, request_id) raise if result in ("succeeded", "already_processed"): finalize_payment_success(db, order_id, amount, request_id) return with transaction(db): db.execute( "UPDATE payment_attempts SET status = %s WHERE request_id = %s", ("failed", request_id), )
@Reviewer
ここでも重要なのは「リトライ回数」ではなく、request_id 単位で結果を閉じる責務がどこにあるかです。

このコード例は、
「このままコピペで完成」という意味ではありません。
伝えたいのは、結果不明状態の後始末をどこで受け持つべきかです。

レビューアーは、改善案を見るときに次を確認したいところです。

  • unknown が記録されるだけで放置されていないか
  • 注文状態と支払い試行状態が同一トランザクションで更新されるか
  • 照会APIがない場合に、同期リトライを諦める設計へ戻しているか

もし照会APIも冪等性キーも存在しないなら、
その操作は「リトライで頑張る」のではなく「同期再送をしない」方向へ設計を戻したほうが安全です。

改善後シーケンス:レビューで許容しやすい形

UML Diagram

この構造なら、レビューアーは少なくとも次を確認できます。

  • 再送時に外部が重複判定できるか
  • unknown を failure に丸めていないか
  • 内部状態遷移が追跡可能か

この図で見てほしいのは、
「retryする」ではなく「unknownを確定させる」処理が別責務として切り出されていることです。

3状態の遷移をレビューで確認する

レビュー時に追いたい状態遷移は、最低でも以下です。

現在状態 契機 遷移先 レビューで確認すること
processing APIが200を返す succeeded 注文状態と同時コミットされるか
processing タイムアウト unknown 「失敗」に丸めていないか
unknown 照会で成功判明 succeeded 二重更新防止があるか
unknown 照会で未処理判明 retryable 同じ request_id で再送するか
unknown 照会で拒否判明 failed 手動対応条件が明確か

レビューでの確認質問

  • この操作は、同一リクエストを2回送っても安全か?
  • 外部APIは何をもって同一要求と判定するか?
  • タイムアウト時に「成功不明」を保持する状態はあるか?
  • DB更新は外部副作用の前後どちらに置かれているか?
  • 再試行は誰の責務か? アプリ層か、ジョブ層か、SDK層か?

この質問に実装者が答えにくそうなら、
その時点で設計の説明責任がまだ足りていない可能性が高いです。

コメント強度の目安

停止条件

状況 コメント強度 判断
副作用付き操作で外部冪等化がない must 差し戻し
タイムアウトを失敗確定として再送する must 差し戻し
unknown の後続確定処理が存在しない must 差し戻し
注文状態と支払い試行状態が部分コミットでずれる must 差し戻し

設計改善で進められる条件

状況 コメント強度 判断
GET系の一時失敗に限定した再試行 should 実装改善で進められる
外部冪等化はあるが状態遷移が粗い should〜must unknown 設計次第
運用手順はあるがコード上の表現が弱い should 補足説明で進められる

チェックリスト

副作用付きリトライ実装のレビュー確認項目
  • 再試行対象が外部副作用を含む関数全体になっていないか
  • 外部APIに冪等性キーまたは重複防止キーがあるか
  • タイムアウトや接続断を unknown として扱えているか
  • 外部副作用と内部状態更新の順序が説明可能か
  • 再送時の重複防止戦略がコードと運用の両方で成立しているか
  • API仕様上、冪等化キーの保持期間が十分か
  • タイムアウト後に照会する手順が運用に組み込まれているか
  • 409422 などの応答差を実装者が説明できるか

実装者の反論にどう返すか

実際のレビューでは、設計論だけを置いても会話が前に進みにくいものです。
以下は、現場で出やすい反論と返し方の例です。

Comment
@Developer: でも業務上、このAPIは冪等性キーに対応していません。
@Reviewer: その場合は同期再送を前提にした設計自体が成立しません。retry回数の調整では解決できないので、この操作をリトライ対象外にするか、結果照会可能な別経路を用意する必要があります。
Comment
@Developer: unknown状態を持つのは複雑すぎませんか。
@Reviewer: 外部副作用の結果不明を failure に丸めるほうが危険です。複雑さが増えるのは unknown 状態のせいではなく、もともと外部副作用が不明結果を持つからです。複雑さを隠すより、状態として明示したほうが安全です。
Comment
@Developer: 照会APIがないので、今はretryで逃がしたいです。
@Reviewer: 照会できないなら、なおさら同期再送は危険です。再送でしか回復できない設計ではなく、保留・手動確認・後続ジョブなど別の運用経路へ切り替えるべきです。

おわりに

副作用を伴うリトライ実装のレビューで問われるのは、
「何回再試行するか」ではありません。
どの時点から先を再送してはいけないかです。

レビューアーは、タイムアウト処理や例外補足の巧さに引っ張られず、
副作用境界・冪等化・不明状態の3点で線を引く必要があります。

この論点で立ち止まりたいPRは、
実装が雑なPRよりも、一見きれいに見えるのに事故条件を抱えたPRです。
そこに気づけるかどうかで、レビューの質はかなり変わってきます。