HTTP リクエストが失敗したら、いつ再試行すべきか?
リクエストをループで囲み、失敗したらもう一度送る。コードを書くこと自体は簡単です。難しいのは、クライアントが失敗を観測したとき、サーバーが処理しなかったのか、それとも結果を受け取れなかっただけなのかを判断することです。
今回はローカルで実験しました。サーバーは POST を受け取ると、メモリ上のカウンターを一つ増やしてから接続を切断します。クライアントは同じリクエストを二度送り、二度とも例外を受け取りました。それでもサーバーのカウントは二でした。実際の注文や外部 API は扱っていませんが、「結果が不明」を「実行されなかった」と読み替える危険性は示せます。
この記事では、HTTP クライアントに再試行を加える前に満たすべき条件、待機時間、すぐ停止すべき状況を考えます。対象を限定した GET クライアントを実装し、回復する経路と停止する境界を確かめた後、書き込みに追加のプロトコルが必要な理由へ戻ります。
1. 三種類の「失敗」を区別する
一つ目は、利用できる応答を取得できなかった場合です。接続の切断や、本文を読み取る途中のタイムアウトが該当します。これはクライアントから見た状態であり、この情報だけでサーバーの処理の有無は判断できません。
二つ目は、HTTP 応答を取得したものの、期待するステータスではなかった場合です。404、429、503 はこの層にあり、ステータスを個別に確認する必要があります。fetch() の Promise は、404 が返ったという理由だけでは拒否されません。MDN に明記されており、この記事の Node.js 実験でも確認しています。そのため、catch の中だけで再試行すると HTTP ステータスの判定が抜けます。MDN:fetch の戻り値と例外
三つ目は、HTTP リクエストは完了していても、業務上は先へ進めない場合です。例えば、200 の本文で「在庫不足」を返す API を想定できます。これは説明用の仮定であり、今回の実験サーバーの動作ではありません。在庫や入力が変わっていなければ、同じリクエストを送ることで問題が解消する根拠はありません。業務の判定には API 独自の契約が必要で、汎用の HTTP ラッパーには分かりません。
したがって、再試行の条件は明示的な許可リストにします。今回の実験では、GET が 429 または 503 を受け取った場合だけ再試行します。404、501、ネットワーク例外、タイムアウトでは停止します。429 はリクエストが多すぎること、503 は一時的な過負荷やメンテナンスなどを示しますが、次の試行の成功を約束するものではありません。RFC 6585 §4、RFC 9110 §15.6.4
この許可範囲は、実験用の保守的な方針です。実際の API では、特定のネットワークエラーや 502、504 を再試行できる場合、404 に特別な回復条件がある場合も考えられます。追加する判断は契約に基づくべきで、ステータスの分類だけから推測すべきではありません。
2. 前提は、繰り返し実行したときの意味
冪等性とは、同一リクエストを複数回実行したときの意図されたサーバー側の効果が、一回の場合と同じであることです。応答が毎回同じである必要はありません。HTTP 仕様でも、非冪等なリクエストの自動再試行には慎重な扱いを求めています。RFC 9110 §9.2.2
読み取りなら理解しやすいでしょう。公開情報を再取得する間に内容が更新され、応答が変わることはあります。ただし、API が読み取りの意味を守っていることが前提です。「通知を送信する」処理を GET にしても、メソッド名によって繰り返し実行が安全になるわけではありません。
書き込みでは、タイムアウトから「やり直せる」とは判断できません。サーバーで書き込みが完了し、返送中の応答だけが失われていたら、二度目の処理は既に変化した状態に対して行われます。まず、操作番号、結果を問い合わせる方法、サーバー側の重複排除が提供されているかを確認します。その情報がないまま回数を減らしても、重複の可能性を減らすだけで、なくすことはできません。
そのため、この記事のクライアントは GET に固定し、任意の method を指定する引数は用意しません。安全に扱える操作の範囲は、ループの実装より先に決める事項です。
3. 小さなクライアントにも二つの時間制限が必要
添付の完全なスクリプト http-retry-lab.mjs には、クライアント、一時的な HTTP サーバー、アサーションを含めています。npm パッケージは不要です。2026 年 9 月 29 日に実行した環境は macOS、Node.js v22.23.1 です。直接実行すると 127.0.0.1 のランダムなポートだけに接続し、ファイルへの書き込みや外部サービスの呼び出しは行いません。
空のディレクトリに添付ファイルを保存して実行します。
node --version
node http-retry-lab.mjs
エクスポートされる getText() の既定値は以下です。実験を観察しやすくする値であり、あらゆる本番 API に勧める設定ではありません。
| パラメーター | 既定値 | 意味 |
|---|---|---|
maxAttempts | 3 | 最初のリクエストを含むため、追加の再試行は最大二回 |
totalMs | 4000 | 一つの論理操作の総予算。リクエスト、本文の読み取り、待機を含む |
attemptMs | 1000 | 一回のリクエストとその本文の読み取りの制限時間 |
baseMs | 50 | 最初のバックオフ時間の選択範囲の上限 |
capMs | 200 | 増加する選択範囲の上限 |
回数だけを制限しても、遅い応答なら長く待つ可能性があります。一回ごとの時間制限だけなら、再試行するたびに新しい制限時間を得るため、待機と合わせて呼び出し側が許容する時間を超えかねません。ここでは、最初の呼び出しから総予算を計算し、以後の試行も同じ予算を使います。
実装では、利用者によるキャンセル、総予算、一回の制限時間をシグナルで組み合わせ、リクエストと本文の読み取りで共有します。待機にもキャンセルのシグナルを渡します。Node.js は AbortSignal.timeout()、AbortSignal.any()、シグナルでキャンセルできる Promise タイマーを提供しています。Node.js v22.23.1:AbortSignal、Promise タイマー
総予算は performance.now() による経過時間でも確認します。成功でない応答を受け取ったら、不要になった本文をキャンセルしてから次へ進むかを決めます。不要なエラー本文を読むために、その判断を遅らせないようにしています。
これは非同期プログラムの時間制約です。イベントループを塞ぐ同期処理を中断したり、サーバーで完了した操作を取り消したりはできません。また、タイマーがミリ秒単位で正確に実行される保証もありません。Node.js のタイマーの説明
4. バックオフで増やすのは選択範囲
多数のクライアントが同時に失敗し、全員が固定で 100 ミリ秒待つと、再び同時に戻ってくる可能性があります。ジッターの目的は、次のリクエストの時刻を分散することです。AWS の記事はシミュレーションでこの違いを示しています。ここでは full jitter の考え方を使いますが、その負荷実験を再現したわけではなく、性能の数値も引用しません。Exponential Backoff And Jitter
添付ファイルの中心となる関数は次のとおりです。
export function fullJitter(attempt, baseMs, capMs, random = Math.random) {
const value = random();
if (!Number.isFinite(value) || value < 0 || value >= 1) {
throw new RangeError('random() must return a value in [0, 1)');
}
const ceiling = Math.min(capMs, baseMs * 2 ** (attempt - 1));
return Math.floor(value * ceiling);
}
最初の失敗後は 50 ミリ秒未満の整数、二度目の失敗後は 100 ミリ秒未満の整数から選び、選択範囲の上限は最大 200 ミリ秒まで増加します。個々の待機時間は前回より短くなることもあります。増えていくのは上限です。
アサーションを再現可能にするため、テストでは乱数の入力を 0.5 に固定し、25、50、100、100 ミリ秒を得ました。入力がゼロ、一に近い値、範囲外の場合も確認しています。検証したのは計算式と分岐で、乱数分布の品質や高い同時負荷での処理能力ではありません。ログの plannedWaitsMs は計算した待機予定値で、正確な実測時間ではありません。
5. Retry-After をそのままミリ秒として扱わない
Retry-After は非負の整数秒、または HTTP 日付です。Retry-After: 1 は一秒を表し、一ミリ秒ではありません。RFC 9110 §10.2.3
この実装では、秒をミリ秒に換算します。日付は現在の実時刻との差を計算し、過去の日付ならゼロにします。解析した待機時間とジッターの大きい方を採用し、総予算の残りと比較します。サーバーが 60 秒の待機を要求し、呼び出し側には一秒未満しか残っていなければ、wait-exceeds-budget で停止します。60 秒を 200 ミリ秒に切り詰めて再送すると、指定より早く要求してしまいます。
互換性に影響する制約もあります。添付ファイルが認識する日付は、現在の IMF-fixdate 形式だけであり、HTTP 日付の完全なパーサーではありません。小数秒、負数、任意の文字列、未対応の形式は unsupported-retry-after とし、ゼロと推測しません。安全な整数の範囲を超える秒数は無限の待機と扱い、その後の予算確認で拒否します。
不確かな入力で停止することを意図した選択です。実際のサービスに接続するなら、その応答形式に合わせて互換性を補い、マシンの時計が信頼できるかも確かめます。日付の待機には実時刻、総予算には経過時間を使い、役割を分けています。
6. 実行結果:回復することと、停止すること
スクリプトでは、リクエスト数、停止理由、待機予定値をアサーションで確認します。以下は上記環境での実行結果で、リクエスト数はサーバーのカウンターから取得しました。短いタイムアウトを使う行では既定値を上書きしています。
| サーバーまたは呼び出しの条件 | リクエスト数 | クライアントの結果 |
|---|---|---|
| 503、503、200 | 3 | 成功。待機予定値は 25、50 ミリ秒 |
| 常に 404 | 1 | http-stop |
| 常に 501 | 1 | http-stop |
| 常に 503 | 3 | http-stop。回数上限に到達 |
503、maxAttempts: 1 | 1 | http-stop。追加の再試行なし |
429、Retry-After: 1、次は 200 | 2 | 成功。待機予定値は 1000 ミリ秒 |
503、Retry-After: 60、総予算 1000 ミリ秒 | 1 | wait-exceeds-budget |
503、Retry-After: 1.5 | 1 | unsupported-retry-after |
| 応答ヘッダーを 250 ミリ秒遅延、一回の制限は 50 ミリ秒 | 1 | attempt-timeout |
| 200 のヘッダーは即時、本文の終了は 250 ミリ秒遅延、一回の制限は 50 ミリ秒 | 1 | attempt-timeout |
| 同じ遅い本文、総予算 50 ミリ秒、一回の制限は 1000 ミリ秒 | 1 | deadline |
| 呼び出し前にキャンセル済み | 0 | cancelled |
| 待機の準備中にキャンセル | 1 | cancelled。次のリクエストなし |
| 待機中にキャンセル | 1 | cancelled。次のリクエストなし |
遅い本文のテストは特に有用です。200 と本文の最初の部分を受け取っても、読み取りが完了したとは限りません。ヘッダーを受け取った時点でタイムアウトを解除すると、残りのダウンロードで長時間止まる可能性があります。今回の実装は response.text() を同じ制限時間に含めるため、この経路でも停止できます。
キャンセルのテストは別の要件を確かめます。利用者が結果を必要としなくなったら、待機を抜けて追加のリクエストを送らないことです。サーバーのロールバックは検証していません。送信済みのリクエストがサーバーに届いている場合、クライアントのキャンセルでサーバーの処理まで止まる保証はありません。
7. 書き込みの反例:同じヘッダーでも重複は防げない
冒頭の POST ハンドラーが行うのは、この処理だけです。
request.resume();
request.on('end', () => { submitted++; request.socket.destroy(); });
クライアントは二度とも同じ本文と、同じ X-Demo-Key ヘッダーを送ります。サーバーには、このヘッダーを処理する実装がありません。出力は次のとおりです。
{"case":"response-lost-after-write","clientErrors":2,"serverActions":2,"sameDemoKey":true}
この実験では、二度の例外がいずれもメモリへの書き込み後に発生しました。また、単に「番号」をヘッダーへ追加しても重複排除にはならないと分かります。反例は独立した POST のループであり、この記事の GET クライアントを書き込み用に拡張したものではありません。
再試行できる書き込みを設計するなら、サーバー側のプロトコルとして考えます。一つの論理操作では各試行に同じ番号を使い、サーバーは番号とリクエスト内容を関連付けます。重複した要求には何を返すか、処理中はどう応答するか、番号をいつまで保持するか、同じ番号で異なる内容が来たらどう拒否するかを定めます。重複排除の記録と業務上の書き込みも整合させる必要があり、書き込みだけ完了して記録が残らない隙間があると、再び重複が起きる可能性があります。
これは反例から導いた設計上の提案です。添付ファイルには永続的な重複排除、トランザクション、実際の業務システムを実装していません。支払い API やタスク作成 API が安全に再試行できると、この結果から主張することもできません。既存の API では、実際に提供されている契約を読んでからクライアントを実装します。
8. 本番へ入れる前に、既に再試行している層を探す
業務コードにもう一つループを加える前に、SDK、プロキシ、タスクのフレームワークの方針を確認します。AWS の信頼性に関する指針も、試行回数と総時間の制限、複数の層で再試行を重ねないことを勧めています。Limit retries
三層の呼び出しがあり、各層が最大三回試行し、毎回その下の層の三回の失敗をすべて引き起こすと仮定します。最下層へ届くリクエストは 3 × 3 × 3 = 27 回になり得ます。これは仮定に基づく回数の計算で、この記事で測った本番のトラフィックではありません。上の層に「三回」と書かれていても、下流で三回に収まる保証はないということです。
まず再試行を担当する層を決め、一つの論理操作について総時間、実際の試行回数、各ステータス、待機予定、最後の停止理由を記録します。最後に成功したことだけを見ると、追加の負荷が隠れます。失敗した試行だけを数えると、一つの操作を複数のエラーへ分割してしまい、利用者がどれだけ待ったかという視点を失います。
添付ファイルは制御条件を学ぶためのもので、汎用の本番ライブラリではありません。リダイレクトを拒否し、小さなテキストだけを読み、応答サイズの制限はありません。実験は DNS、TLS、実際のインターネット、並行したレート制限、SDK との統合を対象にしていません。大きなファイル、長時間のストリーミング応答、業務上の書き込みには、読み取り方法、リソース制限、API のプロトコルを改めて決める必要があります。
最初の問いへ戻ると、繰り返しの効果を許容でき、失敗が回復する根拠があり、残りの時間予算で続けられる場合に、再試行する意味があります。回数上限、時間予算、待機の方針は判断を制御可能にし、サーバーの操作の意味は判断が成立するかを決めます。二度のエラーでも二度実行された反例を実装のそばに残し、成功率を上げる前に結果を確認する必要があることを思い出せるようにしたいと思います。
文書と外部リンクの確認日:2026 年 9 月 29 日。実験結果は、添付したローカルスクリプトについてのものです。