Repository navigation
Bugfix HTTP/2 keep_alive_max_requests dropping the requests in flight - #377
harryherbig wants to merge 1 commit into
Conversation
|
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 hypercorn --bind 127.0.0.1:8080 --workers 1 --graceful-timeout 4 count_app:appApplication: 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 slowThe client sends 999 quick GETs one at a time on one connection, and all are answered. In With this PR's Until a release carries this, adding |
On HTTP/2, reaching
keep_alive_max_requestscalledclose_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_sendswallows theProtocolError). The GOAWAY'slast_stream_idcovers 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_requestsThe fix mirrors the shutdown path that already exists in
H2Protocol: when the limit is reached, announceSETTINGS_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 afterconnection: close, instead of lingering until the keep-alive timeout. This is the HTTP/2 counterpart of #227.test_protocol_keep_alive_max_requestsnow 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 andClosedfollow the lastStreamClosed.No httpx end-to-end test: httpcore ignores a
max_concurrent_streamsof zero (if max_concurrent_streams:in_receive_remote_settings_change) and h2 then raisesLocalProtocolErroron the client side for any further request in the drain window. grpc-core and Envoy handle the setting as intended.