タイムアウトが答えるのは、「期限までに返事が届いたか」という1つの問いだけです。リクエストが実行されたかどうかは、何も教えてくれません。注文に商品を1つ追加するリクエストを送り、30ミリ秒待っても何も返ってこなかったとき、その結果と矛盾しない経緯が3通りあります。

TL;DR

  • タイムアウトは3つの世界と矛盾しません。リクエストが届かなかった、リクエストは届いて適用されたが応答が失われた、リクエストはまだ処理中なのにこちらが早く待つのをやめた。送り手はこれらを区別できず、信頼できないリンクの上のどんなプロトコルも、区別できるようにはできません(Two Generals 問題)。
  • だから「ちょうど1回の配送」は、リンクに後から足せる性質ではありません。作れるのは、少なくとも1回の配送と、重複を除く受け手の組み合わせで、これは自分が制御できる境界の内側での、ちょうど1回の効果になります。
  • そのための仕組みが**冪等キー(idempotency key)**です。クライアントが意図ごとに1回、最初の送信の前に決め、再試行のたびに同じものを使い、サーバーは効果と原子的に記録します。
  • 15% のリクエストが失われ、クライアントのタイムアウトがハンドラーの処理時間より短いことがある環境で試しました。キーなしの再試行は、約半数を重複させました。キーつきの再試行は、重複がゼロでした。効果のあとにキーを記録する実装は、その変種を1回実行したところ、200件中89件を重複させました。
  • 再試行には、ジッターつきのバックオフ、試行回数の上限、Retry-After の尊重が要ります。ジッターがなければ、同時に失敗した1000のクライアントは、毎ラウンド同時に再試行します。

1つのタイムアウトの背後にある3つの世界

何が起きたかサーバーの状態クライアントに見えたもの
リクエストが途中で失われた何も適用されていないタイムアウト
リクエストが適用され、応答が失われた1回適用済みタイムアウト
リクエストが遅く、クライアントが待つのをやめたあとで1回適用されるタイムアウト

正しい反応は、世界ごとに違います。1つ目の世界では、再送しなければ仕事が失われます。2つ目では、再送すると仕事が二重になります。3つ目では、再送が元のリクエストと競合します。

これはTwo Generals 問題です。1975 年に最初に発表され、1978 年にこの名前が付きました。信頼できないチャネルでしか通信できない2者は、双方が行動したという確実な合意に到達できません。実務上の帰結は、確認応答では無限後退を止められないことです。確認応答の確認応答も、失われうるからです。

HTTP の仕様は、ちょうどよい場所に線を引いています。RFC 9110 §9.2.2 は、PUT、DELETE と安全なメソッドは冪等だとしています。通信障害が起きたあと、元のリクエストが成功していたとしても、自動的に繰り返してかまいません。一方、冪等でないメソッドは、リクエストの意味が実際には冪等であると知る手段か、元のリクエストが適用されなかったことを検出する手段がない限り、クライアントは自動的に再試行すべきでない(SHOULD NOT)とされています。この記事の残りは、その2つの「手段」の話です。

「ちょうど1回」をうたうシステムは、実際に何をしているか

ちょうど1回のセマンティクスをうたうシステムは、不可能性を打ち破っているのではありません。境界の内側で重複を除いています。Apache Kafka のプロデューサーのドキュメントははっきり書いています。冪等プロデューサーは、Kafka の配送セマンティクスを「少なくとも1回」から「ちょうど1回の配送」に強化します。仕組みは、ブローカーがプロデューサー ID とシーケンス番号を追跡することです(配送セマンティクス)。そして、プロデューサーが冪等性を保証できるのは「単一のセッション内で送ったメッセージ」だけで、アプリケーションが行う再送は重複を除けないので避けるように、と書かれています(KafkaProducer の javadoc)。レシピはこれがすべてです。メッセージごとの識別子、受け手の側の記憶、そして明示された範囲です。

実務での言い方にすると、こうなります。ちょうど1回の処理 = 少なくとも1回の配送 + 冪等な効果。

実験:3つのクライアントと、不安定なネットワーク

下のスクリプトは、ローカルの HTTP サーバーを起動します。ハンドラーは最大 60 ms かかり、副作用(意図ごとのカウンターの加算)を適用します。「ネットワーク」はリクエストの 15% を届く前に落とし、クライアントは 30 ms で待つのをやめます。そのため、かなりの数のリクエストは、クライアントが聞くのをやめたあとに適用されます。3つのクライアントが、それぞれ200件の意図を適用しようとします。

  • 再試行しない:1回だけ送る。
  • 再試行する(キーなし):最大6回試行し、フルジッターのバックオフを使う。
  • 再試行する(同じキー):同じだが、意図ごとに固定した Idempotency-Key を付ける。サーバーはキーを記録し、重複には保存した結果を返す。
once.mjs
// Three clients, one flaky network, one side effect that must not happen twice.
// Requires Node 18+ (global fetch). Run: node once.mjs   (counts vary slightly between runs: real timers)
import http from "node:http";

const INTENTS = 200;                 // distinct things the "user" wants to happen exactly once
const CONCURRENCY = 10;
const applied = new Map();           // intent id -> how many times the side effect really ran
const seen = new Map();              // idempotency key -> { fingerprint, promise }  (the dedupe store)

const server = http.createServer(async (req, res) => {
  let body = ""; for await (const c of req) body += c;
  const key = req.headers["idempotency-key"];
  const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };

  const run = async () => {                                    // the side effect
    await new Promise((r) => setTimeout(r, Math.random() * 60)); // slow enough that clients sometimes give up first
    const { intent } = JSON.parse(body);
    applied.set(intent, (applied.get(intent) ?? 0) + 1);
    return { status: 201, intent };
  };

  if (!key) return send(201, await run());                     // no key: every request is a new request
  const fingerprint = body;
  const prev = seen.get(key);
  if (prev && prev.fingerprint !== fingerprint) return send(422, { error: "key reused with a different request" });
  if (prev) { const r = await prev.promise; return send(r.status, r); }   // duplicate or concurrent duplicate: share the one result
  const promise = run();
  seen.set(key, { fingerprint, promise });                     // record BEFORE awaiting, so concurrent duplicates find it
  const r = await promise;
  send(r.status, r);
});

// A flaky network: drops 15% of requests before they arrive; the client gives up after 30 ms.
const call = async (intent, key) => {
  if (Math.random() < 0.15) throw new Error("request lost");
  const headers = { "content-type": "application/json", ...(key && { "idempotency-key": key }) };
  const res = await fetch(url, { method: "POST", headers, body: JSON.stringify({ intent }), signal: AbortSignal.timeout(30) });
  if (res.status >= 500) throw new Error("server error");
  return res.status;
};

const jitter = (n, base = 5, cap = 100) => Math.random() * Math.min(cap, base * 2 ** n); // full jitter
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const clients = {
  "never retry": async (i) => { try { await call(i); } catch {} },
  "retry, no key": async (i) => { for (let n = 0; n < 6; n++) { try { return await call(i); } catch { await sleep(jitter(n)); } } },
  "retry, same key": async (i) => { const key = `intent-${i}`; for (let n = 0; n < 6; n++) { try { return await call(i, key); } catch { await sleep(jitter(n)); } } },
};

await new Promise((r) => server.listen(0, "127.0.0.1", r));
const url = `http://127.0.0.1:${server.address().port}/`;

for (const [name, client] of Object.entries(clients)) {
  applied.clear(); seen.clear();
  let next = 0;                                                // a small worker pool, so that we measure the protocol and not connection storms
  await Promise.all(Array.from({ length: CONCURRENCY }, async () => { while (next < INTENTS) await client(`${name}-${next++}`); }));
  await sleep(200);                                            // let slow in-flight requests finish
  const counts = [...applied.values()];
  console.log(`${name.padEnd(16)} lost=${INTENTS - counts.length}  applied once=${counts.filter((c) => c === 1).length}  duplicated=${counts.filter((c) => c > 1).length}`);
}
server.close();

1回の実行結果です(Node.js 20.19、Linux 6.12。並行ワーカーは10。実タイマーなので、実行のたびに数字は変わります)。

never retry      lost=32  applied once=168  duplicated=0
retry, no key    lost=0  applied once=95  duplicated=105
retry, same key  lost=0  applied once=200  duplicated=0

何回か実行して、同じ傾向でした。キーがなければ約半数の意図が2回以上適用され、キーがあれば0件でした。再試行しないクライアントは、仕事の約10〜20% を失いました。(1行目の「lost」は、届く前に落ちたリクエストだけです。クライアントは、適用されたリクエストの多くでもタイムアウトしましたが、それを知りませんでした。それが要点です。)

修正に見えるバグ

サーバーのハンドラーの順序を見てください。

const promise = run();
seen.set(key, { fingerprint, promise });   // record BEFORE awaiting
const r = await promise;

キーは効果が終わる前に記録され、最初の処理が走っている間に届いた重複は、同じ promise を待ちます。seen.set を await run() のあとに動かしても、コードは「重複排除のストアを持っている」ように見えますが、並行する重複はどちらもそれを見逃します。実際にその変更をして実行しました。

retry, same key  lost=0  applied once=111  duplicated=89

キーを正しく送っているクライアントが、200件のうち89件の意図を重複させました。(件数はタイミングで動きます。同じ変種をあとで3回実行したら、75件、96件、90件でした。)この隙間は、クライアントのタイムアウトがまさに作る状況です。最初のリクエストがまだ走っている間に、再試行が届くのです。

冪等キーの契約

キーは、クライアントとサーバーの間の約束です。サーバー側は、小さな状態機械です。

リクエストサーバーの動作ステータス
キーを見たことがないキーを記録し、操作を実行する2xx
キーを見たことがある、同じリクエスト、完了済み保存した応答を返す。再実行しない元のステータス
キーを見たことがある、同じリクエスト、実行中完了を待つか、あとで来るようクライアントに伝える待ったうえでの 2xx、または 409
キーを見たことがある、異なるリクエスト本文拒否する。クライアントがキーを誤用している422

これらのステータスの選び方は、(失効した)Idempotency-Key ヘッダーの IETF ドラフトに沿っています。そのドラフトは、処理中のリクエストに 409、異なるペイロードでのキー再利用に 422 を提案していました。2026-10-04 時点で、Datatracker は draft-ietf-httpapi-idempotency-key-header-07 を、失効した Internet-Draft(最終更新 2026-04-18)として表示しており、RFC ではありません。比較のための慣習と考え、標準とは考えないでください。

クライアント側も、同じくらい重要です。

  1. キーは、最初の送信の前に、意図ごとに1回だけ生成する。 試行ごとではありません。再試行のループの中で生成したキーは、毎回違うキーになり、キーがないのと同じです。
  2. キーを、保留中の操作と一緒に永続化する。 試行の合間にアプリが再起動しても、再起動後の再試行は同じキーを使わなければなりません。キューに入れたリクエストの隣に保存します。
  3. 別の意図にキーを再利用しない。 サーバーの 422 は安全網で、計画ではありません。
  4. 再試行は、バイト単位で同一にする。 サーバーがペイロードをフィンガープリントするなら、少なくとも意味的に同一にします。

キーと効果は、一緒にコミットされなければならない

実験にはメモリ上の Map で十分ですが、本物のサーバーは「適用」と「記録」の間でクラッシュしえます。効果がコミットされてキーがされていなければ、再試行で効果が再び実行されます。キーがコミットされて効果がされていなければ、起きていないことへの応答が再生されます。対策は、この2つを1つの原子的なステップにすることです。リレーショナルデータベースなら、同じトランザクションにします。

atomic_dedupe.py(SQLite、Python 標準ライブラリのみ)
# The dedupe record and the side effect must commit together. Run: python3 atomic_dedupe.py
import sqlite3

db = sqlite3.connect(":memory:", isolation_level=None)   # we manage transactions ourselves
db.executescript("""
  CREATE TABLE balance (id INTEGER PRIMARY KEY, amount INTEGER);
  INSERT INTO balance VALUES (1, 0);
  CREATE TABLE idempotency (key TEXT PRIMARY KEY, response TEXT);
""")

def charge(key, amount, crash_before_commit=False):
    db.execute("BEGIN IMMEDIATE")
    row = db.execute("SELECT response FROM idempotency WHERE key = ?", (key,)).fetchone()
    if row:                                   # duplicate: replay the stored answer, do nothing else
        db.execute("COMMIT")
        return row[0]
    db.execute("UPDATE balance SET amount = amount + ? WHERE id = 1", (amount,))   # the side effect
    if crash_before_commit:
        db.execute("ROLLBACK")                # the process dies here: neither the effect nor the key survives
        raise RuntimeError("crashed before commit")
    db.execute("INSERT INTO idempotency VALUES (?, ?)", (key, f"charged {amount}"))
    db.execute("COMMIT")
    return f"charged {amount}"

def balance():
    return db.execute("SELECT amount FROM balance").fetchone()[0]

try:
    charge("k1", 100, crash_before_commit=True)
except RuntimeError as e:
    print("first attempt:", e, "| balance =", balance())
print("retry         :", charge("k1", 100), "| balance =", balance())
print("duplicate     :", charge("k1", 100), "| balance =", balance())
first attempt: crashed before commit | balance = 0
retry         : charged 100 | balance = 100
duplicate     : charged 100 | balance = 100

「クラッシュ」は更新とキーの記録の両方をロールバックするので、再試行は効果を1回だけ適用します。そのあとの重複はキーを見つけて、応答を再生します。(これは SQLite の BEGIN IMMEDIATE を使っており、書き込みを直列化します。他のデータベースでは、同じトランザクションの中で、キーに対する一意制約つきの挿入を行うのが通常のパターンです。その版はここでは実行していません。)

ここは、保証の範囲が終わる場所でもあります。効果が第三者の呼び出し(メール、決済プロバイダー)だった場合、あなたのトランザクションはそれをロールバックできません。唯一の防御は、冪等性の識別子を次に渡すことです。同じキー、またはそこから導いたキーを、次のホップに転送します。冪等性はエンドツーエンドの性質で、キーを尊重しないホップが1つあれば、上流のすべてにとってそれが壊れます。

再試行:バックオフ、ジッター、予算

すぐに再試行すると、遅いサーバー1台が再試行の嵐になります。指数バックオフは試行の間隔を広げ、ジッターは、同じ瞬間に失敗したクライアントが別々の瞬間に再試行するようにします。よく引用される解析は Exponential Backoff And Jitter で、次の変種を「Full Jitter」と呼んでいます。

$$ \text{sleep}_n = \mathrm{random}\bigl(0,\ \min(\text{cap},\ \text{base}\cdot 2^{n})\bigr) $$

ここで \(n\) は、これまでに失敗した試行の回数です。決定的なモデルで効果が分かります。1000のクライアントが同じ瞬間に失敗し、すべての再試行がまた失敗するとして、スケジューリングだけを観察します。

jitter.mjs
// 1000 clients fail at the same instant (say, the server restarts). When do their retries arrive?
// Deterministic: seeded PRNG. Run: node jitter.mjs
const rng = (a) => () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 2 ** 32; };
const rand = rng(42);
const BASE = 100, CAP = 10_000, CLIENTS = 1000, ATTEMPTS = 5; // ms

const strategies = {
  "no jitter":     (n) => Math.min(CAP, BASE * 2 ** n),
  "full jitter":   (n) => rand() * Math.min(CAP, BASE * 2 ** n),
  "equal jitter":  (n) => { const d = Math.min(CAP, BASE * 2 ** n); return d / 2 + rand() * (d / 2); },
  "decorrelated":  (n, prev) => Math.min(CAP, BASE + rand() * ((prev ?? BASE) * 3 - BASE)),
};

for (const [name, delay] of Object.entries(strategies)) {
  const buckets = new Map();                                // 10 ms bucket -> number of retry requests
  for (let c = 0; c < CLIENTS; c++) {
    let t = 0, prev;
    for (let n = 0; n < ATTEMPTS; n++) {
      prev = delay(n, prev); t += prev;                      // every retry fails again, so we measure pure scheduling
      const b = Math.floor(t / 10); buckets.set(b, (buckets.get(b) ?? 0) + 1);
    }
  }
  const peak = Math.max(...buckets.values());
  console.log(`${name.padEnd(13)} peak retries in one 10 ms bucket: ${String(peak).padStart(4)}   (total ${CLIENTS * ATTEMPTS})`);
}
no jitter     peak retries in one 10 ms bucket: 1000   (total 5000)
full jitter   peak retries in one 10 ms bucket:  157   (total 5000)
equal jitter  peak retries in one 10 ms bucket:  214   (total 5000)
decorrelated  peak retries in one 10 ms bucket:   73   (total 5000)

ジッターがなければ、1000のクライアントすべてが、毎ラウンド同じ 10 ms のバケットでサーバーに到達します。どのジッターでも、ピークは大きく下がります。この出力から3つのジッターの変種に優劣をつけるのは避けてください。最初のラウンドの窓の幅がそれぞれ違うので、ピークの違いはそれだけでも生じます。変種どうしの比較は、完了までの時間と総作業量で行っている、リンク先の解析を見てください。

バックオフは、再試行の方針の半分にすぎません。もう半分は、いつやめるか、いつ始めないかです。

  • 再試行してよいものだけを再試行する。 ネットワークエラーとタイムアウト、503(Retry-Afterがあれば尊重する)、429(RFC 6585 §4)。リクエストが間違っていると伝える 4xx は再試行しない。
  • 試行回数と総時間に上限を置く。 期限のない再試行ループはリークです。
  • 再試行は1つの層で行う。 3つの層がそれぞれ最大3回試行すると、いちばん下の層は、ユーザーの1回の操作に対して最大27個のリクエストを見ることになります。
  • 一時的な失敗を最終結果として保存しない。 サーバーが 503 をキーに紐づけて保存すると、以降のすべての再試行で、その失敗が永遠に再生されます。保存するのは、終端の結果です。

冪等性がそれでも壊れる場所

失敗結果防御
効果のあとにキーを記録する並行する重複がどちらも実行される(僕の実行では200件中89件)先に記録する、またはキーをロックする。重複は最初の処理を待たせる。
試行ごとに新しいキー重複排除がまったく働かない最初の送信の前に、意図ごとに1回だけ生成する。
アプリの再起動をまたいでキーを保存していないクラッシュ後の重複キューに入れた操作と一緒にキーを保存する。
同じキーで違うペイロード間違った結果が再生されるリクエストをフィンガープリントし、422 を返す。
効果とキーが別々のコミットクラッシュの隙間で、効果が2回走る、または幻の結果が再生される1つのトランザクション(またはトランザクション内の一意制約)。
キーの保持期間がクライアントの再試行窓より短い古い再試行がもう一度実行されるクライアントが再試行しうる最長の時間より長く保持する。
下流のホップがキーを無視する境界の下で重複が再び現れるキーを転送する。または下流に独自の冪等性の識別子を持たせる。
ジッターなしの再試行負荷の同期した波フルジッター。試行回数の上限。Retry-After を尊重。
すべての層で再試行負荷が掛け算で増える(3 x 3 x 3 = 27)再試行は1つの層で行い、期限は下に渡す。
一時的な失敗を結果として保存エラーが永久に再生される終端の結果だけを保存する。

キーが必要な場面と、不要な場面

冪等キーを使うのは、自然には冪等でない副作用があり、クライアントが再試行しうる操作です。作成、課金、送信、キューへの投入などです。

次の場合は、要らないかもしれません。

  • 操作がすでに RFC 9110 §9.2.2 の意味で冪等である場合:リソースを置き換える PUT、識別子で指定する DELETE、読み取り。
  • 操作に自然な一意の識別子がある場合。その識別子への一意制約が、そのまま重複排除のストアになります。
  • 最大1回で許容される場合(ベストエフォートのテレメトリなど):1回送って先へ進む。
  • 効果に再試行の経路がそもそもない場合。ユーザーは待つより、エラーを見たいはずだからです。

動かして、1つずつ変える

# Node.js 18 以降(実行したのは Node.js 20)。依存関係なし。
node once.mjs        # 数秒かかる。数字は実行ごとに変わる
node jitter.mjs      # 決定的
python3 atomic_dedupe.py

そして、1か所ずつ変えてみてください。once.mjs の seen.set を await の下に動かして、重複が戻ってくるのを見ます。再試行のループの中でキーを生成します。ネットワークの損失率を 50% に上げます。クライアントのタイムアウトを 200 ms にして、応答が失われるケースをなくし、それでも差が出るクライアントを確かめます。

再試行という賭け

再試行はすべて、「最初の試行は成功しなかった」という賭けです。冪等キーは、その賭けが外れたときに、何も起きないようにします。2回目の試行が最初の試行を見つけて、その結果を返すのです。その代償は、受け手側のメモリ、原子的なコミット1回、そして最初の送信の前にキーを決めるという規律です。得られるのは、「ちょうど1回」がネットワークの性質ではなく、名前を付けられる境界の性質になることです。

実験で確認したことと、していないこと

すべて Linux 6.12、Node.js 20.19、Python 3.13 で実行しました。実験は、単一のプロセス、ローカルのネットワーク、メモリ上の重複排除ストアで行っており、損失率とタイミングは僕が選んだパラメーターで、実際のネットワークの計測値ではありません。once.mjs の件数は、実タイマーを使うため、実行ごとに変わります。ジッターの出力はスケジューリングのモデルで、負荷試験ではありません。PostgreSQL や MySQL 版の原子的な重複排除、キーの有効期限、第三者の下流は試していません。仕様やドキュメントについての記述は、リンク先のページから 2026-10-04 時点で確認したものです。