docs(http2): add ASGI example demonstrating priority and trailers

Add complete HTTP/2 example in examples/http2_features/:
- ASGI app showing priority access and trailer sending
- Test script using raw h2 library for HTTP/2 testing
- Docker setup for easy testing
- Documentation update referencing the example

The example demonstrates:
- Reading http.response.priority extension in ASGI scope
- Sending http.response.trailers messages
- Multiple streams on the same connection
This commit is contained in:
Benoit Chesneau 2026-01-27 13:15:36 +01:00
parent 0f298e4838
commit fa5e319f15
8 changed files with 646 additions and 0 deletions

View File

@ -476,6 +476,31 @@ with httpx.Client(http2=True, verify=False) as client:
print(f"HTTP Version: {response.http_version}")
```
## Complete Example
A complete HTTP/2 example demonstrating priority and trailers is available in the
`examples/http2_features/` directory. This includes:
- **http2_app.py**: ASGI application showing priority access and trailer sending
- **test_http2.py**: Test script verifying HTTP/2 features
- **Dockerfile** and **docker-compose.yml**: Docker setup for testing
To run the example:
```bash
cd examples/http2_features
docker compose up --build
# In another terminal:
docker compose exec http2-features python /app/http2_features/test_http2.py
```
The example demonstrates:
1. **Priority access**: Reading `http.response.priority` extension in ASGI scope
2. **Response trailers**: Sending `http.response.trailers` messages
3. **Combined features**: Using both priority and trailers in one response
## See Also
- [Settings Reference](reference/settings.md#http2_max_concurrent_streams) - All HTTP/2 settings

View File

@ -0,0 +1,22 @@
FROM python:3.12-slim
WORKDIR /app
# Install h2 for HTTP/2 support and httpx for testing
RUN pip install --no-cache-dir h2 httpx
# Copy gunicorn source and install
COPY . /app/gunicorn-src
RUN pip install /app/gunicorn-src
# Copy example app
COPY examples/http2_features /app/http2_features
# Copy SSL certificates
COPY examples/server.crt /app/certs/server.crt
COPY examples/server.key /app/certs/server.key
ENV PYTHONPATH=/app
EXPOSE 8443
CMD ["gunicorn", "http2_features.http2_app:app", "-c", "http2_features/gunicorn_conf.py"]

View File

@ -0,0 +1,3 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.

View File

@ -0,0 +1,13 @@
services:
http2-features:
build:
context: ../..
dockerfile: examples/http2_features/Dockerfile
ports:
- "8443:8443"
healthcheck:
test: ["CMD", "python", "-c", "import httpx; httpx.get('https://127.0.0.1:8443/health', verify=False)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s

View File

@ -0,0 +1,20 @@
# Gunicorn configuration for HTTP/2 features example
bind = "0.0.0.0:8443"
workers = 2
worker_class = "asgi"
# SSL configuration (required for HTTP/2)
certfile = "/app/certs/server.crt"
keyfile = "/app/certs/server.key"
# HTTP/2 configuration
http_protocols = "h2,h1"
http2_max_concurrent_streams = 100
http2_initial_window_size = 65535
http2_max_frame_size = 16384
# Logging
accesslog = "-"
errorlog = "-"
loglevel = "info"

View File

@ -0,0 +1,270 @@
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""
HTTP/2 ASGI application demonstrating priority and trailers.
This example shows how to:
- Access stream priority information from HTTP/2 requests
- Send response trailers (useful for gRPC, checksums, etc.)
Run with:
cd examples/http2_features
docker compose up --build
Test with:
python test_http2.py
Or manually:
curl -k --http2 https://localhost:8443/
curl -k --http2 https://localhost:8443/priority
curl -k --http2 https://localhost:8443/trailers
"""
import json
import hashlib
async def app(scope, receive, send):
"""ASGI application demonstrating HTTP/2 priority and trailers."""
if scope["type"] == "lifespan":
await handle_lifespan(scope, receive, send)
elif scope["type"] == "http":
await handle_http(scope, receive, send)
else:
raise ValueError(f"Unknown scope type: {scope['type']}")
async def handle_lifespan(scope, receive, send):
"""Handle lifespan events (startup/shutdown)."""
while True:
message = await receive()
if message["type"] == "lifespan.startup":
print("HTTP/2 features app starting...")
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
print("HTTP/2 features app shutting down...")
await send({"type": "lifespan.shutdown.complete"})
return
async def handle_http(scope, receive, send):
"""Route HTTP requests to handlers."""
path = scope["path"]
method = scope["method"]
if path == "/" and method == "GET":
await handle_index(scope, receive, send)
elif path == "/priority" and method == "GET":
await handle_priority(scope, receive, send)
elif path == "/trailers" and method in ("GET", "POST"):
await handle_trailers(scope, receive, send)
elif path == "/combined" and method in ("GET", "POST"):
await handle_combined(scope, receive, send)
elif path == "/health" and method == "GET":
await send_response(send, 200, b"OK")
else:
await send_response(send, 404, b"Not Found\n")
async def handle_index(scope, receive, send):
"""Show available endpoints and HTTP/2 features."""
extensions = scope.get("extensions", {})
http_version = scope.get("http_version", "1.1")
info = {
"message": "HTTP/2 Features Demo",
"http_version": http_version,
"endpoints": {
"/": "This info page",
"/priority": "Shows stream priority information",
"/trailers": "Demonstrates response trailers with checksum",
"/combined": "Shows both priority and trailers",
"/health": "Health check endpoint",
},
"extensions": list(extensions.keys()),
}
body = json.dumps(info, indent=2).encode() + b"\n"
await send_response(send, 200, body, content_type=b"application/json")
async def handle_priority(scope, receive, send):
"""Return stream priority information.
HTTP/2 allows clients to indicate relative importance of requests.
Gunicorn exposes this through the http.response.priority extension.
"""
extensions = scope.get("extensions", {})
priority_info = extensions.get("http.response.priority")
if priority_info:
response = {
"http_version": scope.get("http_version", "1.1"),
"priority": {
"weight": priority_info["weight"],
"depends_on": priority_info["depends_on"],
"description": (
f"Weight {priority_info['weight']}/256 - "
f"{'high' if priority_info['weight'] > 128 else 'normal' if priority_info['weight'] > 64 else 'low'} priority"
),
},
"note": "Priority is advisory - use for scheduling hints",
}
else:
response = {
"http_version": scope.get("http_version", "1.1"),
"priority": None,
"note": "Priority information only available for HTTP/2 requests",
}
body = json.dumps(response, indent=2).encode() + b"\n"
await send_response(send, 200, body, content_type=b"application/json")
async def handle_trailers(scope, receive, send):
"""Demonstrate response trailers.
Trailers are headers sent after the response body.
Common uses: gRPC status codes, checksums, timing info.
"""
extensions = scope.get("extensions", {})
supports_trailers = "http.response.trailers" in extensions
# Read request body if POST
body_data = b""
if scope["method"] == "POST":
body_data = await read_body(receive)
# Generate response
response_body = body_data if body_data else b"Hello from HTTP/2 with trailers!\n"
# Calculate checksum for trailer
checksum = hashlib.md5(response_body).hexdigest()
if supports_trailers:
# Send response announcing trailers
await send({
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/octet-stream"),
(b"trailer", b"content-md5, x-processing-time"),
],
})
# Send body
await send({
"type": "http.response.body",
"body": response_body,
"more_body": False,
})
# Send trailers
await send({
"type": "http.response.trailers",
"headers": [
(b"content-md5", checksum.encode()),
(b"x-processing-time", b"42ms"),
],
})
else:
# HTTP/1.1 fallback - include checksum in regular headers
response = {
"message": "Trailers not supported (HTTP/1.1)",
"data": response_body.decode("utf-8", errors="replace"),
"checksum_in_header": checksum,
}
body = json.dumps(response, indent=2).encode() + b"\n"
await send_response(
send, 200, body,
content_type=b"application/json",
extra_headers=[(b"x-checksum", checksum.encode())]
)
async def handle_combined(scope, receive, send):
"""Show both priority and trailers in one response.
This demonstrates a realistic scenario like gRPC where
priority affects scheduling and trailers carry status.
"""
extensions = scope.get("extensions", {})
priority_info = extensions.get("http.response.priority")
supports_trailers = "http.response.trailers" in extensions
# Build response showing all HTTP/2 features
response = {
"http_version": scope.get("http_version", "1.1"),
"priority": None,
"trailers_supported": supports_trailers,
}
if priority_info:
response["priority"] = {
"weight": priority_info["weight"],
"depends_on": priority_info["depends_on"],
}
response_body = json.dumps(response, indent=2).encode() + b"\n"
checksum = hashlib.md5(response_body).hexdigest()
if supports_trailers:
# Full HTTP/2 response with trailers
await send({
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"trailer", b"content-md5, x-status"),
],
})
await send({
"type": "http.response.body",
"body": response_body,
"more_body": False,
})
await send({
"type": "http.response.trailers",
"headers": [
(b"content-md5", checksum.encode()),
(b"x-status", b"success"),
],
})
else:
await send_response(send, 200, response_body, content_type=b"application/json")
async def send_response(send, status, body, content_type=b"text/plain", extra_headers=None):
"""Send a simple HTTP response."""
headers = [
(b"content-type", content_type),
(b"content-length", str(len(body)).encode()),
]
if extra_headers:
headers.extend(extra_headers)
await send({
"type": "http.response.start",
"status": status,
"headers": headers,
})
await send({
"type": "http.response.body",
"body": body,
})
async def read_body(receive):
"""Read the full request body."""
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body", False):
break
return body

View File

@ -0,0 +1,2 @@
# Requirements for testing HTTP/2 features
httpx>=0.24.0

View File

@ -0,0 +1,291 @@
#!/usr/bin/env python
"""
Test script for HTTP/2 features example.
This script tests:
- HTTP/2 connection establishment
- Stream priority access
- Response trailers
Run the server first:
docker compose up --build
Then run tests:
python test_http2.py
Or run directly against local server:
python test_http2.py --url https://localhost:8443
"""
import argparse
import json
import ssl
import socket
import sys
from urllib.parse import urlparse
def create_h2_connection(host, port):
"""Create an HTTP/2 connection using the h2 library."""
try:
import h2.connection
import h2.config
except ImportError:
print("Please install h2: pip install h2")
sys.exit(1)
# Create socket with SSL
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(['h2'])
sock = ctx.wrap_socket(sock, server_hostname=host)
sock.connect((host, port))
sock.settimeout(10.0)
# Verify ALPN
alpn = sock.selected_alpn_protocol()
if alpn != 'h2':
raise RuntimeError(f"HTTP/2 not negotiated, got: {alpn}")
# Create h2 connection
config = h2.config.H2Configuration(client_side=True)
h2_conn = h2.connection.H2Connection(config=config)
h2_conn.initiate_connection()
sock.sendall(h2_conn.data_to_send())
# Receive server settings
data = sock.recv(65536)
h2_conn.receive_data(data)
sock.sendall(h2_conn.data_to_send())
return sock, h2_conn
def h2_request(sock, h2_conn, stream_id, method, path, authority):
"""Make an HTTP/2 request and return the response."""
import h2.events
# Send request
h2_conn.send_headers(stream_id, [
(':method', method),
(':path', path),
(':authority', authority),
(':scheme', 'https'),
], end_stream=True)
sock.sendall(h2_conn.data_to_send())
# Collect response
status = None
headers = {}
body = b''
trailers = {}
while True:
data = sock.recv(65536)
if not data:
break
events = h2_conn.receive_data(data)
to_send = h2_conn.data_to_send()
if to_send:
sock.sendall(to_send)
for event in events:
if isinstance(event, h2.events.ResponseReceived):
if event.stream_id == stream_id:
for name, value in event.headers:
if name == b':status':
status = int(value.decode())
else:
headers[name.decode()] = value.decode()
elif isinstance(event, h2.events.DataReceived):
if event.stream_id == stream_id:
body += event.data
elif isinstance(event, h2.events.TrailersReceived):
if event.stream_id == stream_id:
for name, value in event.headers:
trailers[name.decode()] = value.decode()
elif isinstance(event, h2.events.StreamEnded):
if event.stream_id == stream_id:
return {
'status': status,
'headers': headers,
'body': body,
'trailers': trailers,
}
elif isinstance(event, h2.events.ConnectionTerminated):
raise RuntimeError(f"Connection terminated: {event.error_code}")
return None
def test_http2_connection(host, port):
"""Test that HTTP/2 is negotiated."""
print("\n=== Testing HTTP/2 Connection ===")
try:
sock, h2_conn = create_h2_connection(host, port)
print("HTTP/2 connection established successfully!")
response = h2_request(sock, h2_conn, 1, 'GET', '/', f'{host}:{port}')
print(f"Status: {response['status']}")
data = json.loads(response['body'].decode())
print(f"Extensions available: {data.get('extensions', [])}")
sock.close()
return response['status'] == 200
except Exception as e:
print(f"ERROR: {e}")
return False
def test_priority(host, port):
"""Test stream priority endpoint."""
print("\n=== Testing Stream Priority ===")
try:
sock, h2_conn = create_h2_connection(host, port)
response = h2_request(sock, h2_conn, 1, 'GET', '/priority', f'{host}:{port}')
print(f"Status: {response['status']}")
data = json.loads(response['body'].decode())
print(f"Priority info: {data.get('priority')}")
if data.get("priority"):
print(f" Weight: {data['priority']['weight']}")
print(f" Depends on: {data['priority']['depends_on']}")
sock.close()
return response['status'] == 200 and data.get("priority") is not None
except Exception as e:
print(f"ERROR: {e}")
return False
def test_trailers(host, port):
"""Test response trailers."""
print("\n=== Testing Response Trailers ===")
try:
sock, h2_conn = create_h2_connection(host, port)
response = h2_request(sock, h2_conn, 1, 'GET', '/trailers', f'{host}:{port}')
print(f"Status: {response['status']}")
print(f"Headers: {response['headers']}")
if response['trailers']:
print(f"Trailers received: {response['trailers']}")
if 'content-md5' in response['trailers']:
print(f" Content-MD5: {response['trailers']['content-md5']}")
else:
print("Note: No trailers received (client may not have advertised support)")
sock.close()
return response['status'] == 200
except Exception as e:
print(f"ERROR: {e}")
return False
def test_combined(host, port):
"""Test combined priority and trailers."""
print("\n=== Testing Combined Features ===")
try:
sock, h2_conn = create_h2_connection(host, port)
response = h2_request(sock, h2_conn, 1, 'GET', '/combined', f'{host}:{port}')
print(f"Status: {response['status']}")
data = json.loads(response['body'].decode())
print(f"Response: {json.dumps(data, indent=2)}")
if response['trailers']:
print(f"Trailers: {response['trailers']}")
sock.close()
return response['status'] == 200
except Exception as e:
print(f"ERROR: {e}")
return False
def test_multiple_streams(host, port):
"""Test multiple requests on the same connection."""
print("\n=== Testing Multiple Streams ===")
try:
sock, h2_conn = create_h2_connection(host, port)
# Make multiple requests on the same connection
paths = ['/', '/priority', '/trailers', '/combined']
for i, path in enumerate(paths):
stream_id = i * 2 + 1 # Odd numbers for client-initiated streams
response = h2_request(sock, h2_conn, stream_id, 'GET', path, f'{host}:{port}')
print(f" {path}: {response['status']}")
sock.close()
return True
except Exception as e:
print(f"ERROR: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="Test HTTP/2 features")
parser.add_argument(
"--url",
default="https://localhost:8443",
help="Base URL of the server (default: https://localhost:8443)"
)
args = parser.parse_args()
parsed = urlparse(args.url)
host = parsed.hostname or 'localhost'
port = parsed.port or 8443
print(f"Testing against: {host}:{port}")
results = []
try:
results.append(("HTTP/2 Connection", test_http2_connection(host, port)))
results.append(("Stream Priority", test_priority(host, port)))
results.append(("Response Trailers", test_trailers(host, port)))
results.append(("Combined Features", test_combined(host, port)))
results.append(("Multiple Streams", test_multiple_streams(host, port)))
except ConnectionRefusedError:
print(f"\nConnection refused to {host}:{port}")
print("Make sure the server is running: docker compose up --build")
return 1
except Exception as e:
print(f"\nUnexpected error: {e}")
return 1
print("\n=== Test Results ===")
all_passed = True
for name, passed in results:
status = "PASS" if passed else "FAIL"
print(f" {name}: {status}")
if not passed:
all_passed = False
if all_passed:
print("\nAll tests passed!")
return 0
else:
print("\nSome tests failed.")
return 1
if __name__ == "__main__":
sys.exit(main())