gunicorn/tests/docker/http2/test_http2_docker.py
Benoit Chesneau 75b46bf6cf 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.
2026-01-27 09:57:32 +01:00

389 lines
15 KiB
Python

"""HTTP/2 Docker integration tests.
These tests verify HTTP/2 functionality with real connections to gunicorn
running in Docker containers, both directly and through an nginx proxy.
"""
import asyncio
import ssl
import socket
import pytest
# Mark all tests in this module as requiring Docker
pytestmark = [
pytest.mark.docker,
pytest.mark.http2,
pytest.mark.integration,
]
class TestDirectHTTP2Connection:
"""Test direct HTTP/2 connections to gunicorn."""
def test_simple_get(self, h2_client, gunicorn_url):
"""Test basic GET request over HTTP/2."""
response = h2_client.get(f"{gunicorn_url}/")
assert response.status_code == 200
assert response.http_version == "HTTP/2"
assert response.text == "Hello HTTP/2!"
def test_health_endpoint(self, h2_client, gunicorn_url):
"""Test health check endpoint."""
response = h2_client.get(f"{gunicorn_url}/health")
assert response.status_code == 200
assert response.text == "OK"
def test_post_with_body(self, h2_client, gunicorn_url):
"""Test POST request with body."""
data = b"test data for echo"
response = h2_client.post(f"{gunicorn_url}/echo", content=data)
assert response.status_code == 200
assert response.content == data
def test_post_large_body(self, h2_client, gunicorn_url):
"""Test POST with larger body."""
data = b"X" * 65536 # 64KB
response = h2_client.post(f"{gunicorn_url}/echo", content=data)
assert response.status_code == 200
assert response.content == data
assert len(response.content) == 65536
def test_headers_endpoint(self, h2_client, gunicorn_url):
"""Test that custom headers are received."""
response = h2_client.get(
f"{gunicorn_url}/headers",
headers={"X-Custom-Header": "test-value"}
)
assert response.status_code == 200
headers = response.json()
assert "HTTP_X_CUSTOM_HEADER" in headers
assert headers["HTTP_X_CUSTOM_HEADER"] == "test-value"
def test_version_endpoint(self, h2_client, gunicorn_url):
"""Test server protocol version."""
response = h2_client.get(f"{gunicorn_url}/version")
assert response.status_code == 200
# HTTP/2 should report as HTTP/2.0 or similar
assert "HTTP" in response.text
def test_large_response(self, h2_client, gunicorn_url):
"""Test receiving large response over HTTP/2."""
response = h2_client.get(f"{gunicorn_url}/large")
assert response.status_code == 200
assert len(response.content) == 1024 * 1024 # 1MB
assert response.content == b"X" * (1024 * 1024)
def test_different_methods(self, h2_client, gunicorn_url):
"""Test various HTTP methods."""
for method in ["GET", "POST", "PUT", "DELETE", "PATCH"]:
response = h2_client.request(method, f"{gunicorn_url}/method")
assert response.status_code == 200
assert response.text == method
def test_status_codes(self, h2_client, gunicorn_url):
"""Test various HTTP status codes."""
for code in [200, 201, 400, 404, 500]:
response = h2_client.get(f"{gunicorn_url}/status?code={code}")
assert response.status_code == code
def test_not_found(self, h2_client, gunicorn_url):
"""Test 404 response."""
response = h2_client.get(f"{gunicorn_url}/nonexistent")
assert response.status_code == 404
class TestConcurrentStreams:
"""Test HTTP/2 multiplexing with concurrent streams."""
@pytest.mark.asyncio
async def test_concurrent_requests(self, async_h2_client, gunicorn_url):
"""Test multiple concurrent requests over single connection."""
httpx = pytest.importorskip("httpx")
async with httpx.AsyncClient(http2=True, verify=False, timeout=30.0) as client:
# Send 10 concurrent requests
tasks = [
client.get(f"{gunicorn_url}/")
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.http_version == "HTTP/2" for r in responses)
assert all(r.text == "Hello HTTP/2!" for r in responses)
@pytest.mark.asyncio
async def test_concurrent_mixed_requests(self, async_h2_client, gunicorn_url):
"""Test concurrent requests to different endpoints."""
httpx = pytest.importorskip("httpx")
async with httpx.AsyncClient(http2=True, verify=False, timeout=30.0) as client:
tasks = [
client.get(f"{gunicorn_url}/"),
client.get(f"{gunicorn_url}/headers"),
client.get(f"{gunicorn_url}/version"),
client.post(f"{gunicorn_url}/echo", content=b"test"),
client.get(f"{gunicorn_url}/health"),
]
responses = await asyncio.gather(*tasks)
assert len(responses) == 5
assert all(r.status_code == 200 for r in responses)
@pytest.mark.asyncio
async def test_many_concurrent_streams(self, async_h2_client, gunicorn_url):
"""Test many concurrent streams (up to HTTP/2 limit)."""
httpx = pytest.importorskip("httpx")
async with httpx.AsyncClient(http2=True, verify=False, timeout=60.0) as client:
# Send 50 concurrent requests
tasks = [
client.get(f"{gunicorn_url}/")
for _ in range(50)
]
responses = await asyncio.gather(*tasks)
assert len(responses) == 50
assert all(r.status_code == 200 for r in responses)
class TestHTTP2BehindProxy:
"""Test HTTP/2 through nginx proxy."""
def test_simple_get_via_proxy(self, h2_client, nginx_url):
"""Test basic GET through nginx proxy."""
response = h2_client.get(f"{nginx_url}/")
assert response.status_code == 200
assert response.http_version == "HTTP/2"
assert response.text == "Hello HTTP/2!"
def test_post_via_proxy(self, h2_client, nginx_url):
"""Test POST through nginx proxy."""
data = b"proxied data"
response = h2_client.post(f"{nginx_url}/echo", content=data)
assert response.status_code == 200
assert response.content == data
def test_headers_preserved(self, h2_client, nginx_url):
"""Test that custom headers pass through proxy."""
response = h2_client.get(
f"{nginx_url}/headers",
headers={"X-Custom": "test-value"}
)
assert response.status_code == 200
headers = response.json()
assert "HTTP_X_CUSTOM" in headers
assert headers["HTTP_X_CUSTOM"] == "test-value"
def test_forwarded_headers(self, h2_client, nginx_url):
"""Test that proxy adds forwarded headers."""
response = h2_client.get(f"{nginx_url}/headers")
assert response.status_code == 200
headers = response.json()
# Nginx should add X-Forwarded-* headers
assert "HTTP_X_FORWARDED_FOR" in headers
assert "HTTP_X_FORWARDED_PROTO" in headers
assert headers["HTTP_X_FORWARDED_PROTO"] == "https"
def test_large_response_via_proxy(self, h2_client, nginx_url):
"""Test large response through proxy."""
response = h2_client.get(f"{nginx_url}/large")
assert response.status_code == 200
assert len(response.content) == 1024 * 1024
@pytest.mark.asyncio
async def test_concurrent_via_proxy(self, async_h2_client, nginx_url):
"""Test concurrent requests through proxy."""
httpx = pytest.importorskip("httpx")
async with httpx.AsyncClient(http2=True, verify=False, timeout=30.0) as client:
tasks = [
client.get(f"{nginx_url}/")
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.http_version == "HTTP/2" for r in responses)
class TestHTTP2Protocol:
"""Test HTTP/2 specific protocol behaviors."""
def test_alpn_negotiation(self, gunicorn_url):
"""Verify ALPN negotiates h2 protocol."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(['h2', 'http/1.1'])
with socket.create_connection(('127.0.0.1', 8443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname='localhost') as ssock:
selected = ssock.selected_alpn_protocol()
assert selected == 'h2', f"Expected h2, got {selected}"
def test_alpn_http11_fallback(self, gunicorn_url):
"""Test that server accepts HTTP/1.1 via ALPN."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(['http/1.1'])
with socket.create_connection(('127.0.0.1', 8443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname='localhost') as ssock:
selected = ssock.selected_alpn_protocol()
assert selected == 'http/1.1', f"Expected http/1.1, got {selected}"
def test_http11_client_works(self, h1_client, gunicorn_url):
"""Test that HTTP/1.1 client can still connect."""
response = h1_client.get(f"{gunicorn_url}/")
assert response.status_code == 200
assert response.http_version == "HTTP/1.1"
assert response.text == "Hello HTTP/2!"
def test_tls_version(self, gunicorn_url):
"""Verify TLS 1.2+ is used."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection(('127.0.0.1', 8443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname='localhost') as ssock:
version = ssock.version()
assert version in ('TLSv1.2', 'TLSv1.3'), f"Unexpected TLS version: {version}"
class TestHTTP2ErrorHandling:
"""Test HTTP/2 error handling."""
def test_invalid_path(self, h2_client, gunicorn_url):
"""Test request to non-existent path."""
response = h2_client.get(f"{gunicorn_url}/does/not/exist")
assert response.status_code == 404
assert response.http_version == "HTTP/2"
def test_server_error(self, h2_client, gunicorn_url):
"""Test server error response."""
response = h2_client.get(f"{gunicorn_url}/status?code=500")
assert response.status_code == 500
assert response.http_version == "HTTP/2"
@pytest.mark.asyncio
async def test_connection_reuse_after_error(self, async_h2_client, gunicorn_url):
"""Test that connection is reused after error response."""
httpx = pytest.importorskip("httpx")
async with httpx.AsyncClient(http2=True, verify=False, timeout=30.0) as client:
# First request - error
r1 = await client.get(f"{gunicorn_url}/status?code=500")
assert r1.status_code == 500
# Second request - should work on same connection
r2 = await client.get(f"{gunicorn_url}/")
assert r2.status_code == 200
assert r2.text == "Hello HTTP/2!"
class TestHTTP2Headers:
"""Test HTTP/2 header handling."""
def test_response_headers(self, h2_client, gunicorn_url):
"""Test that response headers are correctly received."""
response = h2_client.get(f"{gunicorn_url}/")
assert "content-type" in response.headers
assert "content-length" in response.headers
assert response.headers["x-request-path"] == "/"
assert response.headers["x-request-method"] == "GET"
def test_many_request_headers(self, h2_client, gunicorn_url):
"""Test sending many headers."""
headers = {f"X-Custom-{i}": f"value-{i}" for i in range(20)}
response = h2_client.get(f"{gunicorn_url}/headers", headers=headers)
assert response.status_code == 200
received = response.json()
for i in range(20):
key = f"HTTP_X_CUSTOM_{i}"
assert key in received
assert received[key] == f"value-{i}"
def test_header_case_insensitivity(self, h2_client, gunicorn_url):
"""Test HTTP/2 header case handling."""
response = h2_client.get(
f"{gunicorn_url}/headers",
headers={"X-Mixed-Case-Header": "test"}
)
assert response.status_code == 200
# HTTP/2 lowercases headers, but WSGI uppercases them
headers = response.json()
assert "HTTP_X_MIXED_CASE_HEADER" in headers
class TestHTTP2Performance:
"""Performance-related HTTP/2 tests."""
@pytest.mark.asyncio
async def test_parallel_large_requests(self, async_h2_client, gunicorn_url):
"""Test parallel requests with large responses."""
httpx = pytest.importorskip("httpx")
async with httpx.AsyncClient(http2=True, verify=False, timeout=60.0) as client:
tasks = [
client.get(f"{gunicorn_url}/large")
for _ in range(5)
]
responses = await asyncio.gather(*tasks)
assert len(responses) == 5
assert all(r.status_code == 200 for r in responses)
assert all(len(r.content) == 1024 * 1024 for r in responses)
def test_connection_keepalive(self, h2_client, gunicorn_url):
"""Test that connections are kept alive."""
# Multiple requests should reuse the same connection
for _ in range(5):
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)