mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
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.
389 lines
15 KiB
Python
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)
|