From fa5e319f15a5e63a313a57b1b70ee0be45cde2c2 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Tue, 27 Jan 2026 13:15:36 +0100 Subject: [PATCH] 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 --- docs/content/guides/http2.md | 25 ++ examples/http2_features/Dockerfile | 22 ++ examples/http2_features/__init__.py | 3 + examples/http2_features/docker-compose.yml | 13 + examples/http2_features/gunicorn_conf.py | 20 ++ examples/http2_features/http2_app.py | 270 +++++++++++++++++++ examples/http2_features/requirements.txt | 2 + examples/http2_features/test_http2.py | 291 +++++++++++++++++++++ 8 files changed, 646 insertions(+) create mode 100644 examples/http2_features/Dockerfile create mode 100644 examples/http2_features/__init__.py create mode 100644 examples/http2_features/docker-compose.yml create mode 100644 examples/http2_features/gunicorn_conf.py create mode 100644 examples/http2_features/http2_app.py create mode 100644 examples/http2_features/requirements.txt create mode 100644 examples/http2_features/test_http2.py diff --git a/docs/content/guides/http2.md b/docs/content/guides/http2.md index b703e9d0..b958dcd8 100644 --- a/docs/content/guides/http2.md +++ b/docs/content/guides/http2.md @@ -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 diff --git a/examples/http2_features/Dockerfile b/examples/http2_features/Dockerfile new file mode 100644 index 00000000..3e977d1f --- /dev/null +++ b/examples/http2_features/Dockerfile @@ -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"] diff --git a/examples/http2_features/__init__.py b/examples/http2_features/__init__.py new file mode 100644 index 00000000..530e35ca --- /dev/null +++ b/examples/http2_features/__init__.py @@ -0,0 +1,3 @@ +# +# This file is part of gunicorn released under the MIT license. +# See the NOTICE for more information. diff --git a/examples/http2_features/docker-compose.yml b/examples/http2_features/docker-compose.yml new file mode 100644 index 00000000..1545e50e --- /dev/null +++ b/examples/http2_features/docker-compose.yml @@ -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 diff --git a/examples/http2_features/gunicorn_conf.py b/examples/http2_features/gunicorn_conf.py new file mode 100644 index 00000000..dcbbf3d5 --- /dev/null +++ b/examples/http2_features/gunicorn_conf.py @@ -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" diff --git a/examples/http2_features/http2_app.py b/examples/http2_features/http2_app.py new file mode 100644 index 00000000..e325d27a --- /dev/null +++ b/examples/http2_features/http2_app.py @@ -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 diff --git a/examples/http2_features/requirements.txt b/examples/http2_features/requirements.txt new file mode 100644 index 00000000..ad0709d2 --- /dev/null +++ b/examples/http2_features/requirements.txt @@ -0,0 +1,2 @@ +# Requirements for testing HTTP/2 features +httpx>=0.24.0 diff --git a/examples/http2_features/test_http2.py b/examples/http2_features/test_http2.py new file mode 100644 index 00000000..09cacf3a --- /dev/null +++ b/examples/http2_features/test_http2.py @@ -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())