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