Can you resend requests after an HTTP/2 GOAWAY? Last-Stream-ID, REFUSED_STREAM, and what changes in HTTP/3

GOAWAY's Last-Stream-ID marks the last stream a server may have processed; requests above it can be resent on a new connection, even POST. Plus the two-step shutdown, REFUSED_STREAM and HTTP/3.

http2http3rfc-9113networkprotocol

When a server is redeployed or a proxy restarts, clients on open HTTP/2 connections receive a GOAWAY frame. A few requests may already be on the wire with no response yet. Whether those can be sent again on a new connection isn’t settled by the method alone. Most of the answer comes from one stream number inside the GOAWAY.

This post is based on RFC 9113 (HTTP/2, June 2022, obsoletes RFC 7540 and 8740): parts of Section 5, Sections 6.4, 6.8, 7, 8.7 and 9.1. For HTTP/3 it uses RFC 9114 (June 2022): Sections 2.2, 4.1.1, 5, 6.1, 7.2.6, 8.1 and Appendix A. Retry rules come from RFC 9110 Section 9.2. How individual implementations such as nginx, Envoy, Go or browsers send and react to GOAWAY depends on version and configuration, so it’s out of scope here.

What a GOAWAY frame carries

In HTTP/2, GOAWAY is frame type 0x07 (Section 6.8). It applies to the whole connection rather than one stream, so the stream identifier in its frame header must be 0. A receiver must treat any other value as a connection error of type PROTOCOL_ERROR. The payload has three parts.

GOAWAY (type=0x07, stream=0)
+---+-------------------------------+
| R | Last-Stream-ID (31 bits)      |
+---+-------------------------------+
| Error Code (32 bits)              |
+-----------------------------------+
| Additional Debug Data (optional)  |
+-----------------------------------+
  • Last-Stream-ID: the highest-numbered stream, among those the peer opened, that the sender might have acted on or might still act on. It can be 0 if no streams were processed.
  • Error Code: why the connection is closing. A graceful shutdown uses NO_ERROR (0x00) (Section 7).
  • Additional Debug Data: opaque diagnostic bytes with no protocol meaning.

“Processed” has a specific meaning in the RFC: some data from the stream was passed up to a higher layer of software that might have done something as a result. If the request reached your application handler, count it as processed.

The two ranges Last-Stream-ID creates

In HTTP/2, client-opened streams use odd numbers, and each new stream’s number must be greater than any stream that endpoint has opened before (Section 5.1.1). That ordering is what lets a single number separate “up to here” from “after here”.

Say a client has sent requests on streams 1, 3, 5, 7 and 9, none finished yet, and the server sends GOAWAY(Last-Stream-ID=5, NO_ERROR).

client streams:   1    3    5  |  7    9
                might have been | not processed
                processed       | (can be retried on
                (may still      |  a new connection)
                 complete)      |
                            ^
                    Last-Stream-ID = 5
  • Streams 7 and 9: after sending GOAWAY, the server ignores frames on streams numbered above Last-Stream-ID. The client can treat those streams as if they were never created and send them again on a new connection (Section 6.8). Section 8.7 goes further: requests that weren’t processed haven’t failed, so a client may retry them automatically (MAY), even with a non-idempotent method like POST.
  • Streams 1, 3 and 5: these might have been processed. GOAWAY doesn’t mean the connection closes right away, and the server can keep it open until in-progress streams finish. If a response arrives normally, nothing more is needed. The trouble starts when the connection closes before these streams complete. Then only idempotent actions such as GET, PUT or DELETE can be attempted again (Section 6.8).

A receiver of GOAWAY also must not open new streams on that connection (MUST NOT). New requests go on a new connection.

This guarantee depends on the server reporting honestly. Section 8.7 says a server must not indicate that a stream wasn’t processed unless it can guarantee it (MUST NOT). If frames from a stream were passed to the application layer, the GOAWAY’s Last-Stream-ID must be greater than or equal to that stream’s number (MUST).

When there is no GOAWAY

If a connection ends without a GOAWAY, the last stream identifier is effectively the highest possible value (Section 6.8). Every unfinished stream lands in the “might have been processed” range. Section 5.4.3 says the same from another angle: if the TCP connection is closed or reset while streams are still open or half-closed, those streams can’t be retried automatically.

The RFC’s own example is a POST. If a client sends a POST at the moment the server closes the connection, and there is no GOAWAY, the client can’t tell whether the server began processing it. That is why endpoints should always send GOAWAY before closing a connection (SHOULD, Sections 6.8 and 9.1). Section 6.8 also allows one exception: an endpoint may close without GOAWAY when the peer is misbehaving.

What to do with requests in this range is governed by RFC 9110 Section 9.2.2. A client shouldn’t automatically retry a non-idempotent request unless it knows the request’s semantics are actually idempotent or has a way to detect that the original was never applied (SHOULD NOT). A proxy must not automatically retry non-idempotent requests (MUST NOT), and a client shouldn’t automatically retry a failed automatic retry (SHOULD NOT). The properties of each method are covered in Safe and idempotent HTTP methods.

Shutting down in two steps: 2^31-1 first, then the real number

While a GOAWAY is in flight, the client may still be opening streams. If the server sends GOAWAY(5) and the client sends stream 11 before that frame arrives, stream 11 is dropped unprocessed. A client that can retry loses nothing. One that can’t loses the request, and the RFC notes this matters most for intermediaries that might not be serving their own clients over HTTP/2.

So Section 6.8 recommends this sequence for a server that wants to shut down gracefully:

  1. Send a first GOAWAY with Last-Stream-ID set to 2^31-1, the largest stream number, and error code NO_ERROR (SHOULD). It tells the client that shutdown is coming and it must not start further requests. No stream is discarded at this point.
  2. Give streams already in transit time to arrive. The RFC’s guideline is at least one round-trip time.
  3. Send a second GOAWAY carrying the real Last-Stream-ID (MAY).
  4. Keep the connection open until in-progress streams at or below that number finish, then close it.

An endpoint may send GOAWAY more than once (MAY), but it must not raise Last-Stream-ID above a value it already sent (MUST NOT), because the peer might already have retried unprocessed requests on another connection. The last GOAWAY received tells you which streams could have been acted on.

Even after sending GOAWAY, frames that change connection state can’t simply be dropped. HEADERS, PUSH_PROMISE and CONTINUATION frames on streams above Last-Stream-ID must be minimally processed to keep field compression state consistent, and DATA frames must still count against the connection flow-control window (MUST). Skipping them leaves the two sides with mismatched compression or flow-control state.

The server isn’t the only sender. An endpoint that receives a GOAWAY and has no further use for the connection should still send its own GOAWAY before terminating it (SHOULD). A server that runs out of stream identifiers can also use GOAWAY to push the client onto a new connection (Section 5.1.1).

Turning back a single stream: REFUSED_STREAM

GOAWAY draws a line for the whole connection. An RST_STREAM frame with REFUSED_STREAM (0x07) makes the same promise for one stream: it is being closed before any processing, and the request on it can safely be retried (Section 8.7). As with GOAWAY, a server may only use it when it can guarantee nothing was processed.

A typical place it shows up is the concurrency limit. If a peer sends HEADERS that push it past SETTINGS_MAX_CONCURRENT_STREAMS, the receiver must treat it as a stream error of type PROTOCOL_ERROR or REFUSED_STREAM (MUST). Section 5.1.2 notes that the choice of code decides whether automatic retry is enabled.

The error code 0x07 in RST_STREAM and the frame type 0x07 of GOAWAY share a number by coincidence. One lives in the error code space and the other in the frame type space.

GOAWAY on an error

GOAWAY isn’t only for graceful shutdown. An endpoint that hits a connection error should first send GOAWAY with the number of the last stream it successfully received from the peer (SHOULD), then must close the TCP connection (MUST, Section 5.4.1). That GOAWAY is best effort and may never reach the peer.

If an error happens during a graceful shutdown, the endpoint may send another GOAWAY with the new error code in addition to the earlier NO_ERROR one (Section 5.4). The rule against raising Last-Stream-ID still applies.

A few error codes you are likely to see in logs (Section 7):

Code Name Meaning
0x00 NO_ERROR Not an error; used in a graceful-shutdown GOAWAY
0x01 PROTOCOL_ERROR Protocol error with no more specific code
0x02 INTERNAL_ERROR Unexpected internal error
0x07 REFUSED_STREAM Stream refused before any application processing
0x0b ENHANCE_YOUR_CALM Peer appears to be generating excessive load

Unknown error codes must not trigger any special behavior (MUST NOT); an implementation may treat them as INTERNAL_ERROR (MAY).

Additional Debug Data can help a person track down a cause, but it carries no semantics and shouldn’t drive behavior. Section 6.8 warns that it may contain security- or privacy-sensitive data, and if you log or otherwise store it, you must protect it against unauthorized access (MUST).

What changes in HTTP/3

HTTP/3 also has a GOAWAY frame (type 0x07), but its shape and boundary rule differ.

HTTP/2 (RFC 9113) HTTP/3 (RFC 9114)
Sent on Stream 0 The control stream; on any other stream it’s an H3_FRAME_UNEXPECTED connection error
Value 31-bit Last-Stream-ID Variable-length integer: a client-initiated bidirectional stream ID from the server, a push ID from the client
Error code In the frame None; the reason goes in QUIC CONNECTION_CLOSE when the connection closes
Boundary At or below the value: might have been processed; above: not processed Below the value: might have been processed; at or above: rejected
First value for graceful shutdown 2^31-1 2^62-4 (server), 2^62-1 (client)
Raising the value Forbidden (MUST NOT) Forbidden; the receiver treats it as an H3_ID_ERROR connection error
Rejecting one stream RST_STREAM(REFUSED_STREAM) Abort the stream with H3_REQUEST_REJECTED (0x010b)
Ends without GOAWAY Last stream taken as the maximum Client must assume every request it sent might have been processed (MUST)

The boundary is where people slip. HTTP/2’s value is “the last number that might have been processed”, so that number itself is on the processed side. HTTP/3’s value means “this ID and above are rejected”, so that ID itself is on the rejected side (Section 5.2).

In HTTP/3 the client’s first request goes on QUIC stream 0, with later requests on 4, 8 and so on (Section 6.1). If a client has requests on 0, 4, 8, 12 and 16 and the server sends GOAWAY(12):

client request streams:  0    4    8  |  12   16
                       might have been | not processed
                       processed       |
                                       ^
                                GOAWAY ID = 12

Requests 12 and 16 won’t be processed and can safely be retried on a different HTTP connection. The fate of 0, 4 and 8 stays unknown until a response arrives, that stream is reset individually, a GOAWAY with a lower ID arrives, or the connection ends. The server may still reject individual requests in that range if it hasn’t processed them (MAY).

Put the two definitions side by side and the last request on the processed side is written as its own stream number in HTTP/2 but as the next request stream ID in HTTP/3. In code that handles both protocols, copying the comparison operator from one to the other misclassifies the request sitting on the boundary.

Turning back one request has its own vocabulary too. A request cancelled without any application processing is “rejected”, and the server should abort the response stream with H3_REQUEST_REJECTED (SHOULD). It must not use that code for a request that was partially or fully processed (MUST NOT); in that case it should use H3_REQUEST_CANCELLED (0x010c) (SHOULD, Section 4.1.1). Appendix A.4 of RFC 9114 lists H3_REQUEST_REJECTED as the counterpart of HTTP/2’s REFUSED_STREAM.

As in HTTP/2, GOAWAY doesn’t close the connection by itself (Section 7.2.6). Once all accepted requests are processed, the endpoint can let the connection go idle or close it immediately, and an endpoint that completes a graceful shutdown should close with H3_NO_ERROR (SHOULD, Section 5.2). When it has to close immediately, it may send a GOAWAY before the QUIC CONNECTION_CLOSE (MAY), and putting both in the same packet improves the odds that the client receives the GOAWAY (Section 5.3).

A client-side retry check

The function below decides whether a request that didn’t get a full response before an HTTP/2 connection ended can be retried automatically on a new connection. The rules come from RFC 9113 Sections 6.8 and 8.7 and RFC 9110 Section 9.2.2 as quoted above; the shape of the function is this post’s own.

// Idempotent methods among those defined in RFC 9110
const IDEMPOTENT = new Set(["GET", "HEAD", "OPTIONS", "TRACE", "PUT", "DELETE"]);

function canRetryOnNewConnection(
  streamId: number,
  method: string,
  lastStreamId: number | null, // value from the last GOAWAY received; null if none
  refused: boolean,            // did this stream get RST_STREAM(REFUSED_STREAM)?
): boolean {
  if (refused) return true; // refused before processing (8.7)
  if (lastStreamId !== null && streamId > lastStreamId) return true; // not processed (6.8)
  return IDEMPOTENT.has(method); // might have been processed: idempotent only (6.8, RFC 9110 9.2.2)
}

HTTP/2 stream numbers never exceed 2^31-1, so number is enough there. HTTP/3 is different. Stream IDs and the GOAWAY value are QUIC variable-length integers (RFC 9114 Section 2.2), and as shown above, a server’s first GOAWAY value is already 2^62-4. A JavaScript number can’t represent every integer above Number.MAX_SAFE_INTEGER (2^53-1) exactly, so in that range the request on the boundary can end up on the wrong side. Porting the check to HTTP/3 takes more than flipping the operator: both streamId and goawayId need an exact representation such as bigint.

// HTTP/3: stream IDs and GOAWAY values are QUIC variable-length integers, up to 2^62-1
function canRetryOnNewH3Connection(
  streamId: bigint,
  method: string,
  goawayId: bigint | null, // value from the last GOAWAY received; null if none
  rejected: boolean,       // was this request stream aborted with H3_REQUEST_REJECTED?
): boolean {
  if (rejected) return true; // rejected before processing (RFC 9114 4.1.1)
  if (goawayId !== null && streamId >= goawayId) return true; // not processed (5.2)
  return IDEMPOTENT.has(method); // might have been processed: idempotent only (RFC 9110 9.2.2)
}

Both functions only answer whether the protocol rules allow the request to be sent again. An actual automatic retry needs a few more things to hold.

  • The request body has to be replayable from the start. A body that was streamed out and discarded can’t be put on a new connection. This is an implementation precondition, not an RFC rule.
  • If this code runs in a proxy, it must not automatically retry a non-idempotent request, even one that was certainly not processed. RFC 9110 Section 9.2.2 forbids proxies from doing so (MUST NOT).
  • The functions don’t look at how many attempts have been made. RFC 9110’s advice not to automatically retry a failed automatic retry has to be enforced by the caller counting attempts.

Summary

Rules taken directly from the RFCs are listed separately from this post’s suggestions.

From the RFCs

  • In HTTP/2, a request on a stream above Last-Stream-ID wasn’t processed. It can go out again on a new connection even if it isn’t idempotent.
  • An unfinished request at or below that number might have been processed. If the connection closes, resend only idempotent requests.
  • On a connection that ends without GOAWAY, treat every unfinished request as possibly processed.
  • A server shutting down gracefully sends a first GOAWAY with 2^31-1 and NO_ERROR, and may follow after at least one RTT with the real number. The number never goes up.
  • In HTTP/3 the boundary becomes “below / at or above”, and GOAWAY carries no error code.

Suggestions from this post

  • In your deploy procedure, send the two-step GOAWAY before stopping the process, and decide both how long to wait for in-progress streams and the upper limit on that wait. The RFC doesn’t set a limit, so pick one from your service’s request durations.
  • When logging a connection close, record whether a GOAWAY arrived, its value and its error code. If you keep Additional Debug Data, restrict who can read it.
  • In code that handles both HTTP/2 and HTTP/3, test the boundary comparison separately for each protocol.

Sources and check date

All checked on 2026-10-22.

Back to the blog