cloudflare / cloudflare/quiche

Edge closes HTTP/3 connections at 10,000 requests with H3_NO_ERROR but no GOAWAY

Open
#2,700 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Rust
Stars
11.8k
Forks
1.1k
Avg merge
3d 7h
Merged PRs (30d)
16

Description

Cloudflare-fronted hosts end every HTTP/3 connection after 10,000 requests by sending a QUIC CONNECTION_CLOSE with application error 0x100 (H3_NO_ERROR). No GOAWAY frame is sent on the control stream first and in-flight request streams are not drained, so whatever is in flight at that moment fails and the client cannot tell which of those requests the server processed.

The HTTP/2 path on the same edge applies the same 10,000-request cap but does it correctly: it sends GOAWAY (`last_stream_id = 19999`, NO_ERROR) and clients retry the streams above that ID with no failures.

Reproduced 2026-09-03 against `cloudflare-quic.com`, `www.cloudflare.com` and a third Cloudflare-fronted host with three independent clients (curl/ngtcp2, .NET/msquic, Python/aioquic).

I realise the request cap itself and the decision to close are edge policy rather than quiche library code, and that `h3::Connection::send_goaway()` exists. I am filing here because the README says quiche powers the edge's HTTP/3 and `cloudflare-quic.com` is this project's test host. Happy to be redirected if there is a better place.

## Standard Requirement

[RFC 9114 Section 5.2, Connection Shutdown](https://www.rfc-editor.org/rfc/rfc9114.html#section-5.2):

```text
Endpoints initiate the graceful shutdown of an HTTP/3 connection by
sending a GOAWAY frame.
...
An endpoint that completes a graceful shutdown SHOULD use the
H3_NO_ERROR error code when closing the connection.
...
If a connection terminates without a GOAWAY frame, clients MUST
assume that any request that was sent, whether in whole or in part,
might have been processed.
```

H3_NO_ERROR is used without the preceding GOAWAY that gives it meaning, which forces clients into the "might have been processed" case for every in-flight request.

## Reproduction

curl 8.21.0 (ngtcp2 1.25.0 / nghttp3 1.18.0), one HTTP/3 connection, 4 requests in flight, about 80 seconds:

```sh
curl --http3-only -Z --parallel-max 4 -s -o /dev/null \
-w '%{url} %{http_code} %{exitcode} %{errormsg}\n' \
"https://cloudflare-quic.com/cdn-cgi/trace?[1-10050]" | grep -v ' 200 0 $'
```
Outputs:
```text
https://cloudflare-quic.com/cdn-cgi/trace?10001 000 55 ngtcp2_conn_writev_stream returned error: ERR_DRAINING
https://cloudflare-quic.com/cdn-cgi/trace?10002 000 55 ngtcp2_conn_writev_stream returned error: ERR_DRAINING
https://cloudflare-quic.com/cdn-cgi/trace?10003 000 55 ngtcp2_conn_writev_stream returned error: ERR_DRAINING
```

The requests in flight when the 10,000th completes fail; the remainder succeed on a fresh connection. Across repeated runs the aborted set shifts by one or two requests either side of 10,000 depending on timing, so requests at or below the cap that the server may well have processed end up in it too, with no way for the client to know.

Same command over HTTP/2:

```sh
curl --http2 -Z --parallel-max 4 -s -o /dev/null \
-w '%{url} %{http_code} %{exitcode} %{errormsg}\n' \
"https://cloudflare-quic.com/cdn-cgi/trace?[1-10050]" | grep -v ' 200 0 $'
```

No output: 0 failures. Request 10,001 onwards lands on a second connection after the GOAWAY.

## Observed

To confirm no GOAWAY precedes the close, the same test was run with a client that logs HTTP/3 control-stream frames (.NET 10 `SocketsHttpHandler` over msquic 2.6.1). At the 10,000th request:

```text
QuicConnection SHUTDOWN_INITIATED_BY_PEER { ErrorCode = 256 }
QuicStream SHUTDOWN_COMPLETE { ConnectionClosedRemotely = 1, ConnectionErrorCode = 256 } x4
HttpProtocolException: The HTTP/3 server closed the connection. HTTP/3 error code 'H3_NO_ERROR' (0x100).
```

No GOAWAY frame was received at any point. 4 of 11,000 requests failed (the four in flight). A qlog captured with a second client (aioquic) shows the same: no GOAWAY on the control stream, and the last packet received carries only `CONNECTION_CLOSE {error_space: application, raw_error_code: 256, reason: ""}`.

Same test over HTTP/2:

```text
Frame: StreamId=0; Type=GoAway; Flags=None; PayloadLength=8
ReadGoAwayFrame: lastStreamId=19999, errorCode=NoError
Retry attempt 1 after connection failure x3
```

0 of 11,000 failed.

## Expected

Do on HTTP/3 what the HTTP/2 path already does at the same cap: send GOAWAY with the ID of the first request stream that will not be processed, finish the requests below that ID, and only then close with H3_NO_ERROR. That gives clients an unambiguous retry set and, since nothing is cut off mid-flight, usually nothing to retry at all.

Sending GOAWAY and closing immediately is not enough on its own: the CONNECTION_CLOSE resets the in-flight streams regardless of the GOAWAY ID, and a GOAWAY that is lost just before the close is never retransmitted, so the client is back in the "might have been processed" case. @LPardue describes the same sequence in #1722.

Contributor guide

Open the contributing guide

Research direction

Start with the README and the h3::Connection::send_goaway() entry point mentioned in the report. First verify whether the 10,000-request cap and connection shutdown are implemented in quiche or external edge policy; done means identifying the correct project scope and, if applicable, defining how HTTP/3 graceful shutdown should match the stated HTTP/2 behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
networking
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.