Skip to content

Bugfix HTTP/2 keep_alive_max_requests dropping the requests in flight - #377

Open
harryherbig wants to merge 1 commit into
pgjones:mainfrom
harryherbig:h2-keep-alive-max-requests
Open

harryherbig wants to merge 1 commit into
pgjones:mainfrom
harryherbig:h2-keep-alive-max-requests

Conversation

@harryherbig

@harryherbig harryherbig commented Sep 22, 2026 •

Copy link
Copy Markdown

On HTTP/2, reaching keep_alive_max_requests called close_connection() right away, on the request that exceeded the limit. h2 moves to the CLOSED state on GOAWAY, where it refuses to send HEADERS or DATA, so every stream still in flight on that connection lost its response (stream_send swallows the ProtocolError). The GOAWAY's last_stream_id covers those streams, so the client cannot safely retry them either.

With a multiplexing client that keeps one long-lived connection (grpc, Envoy, browsers) this fails the 1001st request plus everything in flight next to it, every 1000 requests. Measured with a grpcio client sending 3000 unary calls at concurrency 50 over one connection:

keep_alive_max_requests before after
1000 (default) 99 failed with UNAVAILABLE 0
100 988 failed 0

The fix mirrors the shutdown path that already exists in H2Protocol: when the limit is reached, announce SETTINGS_MAX_CONCURRENT_STREAMS = 0, keep serving the streams already in flight (including ones the client sent before it saw the setting), and send the GOAWAY once the connection is idle. The connection is then closed by the server, as the HTTP/1.1 path does after connection: close, instead of lingering until the keep-alive timeout. This is the HTTP/2 counterpart of #227.

test_protocol_keep_alive_max_requests now drives three streams into the protocol with a limit of two and asserts the sequence: settings go to zero, nothing is reset or terminated while streams are open, GOAWAY and Closed follow the last StreamClosed.

No httpx end-to-end test: httpcore ignores a max_concurrent_streams of zero (if max_concurrent_streams: in _receive_remote_settings_change) and h2 then raises LocalProtocolError on the client side for any further request in the drain window. grpc-core and Envoy handle the setting as intended.

@tahsinrk

tahsinrk commented Nov 2, 2026 •

Copy link
Copy Markdown

Independent confirmation of this loss and of this fix, using a plain ASGI application and the default configuration, without a framework, TLS or a proxy. Hypercorn 0.18.0, h2 4.4.1, Python 3.14.10 on Linux and 3.14.3 on macOS, asyncio worker, cleartext HTTP/2 prior knowledge.

Save the application below as count_app.py and the client as client.py. Install hypercorn==0.18.0 h2==4.4.1, then run:

hypercorn --bind 127.0.0.1:8080 --workers 1 --graceful-timeout 4 count_app:app

Application:

import asyncio


async def app(scope, receive, send):
    if scope["type"] == "lifespan":
        while True:
            message = await receive()
            if message["type"] == "lifespan.startup":
                await send({"type": "lifespan.startup.complete"})
            elif message["type"] == "lifespan.shutdown":
                await send({"type": "lifespan.shutdown.complete"})
                return
    if scope["type"] == "http":
        # Answer with the request body's size; /slow first waits 0.2 seconds.
        size = 0
        while True:
            message = await receive()
            size += len(message.get("body", b""))
            if not message.get("more_body", False):
                break
        if scope["path"] == "/slow":
            await asyncio.sleep(0.2)
        body = str(size).encode()
        await send({"type": "http.response.start", "status": 200,
                    "headers": [(b"content-length", b"%d" % len(body))]})
        await send({"type": "http.response.body", "body": body})

Client:

import json
import socket
import sys
import time

import h2.config
import h2.connection
import h2.events
import h2.exceptions


def probe(port, case):
    connection = h2.connection.H2Connection(
        config=h2.config.H2Configuration(client_side=True, header_encoding="utf-8")
    )
    connection.initiate_connection()
    statuses, bodies, ended = {}, {}, set()
    result = {"case": case, "goaways": [], "error": None}

    def request(stream, method, path, end):
        connection.send_headers(stream, [(":method", method), (":scheme", "http"),
            (":authority", "localhost"), (":path", path)], end_stream=end)

    with socket.create_connection(("127.0.0.1", port), timeout=2) as sock:
        def wait_for(streams, seconds):
            deadline = time.monotonic() + seconds
            while not streams <= ended:
                sock.settimeout(max(0.001, deadline - time.monotonic()))
                try:
                    data = sock.recv(65536)
                    if not data:
                        result["error"] = "EOF before every answer"
                        return
                    events = connection.receive_data(data)
                    sock.sendall(connection.data_to_send())
                except TimeoutError:
                    result["error"] = f"No answer within {seconds} seconds"
                    return
                except (ConnectionError, h2.exceptions.ProtocolError) as exc:
                    result["error"] = f"{type(exc).__name__}: {exc}"
                    return
                for event in events:
                    if isinstance(event, h2.events.ResponseReceived):
                        statuses[event.stream_id] = dict(event.headers)[":status"]
                    elif isinstance(event, h2.events.DataReceived):
                        bodies[event.stream_id] = (bodies.get(event.stream_id, "")
                                                   + event.data.decode())
                        connection.acknowledge_received_data(
                            event.flow_controlled_length, event.stream_id)
                    elif isinstance(event, h2.events.StreamEnded):
                        ended.add(event.stream_id)
                    elif isinstance(event, h2.events.ConnectionTerminated):
                        result["goaways"].append([event.last_stream_id,
                                                  event.error_code.name])

        # Requests 1 to 999: quick GETs, one at a time, all answered.
        for stream in range(1, 1999, 2):
            request(stream, "GET", "/", True)
            sock.sendall(connection.data_to_send())
            wait_for({stream}, 2)
        result["answered_before"] = len(ended)
        if case == "upload":
            # Requests 1,000 and 1,001 are uploads; both bodies follow
            # request 1,001's headers.
            request(1999, "POST", "/upload", False)
            request(2001, "POST", "/upload", False)
            sock.sendall(connection.data_to_send())
            connection.send_data(1999, b"x" * 1000, end_stream=True)
            connection.send_data(2001, b"x" * 2000, end_stream=True)
        else:
            # Request 1,000 takes 0.2 seconds; request 1,001 is quick. The
            # client sends nothing after request 1,001's headers.
            request(1999, "GET", "/slow", True)
            request(2001, "GET", "/", True)
        sock.sendall(connection.data_to_send())
        wait_for({1999, 2001}, 3)
        # A server draining the connection announces zero streams; respect that.
        if (not result["goaways"] and result["error"] is None
                and connection.remote_settings.max_concurrent_streams):
            request(2003, "GET", "/", True)
            sock.sendall(connection.data_to_send())
            wait_for({2003}, 2)
        for number, stream in ((1000, 1999), (1001, 2001), (1002, 2003)):
            result[f"request_{number}"] = {"status": statuses.get(stream),
                                           "body": bodies.get(stream),
                                           "ended": stream in ended}
        return result


if __name__ == "__main__":
    print(json.dumps(probe(int(sys.argv[1]), sys.argv[2])))

In another terminal:

python client.py 8080 upload
python client.py 8080 slow

The client sends 999 quick GETs one at a time on one connection, and all are answered. In upload, requests 1,000 and 1,001 are POSTs whose bodies follow request 1,001's headers. Observed on 0.18.0: GOAWAY with last stream ID 2001 and NO_ERROR; the two bodies, on streams the GOAWAY covers, then fail in h2 on the server, which sends GOAWAY with PROTOCOL_ERROR and closes the connection. Neither upload receives a response. In slow, request 1,000 is a GET the application answers after 0.2 seconds and request 1,001 is a quick GET, and the client sends nothing after request 1,001's headers. Observed on 0.18.0: GOAWAY with last stream ID 2001 and NO_ERROR, and neither request receives a response within three seconds. Both cases gave the same result in three runs each, and the server logged nothing beyond its startup line.

With this PR's src/hypercorn/protocol/h2.py change applied to 0.18.0, both cases answer requests 1,000 and 1,001 with 200 and the request body's size, in three runs of each case. The client skips request 1,002 once the server announces zero streams; otherwise h2 raises TooManyStreamsError on the client side, as your description anticipates.

Until a release carries this, adding --config file:raised.py, where raised.py contains only keep_alive_max_requests = 2**31, also makes both cases answer requests 1,000 and 1,001, and request 1,002 on the same connection is answered too. A server push still counts twice toward the limit, once in _create_stream and once in _create_server_push.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants