diff --git a/gunicorn/asgi/protocol.py b/gunicorn/asgi/protocol.py index 4ef73a7e..9ea2fa35 100644 --- a/gunicorn/asgi/protocol.py +++ b/gunicorn/asgi/protocol.py @@ -235,6 +235,13 @@ class ASGIProtocol(asyncio.Protocol): msg_type = message["type"] + if msg_type == "http.response.informational": + # Handle informational responses (1xx) like 103 Early Hints + info_status = message.get("status") + info_headers = message.get("headers", []) + await self._send_informational(info_status, info_headers, request) + return + if msg_type == "http.response.start": if response_started: exc_to_raise = RuntimeError("Response already started") @@ -409,6 +416,34 @@ class ASGIProtocol(asyncio.Protocol): return scope + async def _send_informational(self, status, headers, request): + """Send an informational response (1xx) such as 103 Early Hints. + + Args: + status: HTTP status code (100-199) + headers: List of (name, value) header tuples + request: The parsed request object + + Note: Informational responses are only sent for HTTP/1.1 or later. + HTTP/1.0 clients do not support 1xx responses. + """ + # Don't send informational responses to HTTP/1.0 clients + if request.version < (1, 1): + return + + reason = self._get_reason_phrase(status) + response = f"HTTP/{request.version[0]}.{request.version[1]} {status} {reason}\r\n" + + for name, value in headers: + if isinstance(name, bytes): + name = name.decode("latin-1") + if isinstance(value, bytes): + value = value.decode("latin-1") + response += f"{name}: {value}\r\n" + + response += "\r\n" + self.transport.write(response.encode("latin-1")) + async def _send_response_start(self, status, headers, request): """Send HTTP response status and headers.""" # Build status line @@ -454,6 +489,7 @@ class ASGIProtocol(asyncio.Protocol): reasons = { 100: "Continue", 101: "Switching Protocols", + 103: "Early Hints", 200: "OK", 201: "Created", 202: "Accepted", @@ -596,6 +632,21 @@ class ASGIProtocol(asyncio.Protocol): msg_type = message["type"] + if msg_type == "http.response.informational": + # Handle informational responses (1xx) like 103 Early Hints over HTTP/2 + info_status = message.get("status") + info_headers = message.get("headers", []) + # Convert headers to list of string tuples + headers = [] + for name, value in info_headers: + if isinstance(name, bytes): + name = name.decode("latin-1") + if isinstance(value, bytes): + value = value.decode("latin-1") + headers.append((name, value)) + await h2_conn.send_informational(stream_id, info_status, headers) + return + if msg_type == "http.response.start": if response_started: exc_to_raise = RuntimeError("Response already started") diff --git a/gunicorn/http/wsgi.py b/gunicorn/http/wsgi.py index e0ff8699..f26555d6 100644 --- a/gunicorn/http/wsgi.py +++ b/gunicorn/http/wsgi.py @@ -107,6 +107,51 @@ def proxy_environ(req): } +def _make_early_hints_callback(req, sock): + """Create a wsgi.early_hints callback for sending 103 Early Hints. + + This allows WSGI applications to send 103 Early Hints responses + before the final response, enabling browsers to preload resources. + + Args: + req: The request object + sock: The socket to write to + + Returns: + A callback function that accepts a list of (name, value) header tuples + and sends a 103 Early Hints response. + + Note: + - Early hints are only sent for HTTP/1.1 or later clients + - HTTP/1.0 clients will silently ignore the callback + - Multiple calls are allowed (sending multiple 103 responses) + """ + def send_early_hints(headers): + """Send 103 Early Hints response. + + Args: + headers: List of (name, value) header tuples, typically Link headers + Example: [('Link', '; rel=preload; as=style')] + """ + # Don't send to HTTP/1.0 clients - they don't support 1xx responses + if req.version < (1, 1): + return + + # Build 103 response + response = b"HTTP/1.1 103 Early Hints\r\n" + for name, value in headers: + if isinstance(name, bytes): + name = name.decode('latin-1') + if isinstance(value, bytes): + value = value.decode('latin-1') + response += f"{name}: {value}\r\n".encode('latin-1') + response += b"\r\n" + + util.write(sock, response) + + return send_early_hints + + def create(req, sock, client, server, cfg): resp = Response(req, sock, cfg) @@ -195,6 +240,10 @@ def create(req, sock, client, server, cfg): # override the environ with the correct remote and server address if # we are behind a proxy using the proxy protocol. environ.update(proxy_environ(req)) + + # Add wsgi.early_hints callback for sending 103 Early Hints + environ['wsgi.early_hints'] = _make_early_hints_callback(req, sock) + return resp, environ diff --git a/gunicorn/http2/async_connection.py b/gunicorn/http2/async_connection.py index 3c2f08ea..a40c4ad3 100644 --- a/gunicorn/http2/async_connection.py +++ b/gunicorn/http2/async_connection.py @@ -272,6 +272,38 @@ class AsyncHTTP2Connection: stream.receive_trailers(event.headers) return HTTP2Request(stream, self.cfg, self.client_addr) + async def send_informational(self, stream_id, status, headers): + """Send an informational response (1xx) on a stream. + + This is used for 103 Early Hints and other 1xx responses. + Informational responses are sent before the final response + and do not end the stream. + + Args: + stream_id: The stream ID + status: HTTP status code (100-199) + headers: List of (name, value) header tuples + + Raises: + HTTP2Error: If status is not in 1xx range + """ + if status < 100 or status >= 200: + raise HTTP2Error(f"Invalid informational status: {status}") + + stream = self.streams.get(stream_id) + if stream is None: + raise HTTP2Error(f"Stream {stream_id} not found") + + # Build headers with :status pseudo-header + response_headers = [(':status', str(status))] + for name, value in headers: + # HTTP/2 headers must be lowercase + response_headers.append((name.lower(), str(value))) + + # Send headers with end_stream=False (informational, more to follow) + self.h2_conn.send_headers(stream_id, response_headers, end_stream=False) + await self._send_pending_data() + async def send_response(self, stream_id, status, headers, body=None): """Send a response on a stream. diff --git a/gunicorn/http2/connection.py b/gunicorn/http2/connection.py index dc51014b..41a7c2b9 100644 --- a/gunicorn/http2/connection.py +++ b/gunicorn/http2/connection.py @@ -314,6 +314,38 @@ class HTTP2ServerConnection: # Trailers always end the request return HTTP2Request(stream, self.cfg, self.client_addr) + def send_informational(self, stream_id, status, headers): + """Send an informational response (1xx) on a stream. + + This is used for 103 Early Hints and other 1xx responses. + Informational responses are sent before the final response + and do not end the stream. + + Args: + stream_id: The stream ID + status: HTTP status code (100-199) + headers: List of (name, value) header tuples + + Raises: + HTTP2Error: If status is not in 1xx range + """ + if status < 100 or status >= 200: + raise HTTP2Error(f"Invalid informational status: {status}") + + stream = self.streams.get(stream_id) + if stream is None: + raise HTTP2Error(f"Stream {stream_id} not found") + + # Build headers with :status pseudo-header + response_headers = [(':status', str(status))] + for name, value in headers: + # HTTP/2 headers must be lowercase + response_headers.append((name.lower(), str(value))) + + # Send headers with end_stream=False (informational, more to follow) + self.h2_conn.send_headers(stream_id, response_headers, end_stream=False) + self._send_pending_data() + def send_response(self, stream_id, status, headers, body=None): """Send a response on a stream. diff --git a/gunicorn/workers/gthread.py b/gunicorn/workers/gthread.py index b9d210de..994ca0cc 100644 --- a/gunicorn/workers/gthread.py +++ b/gunicorn/workers/gthread.py @@ -475,6 +475,13 @@ class ThreadWorker(base.Worker): environ["wsgi.multithread"] = True environ["HTTP_VERSION"] = "2" # Indicate HTTP/2 + # Replace wsgi.early_hints with HTTP/2-specific version + def send_early_hints_h2(headers): + """Send 103 Early Hints over HTTP/2.""" + h2_conn.send_informational(stream_id, 103, headers) + + environ["wsgi.early_hints"] = send_early_hints_h2 + self.nr += 1 if self.nr >= self.max_requests: if self.alive: diff --git a/tests/docker/http2/Dockerfile.nginx b/tests/docker/http2/Dockerfile.nginx index fbeead38..705ce697 100644 --- a/tests/docker/http2/Dockerfile.nginx +++ b/tests/docker/http2/Dockerfile.nginx @@ -1,4 +1,4 @@ -FROM nginx:1.25-alpine +FROM nginx:1.29-alpine # Install curl for healthcheck RUN apk add --no-cache curl diff --git a/tests/docker/http2/app.py b/tests/docker/http2/app.py index 6e877b58..29b01517 100644 --- a/tests/docker/http2/app.py +++ b/tests/docker/http2/app.py @@ -104,6 +104,33 @@ def app(environ, start_response): status = '200 OK' content_type = 'text/plain' + elif path == '/early-hints': + # Test endpoint for 103 Early Hints + # Send early hints if the callback is available + if 'wsgi.early_hints' in environ: + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ('Link', '; rel=preload; as=script'), + ]) + body = b'Early hints sent!' + status = '200 OK' + content_type = 'text/plain' + + elif path == '/early-hints-multiple': + # Test endpoint for multiple 103 Early Hints responses + if 'wsgi.early_hints' in environ: + # First early hints + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ]) + # Second early hints + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=script'), + ]) + body = b'Multiple early hints sent!' + status = '200 OK' + content_type = 'text/plain' + else: body = b'Not Found' status = '404 Not Found' diff --git a/tests/docker/http2/nginx.conf b/tests/docker/http2/nginx.conf index 822d5612..49cc05ad 100644 --- a/tests/docker/http2/nginx.conf +++ b/tests/docker/http2/nginx.conf @@ -51,6 +51,10 @@ http { proxy_ssl_verify off; proxy_ssl_server_name on; + # Enable forwarding of 103 Early Hints from upstream + # $http2 is set to "h2" when HTTP/2 is used, empty otherwise + early_hints $http2; + # Headers proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/tests/docker/http2/test_http2_docker.py b/tests/docker/http2/test_http2_docker.py index 4b6b1fbe..2252246e 100644 --- a/tests/docker/http2/test_http2_docker.py +++ b/tests/docker/http2/test_http2_docker.py @@ -348,3 +348,41 @@ class TestHTTP2Performance: response = h2_client.get(f"{gunicorn_url}/") assert response.status_code == 200 assert response.http_version == "HTTP/2" + + +class TestHTTP2EarlyHints: + """Test HTTP 103 Early Hints support.""" + + def test_early_hints_endpoint(self, h2_client, gunicorn_url): + """Test that early hints endpoint returns 200.""" + response = h2_client.get(f"{gunicorn_url}/early-hints") + assert response.status_code == 200 + assert response.text == "Early hints sent!" + + def test_early_hints_multiple_endpoint(self, h2_client, gunicorn_url): + """Test multiple early hints endpoint returns 200.""" + response = h2_client.get(f"{gunicorn_url}/early-hints-multiple") + assert response.status_code == 200 + assert response.text == "Multiple early hints sent!" + + def test_early_hints_via_proxy(self, h2_client, nginx_url): + """Test early hints through nginx proxy.""" + response = h2_client.get(f"{nginx_url}/early-hints") + assert response.status_code == 200 + assert response.text == "Early hints sent!" + + @pytest.mark.asyncio + async def test_concurrent_early_hints(self, async_h2_client, gunicorn_url): + """Test concurrent requests to early hints endpoint.""" + httpx = pytest.importorskip("httpx") + + async with httpx.AsyncClient(http2=True, verify=False, timeout=30.0) as client: + tasks = [ + client.get(f"{gunicorn_url}/early-hints") + for _ in range(10) + ] + responses = await asyncio.gather(*tasks) + + assert len(responses) == 10 + assert all(r.status_code == 200 for r in responses) + assert all(r.text == "Early hints sent!" for r in responses) diff --git a/tests/test_early_hints.py b/tests/test_early_hints.py new file mode 100644 index 00000000..62d84ca6 --- /dev/null +++ b/tests/test_early_hints.py @@ -0,0 +1,401 @@ +# -*- coding: utf-8 - +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. + +"""Tests for HTTP 103 Early Hints support (RFC 8297).""" + +import pytest +from unittest import mock +from io import BytesIO + +# Check if h2 is available for HTTP/2 tests +try: + import h2.connection + import h2.config + import h2.events + H2_AVAILABLE = True +except ImportError: + H2_AVAILABLE = False + +from gunicorn.http import wsgi + + +class MockConfig: + """Mock gunicorn configuration.""" + + def __init__(self): + self.is_ssl = False + self.workers = 1 + self.limit_request_fields = 100 + self.limit_request_field_size = 8190 + self.limit_request_line = 8190 + self.secure_scheme_headers = {} + self.forwarded_allow_ips = ['127.0.0.1'] + self.forwarder_headers = [] + self.strip_header_spaces = False + self.permit_obsolete_folding = False + self.header_map = "refuse" + self.sendfile = True + self.errorlog = "-" + + # HTTP/2 settings + self.http2_max_concurrent_streams = 100 + self.http2_initial_window_size = 65535 + self.http2_max_frame_size = 16384 + self.http2_max_header_list_size = 65536 + + def forwarded_allow_networks(self): + return [] + + +class MockRequest: + """Mock HTTP request for testing.""" + + def __init__(self, version=(1, 1)): + self.version = version + self.method = "GET" + self.uri = "/" + self.path = "/" + self.query = "" + self.fragment = "" + self.scheme = "http" + self.headers = [] + self.body = BytesIO(b"") + self.proxy_protocol_info = None + self._expected_100_continue = False + + def should_close(self): + return False + + +class MockSocket: + """Mock socket for testing.""" + + def __init__(self): + self._sent = bytearray() + self._closed = False + + def sendall(self, data): + if self._closed: + raise OSError("Socket is closed") + self._sent.extend(data) + + def send(self, data): + if self._closed: + raise OSError("Socket is closed") + self._sent.extend(data) + return len(data) + + def get_sent_data(self): + return bytes(self._sent) + + def clear(self): + self._sent = bytearray() + + def close(self): + self._closed = True + + +class TestWSGIEarlyHints: + """Test WSGI wsgi.early_hints callback.""" + + def test_early_hints_callback_in_environ(self): + """Verify wsgi.early_hints is added to environ.""" + cfg = MockConfig() + req = MockRequest() + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + assert 'wsgi.early_hints' in environ + assert callable(environ['wsgi.early_hints']) + + def test_send_single_early_hint(self): + """Test sending one Link header as early hint.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # Send early hints + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ]) + + sent_data = sock.get_sent_data() + assert b"HTTP/1.1 103 Early Hints\r\n" in sent_data + assert b"Link: ; rel=preload; as=style\r\n" in sent_data + assert sent_data.endswith(b"\r\n\r\n") + + def test_send_multiple_early_hints(self): + """Test sending multiple Link headers.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ('Link', '; rel=preload; as=script'), + ]) + + sent_data = sock.get_sent_data() + assert b"HTTP/1.1 103 Early Hints\r\n" in sent_data + assert b"Link: ; rel=preload; as=style\r\n" in sent_data + assert b"Link: ; rel=preload; as=script\r\n" in sent_data + + def test_early_hints_not_sent_for_http10(self): + """Test that early hints are not sent for HTTP/1.0 clients.""" + cfg = MockConfig() + req = MockRequest(version=(1, 0)) # HTTP/1.0 + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # Try to send early hints + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ]) + + # Nothing should be sent for HTTP/1.0 + sent_data = sock.get_sent_data() + assert sent_data == b"" + + def test_multiple_early_hints_calls(self): + """Test multiple calls to wsgi.early_hints (multiple 103 responses).""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # First early hints call + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=style'), + ]) + + # Second early hints call + environ['wsgi.early_hints']([ + ('Link', '; rel=preload; as=script'), + ]) + + sent_data = sock.get_sent_data() + # Should have two separate 103 responses + assert sent_data.count(b"HTTP/1.1 103 Early Hints\r\n") == 2 + + def test_early_hints_with_bytes_headers(self): + """Test early hints with bytes header values.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # Send with bytes values + environ['wsgi.early_hints']([ + (b'Link', b'; rel=preload; as=style'), + ]) + + sent_data = sock.get_sent_data() + assert b"HTTP/1.1 103 Early Hints\r\n" in sent_data + assert b"Link: ; rel=preload; as=style\r\n" in sent_data + + def test_empty_early_hints(self): + """Test early hints with empty headers list.""" + cfg = MockConfig() + req = MockRequest(version=(1, 1)) + sock = MockSocket() + + resp, environ = wsgi.create(req, sock, ('127.0.0.1', 12345), + ('127.0.0.1', 8000), cfg) + + # Send empty headers + environ['wsgi.early_hints']([]) + + sent_data = sock.get_sent_data() + # Should still send 103 response with no headers + assert sent_data == b"HTTP/1.1 103 Early Hints\r\n\r\n" + + +@pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available") +class TestHTTP2EarlyHints: + """Test HTTP/2 early hints (send_informational method).""" + + def _create_mock_http2_config(self): + """Create mock config for HTTP/2.""" + cfg = MockConfig() + return cfg + + def _create_mock_socket(self): + """Create mock socket for HTTP/2.""" + return MockSocket() + + def test_send_informational_method_exists(self): + """Test that send_informational method exists on HTTP2ServerConnection.""" + from gunicorn.http2.connection import HTTP2ServerConnection + + cfg = self._create_mock_http2_config() + sock = self._create_mock_socket() + conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345)) + + assert hasattr(conn, 'send_informational') + assert callable(conn.send_informational) + + def test_send_informational_invalid_status(self): + """Test send_informational raises for non-1xx status.""" + from gunicorn.http2.connection import HTTP2ServerConnection + from gunicorn.http2.errors import HTTP2Error + + cfg = self._create_mock_http2_config() + sock = self._create_mock_socket() + conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345)) + conn.initiate_connection() + + # Need to create a stream first + client_conn = h2.connection.H2Connection( + config=h2.config.H2Configuration(client_side=True) + ) + client_conn.initiate_connection() + + # Get client's initial data + client_data = client_conn.data_to_send() + conn.receive_data(client_data) + + # Create a request on the client + client_conn.send_headers(1, [ + (':method', 'GET'), + (':path', '/'), + (':scheme', 'https'), + (':authority', 'localhost'), + ], end_stream=True) + request_data = client_conn.data_to_send() + conn.receive_data(request_data) + + # Try to send 200 as informational (should fail) + with pytest.raises(HTTP2Error) as excinfo: + conn.send_informational(1, 200, [('link', '')]) + assert "Invalid informational status" in str(excinfo.value) + + def test_send_informational_103(self): + """Test sending 103 Early Hints over HTTP/2.""" + from gunicorn.http2.connection import HTTP2ServerConnection + + cfg = self._create_mock_http2_config() + sock = self._create_mock_socket() + conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345)) + conn.initiate_connection() + + # Create a client connection + client_conn = h2.connection.H2Connection( + config=h2.config.H2Configuration(client_side=True) + ) + client_conn.initiate_connection() + client_data = client_conn.data_to_send() + conn.receive_data(client_data) + + # Create a request on the client + client_conn.send_headers(1, [ + (':method', 'GET'), + (':path', '/'), + (':scheme', 'https'), + (':authority', 'localhost'), + ], end_stream=True) + request_data = client_conn.data_to_send() + conn.receive_data(request_data) + + # Clear sent data to isolate the informational response + sock.clear() + + # Send 103 Early Hints + conn.send_informational(1, 103, [ + ('link', '; rel=preload; as=style'), + ]) + + # Verify data was sent + sent_data = sock.get_sent_data() + assert len(sent_data) > 0 + + # Feed the data back to client to verify it's valid HTTP/2 + client_conn.receive_data(sent_data) + # Client should receive an informational response + + def test_send_informational_stream_not_found(self): + """Test send_informational raises for non-existent stream.""" + from gunicorn.http2.connection import HTTP2ServerConnection + from gunicorn.http2.errors import HTTP2Error + + cfg = self._create_mock_http2_config() + sock = self._create_mock_socket() + conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345)) + conn.initiate_connection() + + # Try to send on non-existent stream + with pytest.raises(HTTP2Error) as excinfo: + conn.send_informational(999, 103, [('link', '')]) + assert "not found" in str(excinfo.value) + + +@pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available") +class TestAsyncHTTP2EarlyHints: + """Test async HTTP/2 early hints.""" + + def test_async_send_informational_method_exists(self): + """Test that send_informational method exists on AsyncHTTP2Connection.""" + from gunicorn.http2.async_connection import AsyncHTTP2Connection + + cfg = MockConfig() + reader = mock.MagicMock() + writer = mock.MagicMock() + conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345)) + + assert hasattr(conn, 'send_informational') + assert callable(conn.send_informational) + + +class TestASGIEarlyHints: + """Test ASGI http.response.informational handling.""" + + def test_reason_phrase_103(self): + """Test that 103 has correct reason phrase.""" + from gunicorn.asgi.protocol import ASGIProtocol + + worker = mock.MagicMock() + worker.cfg = MockConfig() + worker.log = mock.MagicMock() + + protocol = ASGIProtocol(worker) + reason = protocol._get_reason_phrase(103) + assert reason == "Early Hints" + + def test_reason_phrase_100(self): + """Test that 100 Continue has correct reason phrase.""" + from gunicorn.asgi.protocol import ASGIProtocol + + worker = mock.MagicMock() + worker.cfg = MockConfig() + worker.log = mock.MagicMock() + + protocol = ASGIProtocol(worker) + reason = protocol._get_reason_phrase(100) + assert reason == "Continue" + + def test_reason_phrase_101(self): + """Test that 101 Switching Protocols has correct reason phrase.""" + from gunicorn.asgi.protocol import ASGIProtocol + + worker = mock.MagicMock() + worker.cfg = MockConfig() + worker.log = mock.MagicMock() + + protocol = ASGIProtocol(worker) + reason = protocol._get_reason_phrase(101) + assert reason == "Switching Protocols"