mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
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:
parent
780e2cf055
commit
75b46bf6cf
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM nginx:1.25-alpine
|
||||
FROM nginx:1.29-alpine
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
401
tests/test_early_hints.py
Normal 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"
|
||||
Loading…
x
Reference in New Issue
Block a user