Add HTTP 103 Early Hints support (RFC 8297)

Implement HTTP 103 Early Hints as modern replacement for HTTP/2 Server Push.
This allows servers to send resource hints before the final response,
enabling browsers to preload assets in parallel.

WSGI support:
- Add wsgi.early_hints callback to environ dict
- Apps can call environ['wsgi.early_hints'](headers) to send 103 responses
- Silently ignored for HTTP/1.0 clients (don't support 1xx responses)

ASGI support:
- Handle http.response.informational message type
- Apps can await send({"type": "http.response.informational", "status": 103, ...})

HTTP/2 support:
- Add send_informational() method to HTTP2ServerConnection
- Add async send_informational() method to AsyncHTTP2Connection
- Wire up early hints in gthread worker for HTTP/2 requests

Includes unit tests and Docker integration tests for all protocols.
This commit is contained in:
Benoit Chesneau 2026-01-25 16:32:01 +01:00
parent 780e2cf055
commit 75b46bf6cf
10 changed files with 642 additions and 1 deletions

View File

@ -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")

View File

@ -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', '</style.css>; 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

View File

@ -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.

View File

@ -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.

View File

@ -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:

View File

@ -1,4 +1,4 @@
FROM nginx:1.25-alpine
FROM nginx:1.29-alpine
# Install curl for healthcheck
RUN apk add --no-cache curl

View File

@ -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', '</style.css>; rel=preload; as=style'),
('Link', '</app.js>; 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', '</critical.css>; rel=preload; as=style'),
])
# Second early hints
environ['wsgi.early_hints']([
('Link', '</deferred.js>; 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'

View File

@ -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;

View File

@ -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)

401
tests/test_early_hints.py Normal file
View File

@ -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', '</style.css>; 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: </style.css>; 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', '</style.css>; rel=preload; as=style'),
('Link', '</app.js>; 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: </style.css>; rel=preload; as=style\r\n" in sent_data
assert b"Link: </app.js>; 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', '</style.css>; 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', '</critical.css>; rel=preload; as=style'),
])
# Second early hints call
environ['wsgi.early_hints']([
('Link', '</app.js>; 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'</style.css>; 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: </style.css>; 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', '</style.css>')])
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', '</style.css>; 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', '</style.css>')])
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"