gunicorn/tests/test_http2_integration.py
Benoit Chesneau 780e2cf055 Add HTTP/2 tests
Unit tests for HTTP/2 implementation:
- test_http2_stream.py: Stream state management tests
- test_http2_request.py: Request interface tests
- test_http2_connection.py: Connection handling tests
- test_http2_async_connection.py: Async connection tests
- test_http2_config.py: Configuration tests
- test_http2_alpn.py: ALPN negotiation tests
- test_http2_errors.py: Error handling tests
- test_http2_integration.py: Integration tests

Docker integration tests:
- Full HTTP/2 testing environment with nginx proxy
- Direct connection tests and proxy tests
- Concurrent stream tests
- Protocol behavior tests
- Error handling tests
- Header handling tests
- Performance tests
2026-01-27 09:57:32 +01:00

643 lines
21 KiB
Python

# -*- coding: utf-8 -
#
# This file is part of gunicorn released under the MIT license.
# See the NOTICE for more information.
"""Integration tests for HTTP/2 with full request/response cycles."""
import pytest
from io import BytesIO
# Check if h2 is available
try:
import h2.connection
import h2.config
import h2.events
H2_AVAILABLE = True
except ImportError:
H2_AVAILABLE = False
pytestmark = pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available")
def get_header_value(headers_list, name):
"""Extract a header value from h2 headers list.
h2 library may return headers as bytes or strings depending on version.
"""
for header_name, header_value in headers_list:
name_str = header_name.decode() if isinstance(header_name, bytes) else header_name
if name_str == name:
return header_value.decode() if isinstance(header_value, bytes) else header_value
return None
class MockConfig:
"""Mock gunicorn configuration for HTTP/2."""
def __init__(self):
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
class MockSocket:
"""Mock socket for integration testing."""
def __init__(self, data=b''):
self._recv_buffer = BytesIO(data)
self._sent = bytearray()
def recv(self, size):
return self._recv_buffer.read(size)
def sendall(self, data):
self._sent.extend(data)
def get_sent_data(self):
return bytes(self._sent)
def set_recv_data(self, data):
self._recv_buffer = BytesIO(data)
def clear_sent(self):
self._sent.clear()
def create_h2_client():
"""Create an h2 client connection."""
config = h2.config.H2Configuration(client_side=True)
conn = h2.connection.H2Connection(config=config)
conn.initiate_connection()
return conn
class TestSimpleRequestResponse:
"""Test simple request/response cycles."""
def test_get_request_text_response(self):
"""Test a complete GET request with text response."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
# Client setup
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Client sends request
client.send_headers(1, [
(':method', 'GET'),
(':path', '/hello'),
(':scheme', 'https'),
(':authority', 'example.com'),
('accept', 'text/plain'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
# Server receives request
requests = server.receive_data()
assert len(requests) == 1
req = requests[0]
# Verify request properties
assert req.method == 'GET'
assert req.path == '/hello'
assert req.version == (2, 0)
assert req.get_header('ACCEPT') == 'text/plain'
# Server sends response
sock.clear_sent()
server.send_response(
stream_id=1,
status=200,
headers=[
('content-type', 'text/plain'),
('content-length', '12'),
],
body=b'Hello World!'
)
# Client verifies response
events = client.receive_data(sock.get_sent_data())
response_events = [e for e in events if isinstance(e, h2.events.ResponseReceived)]
assert len(response_events) == 1
headers_list = response_events[0].headers
assert get_header_value(headers_list, ':status') == '200'
assert get_header_value(headers_list, 'content-type') == 'text/plain'
data_events = [e for e in events if isinstance(e, h2.events.DataReceived)]
assert len(data_events) == 1
assert data_events[0].data == b'Hello World!'
def test_post_request_with_json_body(self):
"""Test POST request with JSON body and response."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Client sends POST with body
request_body = b'{"username": "test", "action": "login"}'
client.send_headers(1, [
(':method', 'POST'),
(':path', '/api/login'),
(':scheme', 'https'),
(':authority', 'api.example.com'),
('content-type', 'application/json'),
('content-length', str(len(request_body))),
], end_stream=False)
client.send_data(1, request_body, end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert len(requests) == 1
req = requests[0]
assert req.method == 'POST'
assert req.content_type == 'application/json'
assert req.body.read() == request_body
# Server responds
sock.clear_sent()
response_body = b'{"status": "success", "token": "abc123"}'
server.send_response(
stream_id=1,
status=200,
headers=[
('content-type', 'application/json'),
('content-length', str(len(response_body))),
],
body=response_body
)
events = client.receive_data(sock.get_sent_data())
data_events = [e for e in events if isinstance(e, h2.events.DataReceived)]
assert data_events[0].data == response_body
class TestMultipleStreams:
"""Test concurrent stream handling."""
def test_concurrent_requests(self):
"""Test handling multiple concurrent requests."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Client sends three concurrent requests
for stream_id, path in [(1, '/one'), (3, '/two'), (5, '/three')]:
client.send_headers(stream_id, [
(':method', 'GET'),
(':path', path),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert len(requests) == 3
paths = {req.path for req in requests}
assert paths == {'/one', '/two', '/three'}
# Server responds to all
sock.clear_sent()
for req in requests:
server.send_response(
stream_id=req.stream.stream_id,
status=200,
headers=[('x-path', req.path)],
body=req.path.encode()
)
events = client.receive_data(sock.get_sent_data())
response_events = [e for e in events if isinstance(e, h2.events.ResponseReceived)]
assert len(response_events) == 3
def test_interleaved_request_response(self):
"""Test interleaved request and response processing."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# First request
client.send_headers(1, [
(':method', 'GET'),
(':path', '/first'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert len(requests) == 1
# Respond to first before second arrives
sock.clear_sent()
server.send_response(1, 200, [], b'First response')
client.receive_data(sock.get_sent_data())
# Second request
client.send_headers(3, [
(':method', 'GET'),
(':path', '/second'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert len(requests) == 1
# Respond to second
sock.clear_sent()
server.send_response(3, 200, [], b'Second response')
events = client.receive_data(sock.get_sent_data())
data_events = [e for e in events if isinstance(e, h2.events.DataReceived)]
assert data_events[0].data == b'Second response'
class TestErrorHandling:
"""Test error response scenarios."""
def test_404_response(self):
"""Test 404 Not Found response."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'GET'),
(':path', '/nonexistent'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
server.receive_data()
sock.clear_sent()
server.send_error(1, 404, "Not Found")
events = client.receive_data(sock.get_sent_data())
response_events = [e for e in events if isinstance(e, h2.events.ResponseReceived)]
headers_list = response_events[0].headers
assert get_header_value(headers_list, ':status') == '404'
def test_500_response(self):
"""Test 500 Internal Server Error response."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'GET'),
(':path', '/error'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
server.receive_data()
sock.clear_sent()
server.send_error(1, 500, "Internal Server Error")
events = client.receive_data(sock.get_sent_data())
response_events = [e for e in events if isinstance(e, h2.events.ResponseReceived)]
headers_list = response_events[0].headers
assert get_header_value(headers_list, ':status') == '500'
def test_stream_reset_by_server(self):
"""Test server resetting a stream."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Start a request but don't finish
client.send_headers(1, [
(':method', 'POST'),
(':path', '/upload'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=False)
sock.set_recv_data(client.data_to_send())
server.receive_data()
# Server resets the stream
sock.clear_sent()
server.reset_stream(1, error_code=0x8) # CANCEL
events = client.receive_data(sock.get_sent_data())
reset_events = [e for e in events if isinstance(e, h2.events.StreamReset)]
assert len(reset_events) == 1
assert reset_events[0].error_code == 0x8
class TestConnectionLifecycle:
"""Test connection lifecycle events."""
def test_graceful_shutdown(self):
"""Test graceful connection shutdown with GOAWAY."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Process a request first
client.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
server.receive_data()
sock.clear_sent()
server.send_response(1, 200, [], b'OK')
client.receive_data(sock.get_sent_data())
# Server initiates graceful shutdown
sock.clear_sent()
server.close()
events = client.receive_data(sock.get_sent_data())
goaway_events = [e for e in events if isinstance(e, h2.events.ConnectionTerminated)]
assert len(goaway_events) == 1
def test_client_initiated_close(self):
"""Test handling client-initiated connection close."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Client closes connection
client.close_connection()
sock.set_recv_data(client.data_to_send())
server.receive_data()
assert server.is_closed is True
class TestLargePayloads:
"""Test handling of large payloads."""
def test_moderate_request_body(self):
"""Test handling moderate-sized request body within flow control."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
# Send body that fits within initial window (65535 bytes)
body = b'X' * 10000
client.send_headers(1, [
(':method', 'POST'),
(':path', '/upload'),
(':scheme', 'https'),
(':authority', 'example.com'),
('content-length', str(len(body))),
], end_stream=False)
client.send_data(1, body, end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert len(requests) == 1
received_body = requests[0].body.read()
assert len(received_body) == len(body)
assert received_body == body
def test_moderate_response_body(self):
"""Test sending moderate-sized response body."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'GET'),
(':path', '/moderate'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
server.receive_data()
# Send moderate response (within max frame size)
moderate_body = b'Y' * 8000
sock.clear_sent()
server.send_response(1, 200, [('content-length', str(len(moderate_body)))], moderate_body)
# Client receives response
events = client.receive_data(sock.get_sent_data())
data_events = [e for e in events if isinstance(e, h2.events.DataReceived)]
received_data = b''.join(e.data for e in data_events)
assert received_data == moderate_body
class TestSpecialCases:
"""Test special/edge cases."""
def test_head_request(self):
"""Test HEAD request (no body in response)."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'HEAD'),
(':path', '/resource'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert requests[0].method == 'HEAD'
# Send response with content-length but no body
sock.clear_sent()
server.send_response(
1, 200,
[('content-length', '1000'), ('content-type', 'text/html')],
body=None
)
events = client.receive_data(sock.get_sent_data())
stream_ended = [e for e in events if isinstance(e, h2.events.StreamEnded)]
assert len(stream_ended) == 1
def test_options_request(self):
"""Test OPTIONS request."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'OPTIONS'),
(':path', '*'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
assert requests[0].method == 'OPTIONS'
assert requests[0].uri == '*'
def test_request_with_query_string(self):
"""Test request with query string parameters."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'GET'),
(':path', '/search?q=test&page=2&sort=desc'),
(':scheme', 'https'),
(':authority', 'example.com'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
req = requests[0]
assert req.path == '/search'
assert req.query == 'q=test&page=2&sort=desc'
def test_request_with_multiple_headers_same_name(self):
"""Test request with multiple headers of the same name."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
server = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
server.initiate_connection()
client = create_h2_client()
sock.set_recv_data(client.data_to_send())
server.receive_data()
client.receive_data(sock.get_sent_data())
client.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'example.com'),
('accept', 'text/html'),
('accept', 'application/json'),
('accept', '*/*'),
], end_stream=True)
sock.set_recv_data(client.data_to_send())
requests = server.receive_data()
req = requests[0]
accept_headers = [h[1] for h in req.headers if h[0] == 'ACCEPT']
assert len(accept_headers) == 3