HTTP/2のGOAWAYの後に残ったリクエストは再送してよいか:Last-Stream-ID、REFUSED_STREAM、HTTP/3との違い
GOAWAYのLast-Stream-IDは、サーバーが処理したかもしれない最後のストリーム番号だ。それより大きい番号のリクエストはPOSTでも新しい接続で再送できる。RFC 9113の2段階の終了、REFUSED_STREAM、HTTP/3で変わる境界をまとめた。
サーバーを再デプロイしたりプロキシを再起動したりすると、開いていたHTTP/2接続のクライアントはGOAWAYフレームを受け取る。そのとき、送信済みでまだレスポンスが返っていないリクエストがいくつか残っていることがある。これを新しい接続で送り直してよいかは、メソッドだけでは決まらない。判断の大部分は、GOAWAYに入っている1つのストリーム番号で決まる。
この記事はRFC 9113(HTTP/2、2022年6月、RFC 7540と8740を廃止)の5章の一部、6.4節、6.8節、7章、8.7節、9.1節と、RFC 9114(HTTP/3、2022年6月)の2.2節、4.1.1節、5章、6.1節、7.2.6節、8.1節、付録A、そしてRFC 9110の9.2節に基づく。nginx、Envoy、Go、ブラウザといった個々の実装がGOAWAYを送受信するときに実際どう動くかはバージョンや設定によって異なるため、扱わない。
GOAWAYフレームの中身
HTTP/2のGOAWAYはタイプ0x07のフレームだ(6.8節)。特定のストリームではなく接続全体に適用されるので、フレームヘッダーのストリーム識別子は0でなければならず、0以外なら受信側はPROTOCOL_ERRORの接続エラーとして扱わなければならない(MUST)。ペイロードは3つの部分からなる。
GOAWAY (type=0x07, stream=0)
+---+-------------------------------+
| R | Last-Stream-ID (31ビット) |
+---+-------------------------------+
| Error Code (32ビット) |
+-----------------------------------+
| Additional Debug Data (可変, 任意) |
+-----------------------------------+
- Last-Stream-ID:このフレームの送信側が処理した、またはこれから処理するかもしれない、相手が開いたストリームのうち最大の番号。どのストリームも処理していなければ0にできる。
- Error Code:接続を閉じる理由。正常な終了ならNO_ERROR(
0x00)を使う(7章)。 - Additional Debug Data:診断用の不透明なデータ。プロトコル上の意味は持たない。
ここでいう「処理した」はRFCが定義している。ストリームのデータの一部でも上位層のソフトウェアに渡され、その層がそれを受けて何かをした可能性がある、という意味だ。リクエストがアプリケーションのハンドラーまで届いたなら、処理されたものとみなす。
Last-Stream-IDで分かれる2つの範囲
HTTP/2ではクライアントが開くストリームは奇数番号を使い、新しいストリームの番号はその端点が以前に開いたどのストリームよりも大きくなければならない(5.1.1節)。そのため、1つの番号で「ここまで」と「ここから」を分けられる。
クライアントがストリーム1、3、5、7、9でリクエストを送り、まだどれも終わっていないところに、サーバーがGOAWAY(Last-Stream-ID=5, NO_ERROR)を送ったとする。
クライアントのストリーム: 1 3 5 | 7 9
処理された可能性 | 処理されていない
(完了することも) | (新しい接続で再試行可)
^
Last-Stream-ID = 5
- 7と9:サーバーはGOAWAYを送った後、Last-Stream-IDより大きい番号のストリームに届いたフレームを無視する。受信側はこれらのストリームを最初から作られなかったものとして扱い、新しい接続で送り直せる(6.8節)。8.7節はさらに踏み込んでいる。処理されていないリクエストは失敗したわけではないので、POSTのような冪等でないメソッドでもクライアントは自動で再試行してよい(MAY)。
- 1、3、5:処理された可能性がある。GOAWAYはすぐに接続を閉じるという意味ではなく、サーバーは接続を開いたまま進行中のストリームを最後まで終えられる。レスポンスが普通に返ってくれば、それで終わりだ。問題はこれらのストリームが終わる前に接続が閉じた場合で、そのとき再試行できるのはGET、PUT、DELETEのような冪等な操作だけになる(6.8節)。
また、GOAWAYを受け取った側はその接続で新しいストリームを開いてはならない(MUST NOT)。新しいリクエストは新しい接続で送る。
この保証は、サーバーが番号を正直に書くことが前提になる。8.7節は、処理していないと保証できないストリームを処理していないと示してはならないと定めている(MUST NOT)。あるストリームのフレームを1つでもアプリケーション層に渡したなら、GOAWAYのLast-Stream-IDはその番号以上でなければならない(MUST)。
GOAWAYなしで切れた場合
接続がGOAWAYなしで終わった場合、Last-Stream-IDは事実上とりうる最大の値とみなされる(6.8節)。終わっていないすべてのストリームが「処理された可能性がある」側に入るということだ。5.4.3節も、ストリームがopenやhalf-closedの状態のままTCP接続が閉じられたりリセットされたりすると、それらのストリームは自動で再試行できないと書いている。
RFC自身が挙げる例がPOSTだ。クライアントがPOSTを送った瞬間にサーバーが接続を閉じ、GOAWAYがなかったとすると、クライアントはサーバーがそのPOSTの処理を始めたかどうかを知ることができない。だから端点は接続を閉じる前に常にGOAWAYを送るべきだ(SHOULD、6.8節と9.1節)。不正な振る舞いをする相手にはGOAWAYなしで接続を閉じてもよい、という例外も6.8節に書かれている。
この範囲に残ったリクエストの扱いはRFC 9110の9.2.2節が定めている。冪等でないメソッドは、リクエストの意味が実際には冪等だとわかっているか、元のリクエストが適用されなかったことを確かめる手段がない限り、自動で再試行すべきではない(SHOULD NOT)。プロキシは冪等でないリクエストを自動で再試行してはならず(MUST NOT)、クライアントは失敗した自動再試行をさらに自動で再試行すべきではない(SHOULD NOT)。メソッドごとの性質はHTTPメソッドの安全性と冪等性で別にまとめた。
2段階で閉じる:まず2^31-1、次に実際の番号
GOAWAYを送っている間にも、クライアントは新しいストリームを開いているかもしれない。サーバーがGOAWAY(5)を送り、そのフレームが届く前にクライアントがストリーム11を送ったなら、11は処理されないまま捨てられる。再試行できるクライアントなら損はないが、再試行できないクライアントはそのリクエストを失う。RFCは、自分のクライアントをHTTP/2で受けていないかもしれない中継者でこれが特に問題になると書いている。
そこで6.8節は、正常に終了したいサーバーに次の手順を勧めている。
- Last-Stream-IDを2^31-1(ストリーム番号の最大値)、エラーコードをNO_ERRORにした最初のGOAWAYを送る(SHOULD)。まもなく終了するので新しいリクエストを始めてはならない、という合図だ。この時点ではどのストリームも捨てない。
- すでに送られてきている途中のストリームが届くまで時間を置く。RFCが挙げる目安は少なくとも1往復時間(RTT)だ。
- 実際のLast-Stream-IDを書いた2つ目のGOAWAYを送る(MAY)。
- その番号以下の進行中のストリームが終わるまで接続を開いておき、その後で閉じる。
GOAWAYは複数回送ってよいが(MAY)、Last-Stream-IDを以前に送った値より大きくしてはならない(MUST NOT)。相手がすでに未処理のリクエストを別の接続で送り直しているかもしれないからだ。どのストリームが処理された可能性があるかは、最後に受け取ったGOAWAYの値が示す。
GOAWAYを送った後でも、接続の状態を変えるフレームは完全には無視できない。Last-Stream-IDより大きいストリームに届いたHEADERS、PUSH_PROMISE、CONTINUATIONはフィールド圧縮の状態を保つために最小限処理しなければならず、DATAフレームは接続のフロー制御ウィンドウに数えなければならない(MUST)。これを怠ると、圧縮やフロー制御の状態が両側でずれる。
GOAWAYを送るのはサーバーだけではない。受け取ったGOAWAYのせいでその接続をもう使う予定がなくなった側も、接続を切る前に自分のGOAWAYを送るべきだ(SHOULD)。ストリーム番号を使い切ったサーバーも、GOAWAYでクライアントを新しい接続へ移らせることができる(5.1.1節)。
ストリーム1つだけを突き返す:REFUSED_STREAM
GOAWAYが接続単位の境界だとすれば、RST_STREAMフレームのREFUSED_STREAM(0x07)はストリーム1つについての同じ約束だ。処理の前にストリームを閉じるという意味で、そのストリームのリクエストは安全に再試行できる(8.7節)。GOAWAYと同じく、本当に処理していないと保証できるときにしか使えない。
これが使われる典型的な場面が同時ストリーム数の上限だ。相手がSETTINGS_MAX_CONCURRENT_STREAMSを超えてHEADERSを送ってきたら、受信側はPROTOCOL_ERRORかREFUSED_STREAMのストリームエラーとして扱わなければならない(MUST)。5.1.2節は、どちらのコードを選ぶかで自動再試行を許すかどうかが決まると書いている。
なお、RST_STREAMのエラーコード0x07とGOAWAYのフレームタイプ0x07は数字が同じなだけで関係はない。一方はエラーコードの空間、もう一方はフレームタイプの空間の値だ。
エラーで閉じるときのGOAWAY
GOAWAYは正常な終了だけに使うものではない。接続エラーに遭遇した端点は、相手から最後に正しく受け取ったストリームの番号を入れたGOAWAYをまず送り(SHOULD)、その後TCP接続を閉じなければならない(MUST、5.4.1節)。ただしこのGOAWAYはベストエフォートで、相手に確実に届く保証はない。
正常な終了の途中でエラーが起きた場合は、NO_ERRORで送ったGOAWAYに加えて、新しいエラーコードを入れたGOAWAYをもう1つ送ってよい(5.4節)。このときもLast-Stream-IDを大きくしてはならないという規則はそのままだ。
ログでよく目にしそうなエラーコードをいくつか挙げる(7章)。
| コード | 名前 | 意味 |
|---|---|---|
| 0x00 | NO_ERROR | エラーではない。正常終了のGOAWAYで使う |
| 0x01 | PROTOCOL_ERROR | より具体的なコードがないプロトコルエラー |
| 0x02 | INTERNAL_ERROR | 予期しない内部エラー |
| 0x07 | REFUSED_STREAM | アプリケーションの処理前にストリームを拒否した |
| 0x0b | ENHANCE_YOUR_CALM | 相手が過大な負荷を生んでいるように見える |
未知のエラーコードを受け取っても特別な動作をしてはならず(MUST NOT)、INTERNAL_ERRORと同じものとして扱ってもよい(MAY)。
Additional Debug Dataは人が原因を探すときの手がかりにはなるが、意味を持たせて動作を変える根拠にしてはいけない。6.8節は、このデータにセキュリティやプライバシーに関わる内容が含まれうるため、ログなどに保存するなら不正アクセスを防ぐ適切な保護を設けなければならないと定めている(MUST)。
HTTP/3で変わること
HTTP/3にもGOAWAY(タイプ0x07)はあるが、形と境界の規則が違う。
| HTTP/2(RFC 9113) | HTTP/3(RFC 9114) | |
|---|---|---|
| 送る場所 | ストリーム0 | 制御ストリーム(control stream)。ほかのストリームで届いたらH3_FRAME_UNEXPECTEDの接続エラー |
| 値の形式 | 31ビットのLast-Stream-ID | 可変長整数。サーバーはクライアントが開いた双方向ストリームのID、クライアントはpush ID |
| エラーコード | フレーム内にある | ない。接続を閉じるときにQUICのCONNECTION_CLOSEに入れる |
| 境界 | 値以下は処理された可能性あり、値より大きければ未処理 | 値未満は処理された可能性あり、値以上は拒否 |
| 正常終了の最初の値 | 2^31-1 | 2^62-4(サーバー)、2^62-1(クライアント) |
| 値を大きくすると | 禁止(MUST NOT) | 禁止。受信側はH3_ID_ERRORの接続エラーとして扱う |
| ストリーム1つの拒否 | RST_STREAM(REFUSED_STREAM) | H3_REQUEST_REJECTED(0x010b)でストリームを中断 |
| GOAWAYなしで終了 | 最後の番号を最大値とみなす | 送ったリクエストはすべて処理された可能性があると想定しなければならない(MUST) |
いちばん取り違えやすいのは境界だ。HTTP/2の値は「処理された可能性がある最後の番号」なので、その番号自体は処理側に入る。HTTP/3の値は「このID以上は拒否」なので、そのID自体は拒否側に入る(5.2節)。
HTTP/3ではクライアントの最初のリクエストはQUICのストリーム0で送られ、以降のリクエストは4、8……と続く(6.1節)。クライアントが0、4、8、12、16でリクエストを送り、サーバーがGOAWAY(12)を送った場合はこうなる。
クライアントのリクエストストリーム: 0 4 8 | 12 16
処理された可能性 | 処理されていない
^
GOAWAY ID = 12
12と16は処理されないので、別のHTTP接続で安全に送り直せる。0、4、8の結果は、レスポンスを受け取るか、そのストリームが個別にリセットされるか、より小さいIDのGOAWAYが届くか、接続が終わるまでわからない。サーバーはこの範囲のリクエストでも、実際に処理していなければ個別に拒否してよい(MAY)。
2つの定義を並べると、処理側に入る最後のリクエストを、HTTP/2はそのリクエストのストリーム番号で書き、HTTP/3はその次のリクエストストリームIDで書くことになる。HTTP/2とHTTP/3の両方を扱うコードで比較演算子を一方からもう一方へそのまま写すと、境界にあるリクエスト1つを誤って分類する。
リクエスト1つを突き返すときの用語も分かれている。アプリケーションの処理をせずに取り消したリクエストは「拒否(rejected)」で、サーバーはH3_REQUEST_REJECTEDでレスポンスストリームを中断すべきだ(SHOULD)。一部でも処理したリクエストにこのコードを使ってはならず(MUST NOT)、その場合はH3_REQUEST_CANCELLED(0x010c)を使うべきだ(SHOULD、4.1.1節)。RFC 9114の付録A.4は、HTTP/2のREFUSED_STREAMに対応するコードとしてH3_REQUEST_REJECTEDを挙げている。
GOAWAYそのものが接続を閉じないのは同じだ(7.2.6節)。受け入れたリクエストをすべて処理した後、接続をアイドル状態にするか、すぐに閉じることができ、正常終了を終えた端点は接続を閉じるときにH3_NO_ERRORを使うべきだ(SHOULD、5.2節)。すぐに閉じなければならないときはQUICのCONNECTION_CLOSEの前にGOAWAYを送ってよく(MAY)、2つのフレームを同じパケットに入れるとクライアントがGOAWAYを受け取れる可能性が高まる(5.3節)。
クライアント側で再試行を判断する例
次の関数は、HTTP/2接続が終わったときにレスポンスを受け取りきれていなかったリクエストを、新しい接続で自動再試行してよいかを判断する。規則は上で引いたRFC 9113の6.8節と8.7節、RFC 9110の9.2.2節から取り、関数の形はこの記事で決めたものだ。
// RFC 9110が定義するメソッドのうち冪等なもの
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"]);
function canRetryOnNewConnection(
streamId: number,
method: string,
lastStreamId: number | null, // 最後に受け取ったGOAWAYの値。受け取っていなければnull
refused: boolean, // このストリームでRST_STREAM(REFUSED_STREAM)を受けたか
): boolean {
if (refused) return true; // 処理前に拒否された (8.7節)
if (lastStreamId !== null && streamId > lastStreamId) return true; // 未処理 (6.8節)
return IDEMPOTENT.has(method); // 処理された可能性あり: 冪等のときだけ (6.8節, RFC 9110 9.2.2節)
}
HTTP/2のストリーム番号は2^31-1を超えないので、numberで足りる。HTTP/3は事情が違う。ストリームIDとGOAWAYの値はQUICの可変長整数で(RFC 9114 2.2節)、先に見たとおりサーバーが送る最初のGOAWAYの値からして2^62-4だ。JavaScriptのnumberはNumber.MAX_SAFE_INTEGER(2^53-1)を超える整数をすべて正確には表せないため、その範囲では境界にあるリクエストを誤って分類しうる。HTTP/3向けに移すときは演算子を変えるだけでは足りず、streamIdとgoawayIdの両方をbigintのように値を正確に保てる型で扱う必要がある。
// HTTP/3: ストリームIDとGOAWAYの値は最大2^62-1のQUIC可変長整数
function canRetryOnNewH3Connection(
streamId: bigint,
method: string,
goawayId: bigint | null, // 最後に受け取ったGOAWAYの値。受け取っていなければnull
rejected: boolean, // このリクエストストリームがH3_REQUEST_REJECTEDで中断されたか
): boolean {
if (rejected) return true; // 処理前に拒否された (RFC 9114 4.1.1節)
if (goawayId !== null && streamId >= goawayId) return true; // 未処理 (5.2節)
return IDEMPOTENT.has(method); // 処理された可能性あり: 冪等のときだけ (RFC 9110 9.2.2節)
}
2つの関数が答えるのは、プロトコルの規則上送り直す資格があるかどうかまでだ。実際に自動再試行するには、さらにいくつかの条件がそろう必要がある。
- リクエストボディを最初から送り直せること。ストリームに流して捨てたボディは、新しい接続に載せ直せない。これはRFCの規則ではなく、実装上の前提条件だ。
- このコードをプロキシで使うなら、冪等でないリクエストは未処理が確実でも自動で再試行しない。RFC 9110 9.2.2節のプロキシに対する禁止(MUST NOT)にかかるためだ。
- 関数は何回目の試行かを見ていない。失敗した自動再試行をさらに自動で再試行しないというRFC 9110の勧告は、呼び出し側で試行回数を数えて守る必要がある。
まとめ
RFCの文言から直接出てくる規則と、それをもとにしたこの記事の提案を分けて書く。
RFCの規則
- HTTP/2でLast-Stream-IDより大きい番号のリクエストは処理されていない。冪等でなくても新しい接続で送り直せる。
- その番号以下で終わっていないリクエストは処理された可能性がある。接続が閉じたら冪等なリクエストだけを送り直す。
- GOAWAYなしで切れた接続では、終わっていないすべてのリクエストを処理された可能性があるものとみなす。
- 正常に終了するサーバーは2^31-1とNO_ERRORで最初のGOAWAYを送り、少なくとも1 RTT後に実際の番号で送り直せる。番号は大きくしない。
- HTTP/3では境界が「未満/以上」に変わり、GOAWAYにはエラーコードがない。
この記事の提案
- デプロイ手順では、プロセスを止める前に2段階のGOAWAYを送り、進行中のストリームが終わるのを待つ時間とその上限を決めておく。上限はRFCが定めていないので、サービスのリクエスト処理時間に合わせて選ぶ。
- 接続の終了を記録するときは、GOAWAYを受け取ったかどうか、その値とエラーコードを一緒に残す。Additional Debug Dataを残すならアクセス権を絞る。
- HTTP/2とHTTP/3の両方を扱うコードでは、境界の比較をプロトコルごとに分けてテストする。
出典と確認日
いずれも2026-10-22に確認した。
- RFC 9113: HTTP/2 5.1.1節、5.1.2節、5.4節(5.4.1、5.4.3を含む)、6.4節、6.8節、7章、8.7節、9.1節
- RFC 9114: HTTP/3 2.2節、4.1.1節、5.2節、5.3節、5.4節、6.1節、7.2.6節、8.1節、付録A.1・A.2.5・A.4・A.4.1
- RFC 9110: HTTP Semantics 9.2.1節、9.2.2節