From fdfdf6402419e059bb06fa760de8044671d71a0f Mon Sep 17 00:00:00 2001 From: Daniel Reed Date: Wed, 28 Aug 2024 19:51:40 -0700 Subject: [PATCH 1/5] Check in a skeleton HTTP/2 server, copied from https://python-hyper.org/projects/h2/en/stable/basic-usage.html (step 4). --- tests/h2server.py | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/h2server.py diff --git a/tests/h2server.py b/tests/h2server.py new file mode 100644 index 00000000..2a2ebb77 --- /dev/null +++ b/tests/h2server.py @@ -0,0 +1,49 @@ +import socket + +import h2.connection +import h2.events +import h2.config + +def send_response(conn, event): + stream_id = event.stream_id + conn.send_headers( + stream_id=stream_id, + headers=[ + (':status', '200'), + ('server', 'basic-h2-server/1.0') + ], + ) + conn.send_data( + stream_id=stream_id, + data=b'it works!', + end_stream=True + ) + +def handle(sock): + config = h2.config.H2Configuration(client_side=False) + conn = h2.connection.H2Connection(config=config) + conn.initiate_connection() + sock.sendall(conn.data_to_send()) + + while True: + data = sock.recv(65535) + if not data: + break + + events = conn.receive_data(data) + for event in events: + if isinstance(event, h2.events.RequestReceived): + send_response(conn, event) + + data_to_send = conn.data_to_send() + if data_to_send: + sock.sendall(data_to_send) + + +sock = socket.socket() +sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +sock.bind(('0.0.0.0', 8080)) +sock.listen(5) + +while True: + handle(sock.accept()[0]) From 10d05d4b670a159414e769d6c74d4bc612c19abb Mon Sep 17 00:00:00 2001 From: Daniel Reed Date: Wed, 28 Aug 2024 21:39:11 -0700 Subject: [PATCH 2/5] Add a test showing that slow, overlapping requests back each other up in HTTP/2. See https://github.com/encode/httpx/discussions/3278. --- tests/_async/test_http2.py | 28 ++++++++++++ tests/_sync/test_http2.py | 28 ++++++++++++ tests/concurrency.py | 5 +++ tests/h2server.py | 91 +++++++++++++++++++++++++++++--------- 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/tests/_async/test_http2.py b/tests/_async/test_http2.py index b4ec6648..5a47d1b1 100644 --- a/tests/_async/test_http2.py +++ b/tests/_async/test_http2.py @@ -1,8 +1,12 @@ +import time + import hpack import hyperframe.frame import pytest +import trio as concurrency import httpcore +from tests import h2server @pytest.mark.anyio @@ -380,3 +384,27 @@ async def test_http2_remote_max_streams_update(): conn._h2_state.local_settings.max_concurrent_streams, ) i += 1 + + +@pytest.mark.trio +async def test_slow_overlapping_requests(): + fetches = [] + + with h2server.run() as server: + url = f"http://127.0.0.1:{server.port}/" + + async with httpcore.AsyncConnectionPool(http1=False, http2=True) as pool: + + async def fetch(start_delay): + await concurrency.sleep(start_delay) + + start = time.time() + await pool.request("GET", url) + end = time.time() + fetches.append(round(end - start, 1)) + + async with concurrency.open_nursery() as nursery: + for start_delay in [0, 0.2, 0.4, 0.6, 0.8]: + nursery.start_soon(fetch, start_delay) + + assert fetches == [1.0] * 5 diff --git a/tests/_sync/test_http2.py b/tests/_sync/test_http2.py index 695359bd..9365b83d 100644 --- a/tests/_sync/test_http2.py +++ b/tests/_sync/test_http2.py @@ -1,8 +1,12 @@ +import time + import hpack import hyperframe.frame import pytest +from tests import concurrency import httpcore +from tests import h2server @@ -380,3 +384,27 @@ def test_http2_remote_max_streams_update(): conn._h2_state.local_settings.max_concurrent_streams, ) i += 1 + + + +def test_slow_overlapping_requests(): + fetches = [] + + with h2server.run() as server: + url = f"http://127.0.0.1:{server.port}/" + + with httpcore.ConnectionPool(http1=False, http2=True) as pool: + + def fetch(start_delay): + concurrency.sleep(start_delay) + + start = time.time() + pool.request("GET", url) + end = time.time() + fetches.append(round(end - start, 1)) + + with concurrency.open_nursery() as nursery: + for start_delay in [0, 0.2, 0.4, 0.6, 0.8]: + nursery.start_soon(fetch, start_delay) + + assert fetches == [1.0] * 5 diff --git a/tests/concurrency.py b/tests/concurrency.py index a0572d53..382d206a 100644 --- a/tests/concurrency.py +++ b/tests/concurrency.py @@ -10,6 +10,7 @@ """ import threading +import time from types import TracebackType from typing import Any, Callable, List, Optional, Type @@ -39,3 +40,7 @@ def start_soon(self, func: Callable[..., object], *args: Any) -> None: def open_nursery() -> Nursery: return Nursery() + + +def sleep(seconds: float) -> None: + time.sleep(seconds) diff --git a/tests/h2server.py b/tests/h2server.py index 2a2ebb77..e7d720c9 100644 --- a/tests/h2server.py +++ b/tests/h2server.py @@ -1,25 +1,40 @@ +import contextlib +import functools +import logging import socket +import threading +import time +import h2.config import h2.connection import h2.events -import h2.config -def send_response(conn, event): + +def send_response(sock, conn, event): + start = time.time() + logging.info("Starting %s.", event) + + time.sleep(1) + stream_id = event.stream_id conn.send_headers( stream_id=stream_id, - headers=[ - (':status', '200'), - ('server', 'basic-h2-server/1.0') - ], - ) - conn.send_data( - stream_id=stream_id, - data=b'it works!', - end_stream=True + headers=[(":status", "200"), ("server", "basic-h2-server/1.0")], ) + data_to_send = conn.data_to_send() + if data_to_send: + sock.sendall(data_to_send) + + conn.send_data(stream_id=stream_id, data=b"it works!", end_stream=True) + data_to_send = conn.data_to_send() + if data_to_send: + sock.sendall(data_to_send) -def handle(sock): + end = time.time() + logging.info("Finished %s in %.03fs.", event, end - start) + + +def handle(sock: socket.socket) -> None: config = h2.config.H2Configuration(client_side=False) conn = h2.connection.H2Connection(config=config) conn.initiate_connection() @@ -28,22 +43,54 @@ def handle(sock): while True: data = sock.recv(65535) if not data: + sock.close() break events = conn.receive_data(data) for event in events: if isinstance(event, h2.events.RequestReceived): - send_response(conn, event) + threading.Thread( + target=functools.partial(send_response, sock, conn, event) + ).start() + + +class HTTP2Server: + def __init__( + self, *, host: str = "127.0.0.1", port: int = 0, timeout: float = 0.2 + ) -> None: + self.sock = socket.socket() + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.settimeout(timeout) + self.sock.bind((host, port)) + self.port = self.sock.getsockname()[1] + self.sock.listen(5) - data_to_send = conn.data_to_send() - if data_to_send: - sock.sendall(data_to_send) + def run(self) -> None: + while True: + try: + handle(self.sock.accept()[0]) + except socket.timeout: + pass + except OSError: + break -sock = socket.socket() -sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) -sock.bind(('0.0.0.0', 8080)) -sock.listen(5) +@contextlib.contextmanager +def run(**kwargs): + server = HTTP2Server(**kwargs) + thr = threading.Thread(target=server.run) + thr.start() + try: + yield server + finally: + server.sock.close() + thr.join() + + +if __name__ == "__main__": + logging.basicConfig( + format="%(relativeCreated)5i <%(threadName)s> %(filename)s:%(lineno)s] %(message)s", + level=logging.INFO, + ) -while True: - handle(sock.accept()[0]) + HTTP2Server(port=8100).run() From 81af27cbc10ea0e5fcbca4ebfacb52de5382e047 Mon Sep 17 00:00:00 2001 From: Daniel Reed Date: Thu, 29 Aug 2024 11:21:03 -0700 Subject: [PATCH 3/5] Mark test_slow_overlapping_requests as currently expected to fail. --- tests/_async/test_http2.py | 1 + tests/_sync/test_http2.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/_async/test_http2.py b/tests/_async/test_http2.py index 5a47d1b1..42542481 100644 --- a/tests/_async/test_http2.py +++ b/tests/_async/test_http2.py @@ -387,6 +387,7 @@ async def test_http2_remote_max_streams_update(): @pytest.mark.trio +@pytest.mark.xfail(reason="https://github.com/encode/httpx/discussions/3278") async def test_slow_overlapping_requests(): fetches = [] diff --git a/tests/_sync/test_http2.py b/tests/_sync/test_http2.py index 9365b83d..334da397 100644 --- a/tests/_sync/test_http2.py +++ b/tests/_sync/test_http2.py @@ -387,6 +387,7 @@ def test_http2_remote_max_streams_update(): +@pytest.mark.xfail(reason="https://github.com/encode/httpx/discussions/3278") def test_slow_overlapping_requests(): fetches = [] From aff27eff76760646896c3c30aa25200914d54074 Mon Sep 17 00:00:00 2001 From: Daniel Reed Date: Fri, 30 Aug 2024 11:56:10 -0700 Subject: [PATCH 4/5] =?UTF-8?q?Mark=20both=20the=20=5F=5Fmain=5F=5F=20(91?= =?UTF-8?q?=E2=80=9396)=20and=20accept=20timeout=20(73)=20blocks=20as=20no?= =?UTF-8?q?t=20covered=20by=20testing.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The accept timeout never triggers during test_http2.py because handle() blocks for the lifetime of the TCP connection. (We could actually move the loop entirely into __main__ and have run() just call accept()/handle() once.) See https://github.com/encode/httpcore/actions/runs/10620523808/job/29440480372?pr=948. --- tests/h2server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/h2server.py b/tests/h2server.py index e7d720c9..4f0df849 100644 --- a/tests/h2server.py +++ b/tests/h2server.py @@ -69,7 +69,7 @@ def run(self) -> None: while True: try: handle(self.sock.accept()[0]) - except socket.timeout: + except socket.timeout: # pragma: no cover pass except OSError: break @@ -87,7 +87,7 @@ def run(**kwargs): thr.join() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover logging.basicConfig( format="%(relativeCreated)5i <%(threadName)s> %(filename)s:%(lineno)s] %(message)s", level=logging.INFO, From b60672cc005ccd50fbfcce55ac48aa0cea160c13 Mon Sep 17 00:00:00 2001 From: Daniel Reed Date: Mon, 2 Sep 2024 09:57:11 -0700 Subject: [PATCH 5/5] (I somehow forgot about args=.) --- tests/h2server.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/h2server.py b/tests/h2server.py index 4f0df849..6b910c72 100644 --- a/tests/h2server.py +++ b/tests/h2server.py @@ -1,5 +1,4 @@ import contextlib -import functools import logging import socket import threading @@ -49,9 +48,7 @@ def handle(sock: socket.socket) -> None: events = conn.receive_data(data) for event in events: if isinstance(event, h2.events.RequestReceived): - threading.Thread( - target=functools.partial(send_response, sock, conn, event) - ).start() + threading.Thread(target=send_response, args=(sock, conn, event)).start() class HTTP2Server: