mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-01 18:21:30 +08:00
1196 lines
39 KiB
Python
1196 lines
39 KiB
Python
# -*- coding: utf-8 -
|
|
#
|
|
# This file is part of gunicorn released under the MIT license.
|
|
# See the NOTICE for more information.
|
|
|
|
"""Tests for HTTP/2 server connection."""
|
|
|
|
import pytest
|
|
from unittest import mock
|
|
from io import BytesIO
|
|
|
|
# Check if h2 is available for integration tests
|
|
try:
|
|
import h2.connection
|
|
import h2.config
|
|
import h2.events
|
|
import h2.exceptions
|
|
H2_AVAILABLE = True
|
|
except ImportError:
|
|
H2_AVAILABLE = False
|
|
|
|
from gunicorn.http2.errors import (
|
|
HTTP2Error, HTTP2ConnectionError
|
|
)
|
|
|
|
|
|
pytestmark = pytest.mark.skipif(not H2_AVAILABLE, reason="h2 library not available")
|
|
|
|
|
|
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 testing connection without real network I/O."""
|
|
|
|
def __init__(self, data=b''):
|
|
self._recv_buffer = BytesIO(data)
|
|
self._sent = bytearray()
|
|
self._closed = False
|
|
|
|
def recv(self, size):
|
|
return self._recv_buffer.read(size)
|
|
|
|
def sendall(self, data):
|
|
if self._closed:
|
|
raise OSError("Socket is closed")
|
|
self._sent.extend(data)
|
|
|
|
def close(self):
|
|
self._closed = True
|
|
|
|
def get_sent_data(self):
|
|
return bytes(self._sent)
|
|
|
|
def set_recv_data(self, data):
|
|
self._recv_buffer = BytesIO(data)
|
|
|
|
|
|
def create_client_connection():
|
|
"""Create an h2 client connection for generating test frames."""
|
|
config = h2.config.H2Configuration(client_side=True)
|
|
conn = h2.connection.H2Connection(config=config)
|
|
conn.initiate_connection()
|
|
return conn
|
|
|
|
|
|
class TestHTTP2ServerConnectionInit:
|
|
"""Test HTTP2ServerConnection initialization."""
|
|
|
|
def test_basic_initialization(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
|
|
assert conn.cfg is cfg
|
|
assert conn.sock is sock
|
|
assert conn.client_addr == ('127.0.0.1', 12345)
|
|
assert conn.streams == {}
|
|
assert conn.is_closed is False
|
|
assert conn._initialized is False
|
|
|
|
def test_settings_from_config(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
cfg.http2_max_concurrent_streams = 50
|
|
cfg.http2_initial_window_size = 32768
|
|
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
|
|
assert conn.max_concurrent_streams == 50
|
|
assert conn.initial_window_size == 32768
|
|
|
|
|
|
class TestHTTP2ServerConnectionInitiate:
|
|
"""Test connection initiation."""
|
|
|
|
def test_initiate_connection(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
|
|
conn.initiate_connection()
|
|
|
|
assert conn._initialized is True
|
|
# Should have sent settings frame
|
|
sent_data = sock.get_sent_data()
|
|
assert len(sent_data) > 0
|
|
|
|
def test_initiate_connection_idempotent(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
|
|
conn.initiate_connection()
|
|
first_sent = len(sock.get_sent_data())
|
|
|
|
conn.initiate_connection() # Second call
|
|
second_sent = len(sock.get_sent_data())
|
|
|
|
# Should not send additional data
|
|
assert first_sent == second_sent
|
|
|
|
|
|
class TestHTTP2ServerConnectionReceiveData:
|
|
"""Test receiving and processing data."""
|
|
|
|
def test_receive_empty_data_closes_connection(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket(b'')
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
requests = conn.receive_data()
|
|
|
|
assert conn.is_closed is True
|
|
assert requests == []
|
|
|
|
def test_receive_client_preface_and_headers(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Generate client data
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
|
|
# Simulate server receiving client settings
|
|
# Feed client preface to server
|
|
requests = conn.receive_data(client_preface)
|
|
|
|
# No requests yet, just settings exchange
|
|
assert requests == []
|
|
|
|
def test_receive_simple_get_request(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send request
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
|
|
# Process client preface on server
|
|
conn.receive_data(client_preface)
|
|
|
|
# Server may have sent settings, feed them to client
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
# Client sends GET request
|
|
client.send_headers(
|
|
stream_id=1,
|
|
headers=[
|
|
(':method', 'GET'),
|
|
(':path', '/test'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
],
|
|
end_stream=True
|
|
)
|
|
request_data = client.data_to_send()
|
|
|
|
# Server receives request
|
|
requests = conn.receive_data(request_data)
|
|
|
|
assert len(requests) == 1
|
|
req = requests[0]
|
|
assert req.method == 'GET'
|
|
assert req.path == '/test'
|
|
|
|
def test_receive_post_with_body(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
conn.receive_data(client_preface)
|
|
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
# Client sends POST with body
|
|
client.send_headers(
|
|
stream_id=1,
|
|
headers=[
|
|
(':method', 'POST'),
|
|
(':path', '/submit'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
('content-type', 'application/json'),
|
|
('content-length', '13'),
|
|
],
|
|
end_stream=False
|
|
)
|
|
client.send_data(stream_id=1, data=b'{"key":"val"}', end_stream=True)
|
|
request_data = client.data_to_send()
|
|
|
|
requests = conn.receive_data(request_data)
|
|
|
|
assert len(requests) == 1
|
|
req = requests[0]
|
|
assert req.method == 'POST'
|
|
assert req.body.read() == b'{"key":"val"}'
|
|
|
|
def test_socket_error_raises_connection_error(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = mock.Mock()
|
|
sock.recv.side_effect = OSError("Connection reset")
|
|
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
with pytest.raises(HTTP2ConnectionError):
|
|
conn.receive_data()
|
|
|
|
|
|
class TestHTTP2ServerConnectionSendResponse:
|
|
"""Test sending responses."""
|
|
|
|
def test_send_simple_response(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create a stream by receiving a request
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
conn.receive_data(client_preface)
|
|
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
client.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client.data_to_send())
|
|
|
|
# Send response
|
|
sock._sent.clear()
|
|
conn.send_response(
|
|
stream_id=1,
|
|
status=200,
|
|
headers=[('content-type', 'text/plain')],
|
|
body=b'Hello!'
|
|
)
|
|
|
|
sent = sock.get_sent_data()
|
|
assert len(sent) > 0
|
|
|
|
# Verify client receives valid response
|
|
events = client.receive_data(sent)
|
|
response_events = [e for e in events if isinstance(e, h2.events.ResponseReceived)]
|
|
data_events = [e for e in events if isinstance(e, h2.events.DataReceived)]
|
|
|
|
assert len(response_events) == 1
|
|
assert len(data_events) == 1
|
|
assert data_events[0].data == b'Hello!'
|
|
|
|
def test_send_response_with_empty_body(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
conn.receive_data(client.data_to_send())
|
|
client.receive_data(sock.get_sent_data())
|
|
|
|
client.send_headers(1, [
|
|
(':method', 'HEAD'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client.data_to_send())
|
|
|
|
sock._sent.clear()
|
|
conn.send_response(stream_id=1, status=200, headers=[], 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_send_response_invalid_stream(self):
|
|
"""Test that sending response on invalid stream returns False."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Sending to a non-existent stream should return False gracefully
|
|
result = conn.send_response(stream_id=999, status=200, headers=[], body=None)
|
|
assert result is False
|
|
|
|
|
|
class TestHTTP2ServerConnectionSendError:
|
|
"""Test sending error responses."""
|
|
|
|
def test_send_error_with_message(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
conn.receive_data(client.data_to_send())
|
|
client.receive_data(sock.get_sent_data())
|
|
|
|
client.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/notfound'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client.data_to_send())
|
|
|
|
sock._sent.clear()
|
|
conn.send_error(stream_id=1, status_code=404, message="Not Found")
|
|
|
|
events = client.receive_data(sock.get_sent_data())
|
|
response_events = [e for e in events if isinstance(e, h2.events.ResponseReceived)]
|
|
data_events = [e for e in events if isinstance(e, h2.events.DataReceived)]
|
|
|
|
assert len(response_events) == 1
|
|
# h2 library returns headers as list of tuples, convert to dict
|
|
# Note: headers may be bytes or strings depending on h2 version
|
|
headers_list = response_events[0].headers
|
|
status = None
|
|
for name, value in headers_list:
|
|
name_str = name.decode() if isinstance(name, bytes) else name
|
|
if name_str == ':status':
|
|
status = value.decode() if isinstance(value, bytes) else value
|
|
break
|
|
assert status == '404'
|
|
|
|
assert len(data_events) == 1
|
|
assert data_events[0].data == b"Not Found"
|
|
|
|
|
|
class TestHTTP2ServerConnectionResetStream:
|
|
"""Test stream reset."""
|
|
|
|
def test_reset_stream(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
conn.receive_data(client.data_to_send())
|
|
client.receive_data(sock.get_sent_data())
|
|
|
|
client.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=False)
|
|
conn.receive_data(client.data_to_send())
|
|
|
|
sock._sent.clear()
|
|
conn.reset_stream(stream_id=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 TestHTTP2ServerConnectionClose:
|
|
"""Test connection close."""
|
|
|
|
def test_close_connection(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
conn.receive_data(client.data_to_send())
|
|
|
|
sock._sent.clear()
|
|
conn.close()
|
|
|
|
assert conn.is_closed is True
|
|
|
|
# Should have sent GOAWAY
|
|
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_close_idempotent(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
conn.close()
|
|
sent_after_first = len(sock.get_sent_data())
|
|
|
|
conn.close() # Second call
|
|
sent_after_second = len(sock.get_sent_data())
|
|
|
|
# Should not send additional GOAWAY
|
|
assert sent_after_first == sent_after_second
|
|
|
|
|
|
class TestHTTP2ServerConnectionCleanup:
|
|
"""Test stream cleanup."""
|
|
|
|
def test_cleanup_stream(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
conn.receive_data(client.data_to_send())
|
|
client.receive_data(sock.get_sent_data())
|
|
|
|
client.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client.data_to_send())
|
|
|
|
assert 1 in conn.streams
|
|
|
|
conn.cleanup_stream(1)
|
|
|
|
assert 1 not in conn.streams
|
|
|
|
def test_cleanup_nonexistent_stream(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Should not raise
|
|
conn.cleanup_stream(999)
|
|
|
|
|
|
class TestHTTP2ServerConnectionMultipleStreams:
|
|
"""Test handling multiple concurrent streams."""
|
|
|
|
def test_multiple_streams(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
conn.receive_data(client.data_to_send())
|
|
client.receive_data(sock.get_sent_data())
|
|
|
|
# Send multiple requests
|
|
client.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/one'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
|
|
client.send_headers(3, [
|
|
(':method', 'GET'),
|
|
(':path', '/two'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
|
|
requests = conn.receive_data(client.data_to_send())
|
|
|
|
assert len(requests) == 2
|
|
paths = {req.path for req in requests}
|
|
assert paths == {'/one', '/two'}
|
|
|
|
|
|
class TestHTTP2ServerConnectionRepr:
|
|
"""Test string representation."""
|
|
|
|
def test_repr(self):
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
|
|
repr_str = repr(conn)
|
|
assert "HTTP2ServerConnection" in repr_str
|
|
assert "streams=" in repr_str
|
|
assert "closed=" in repr_str
|
|
|
|
|
|
class TestHTTP2ServerConnectionPriority:
|
|
"""Test HTTP/2 priority handling."""
|
|
|
|
def test_handle_priority_updated_existing_stream(self):
|
|
"""Test handling priority update for existing stream."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create a client connection to generate frames
|
|
client_conn = create_client_connection()
|
|
|
|
# Get client preface
|
|
client_data = client_conn.data_to_send()
|
|
|
|
# Feed client preface to server
|
|
conn.receive_data(client_data)
|
|
sock._sent = bytearray()
|
|
|
|
# Send a request to create a stream
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
])
|
|
request_data = client_conn.data_to_send()
|
|
conn.receive_data(request_data)
|
|
|
|
# Verify stream was created
|
|
assert 1 in conn.streams
|
|
stream = conn.streams[1]
|
|
|
|
# Default priority values
|
|
assert stream.priority_weight == 16
|
|
assert stream.priority_depends_on == 0
|
|
|
|
# Send a PRIORITY frame
|
|
client_conn.prioritize(1, weight=128, depends_on=0, exclusive=False)
|
|
priority_data = client_conn.data_to_send()
|
|
conn.receive_data(priority_data)
|
|
|
|
# Verify priority was updated
|
|
assert stream.priority_weight == 128
|
|
|
|
def test_handle_priority_updated_nonexistent_stream(self):
|
|
"""Test that priority update for nonexistent stream is ignored."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create a client connection
|
|
client_conn = create_client_connection()
|
|
client_data = client_conn.data_to_send()
|
|
conn.receive_data(client_data)
|
|
|
|
# Send a PRIORITY frame for a stream that doesn't exist
|
|
# This should not raise an error
|
|
client_conn.prioritize(99, weight=64, depends_on=0, exclusive=False)
|
|
priority_data = client_conn.data_to_send()
|
|
|
|
# Should not raise
|
|
conn.receive_data(priority_data)
|
|
|
|
|
|
class TestHTTP2ServerConnectionTrailers:
|
|
"""Test HTTP/2 response trailer support."""
|
|
|
|
def test_send_trailers_after_headers_and_body(self):
|
|
"""Test sending trailers after response headers and body."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create a client connection
|
|
client_conn = create_client_connection()
|
|
client_data = client_conn.data_to_send()
|
|
conn.receive_data(client_data)
|
|
sock._sent = bytearray()
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
request_data = client_conn.data_to_send()
|
|
conn.receive_data(request_data)
|
|
|
|
# Manually send headers without ending stream (for trailer support)
|
|
stream = conn.streams[1]
|
|
response_headers = [(':status', '200'), ('content-type', 'text/plain')]
|
|
conn.h2_conn.send_headers(1, response_headers, end_stream=False)
|
|
stream.send_headers(response_headers, end_stream=False)
|
|
conn._send_pending_data()
|
|
|
|
# Send body without ending stream
|
|
conn.h2_conn.send_data(1, b'Hello World', end_stream=False)
|
|
stream.send_data(b'Hello World', end_stream=False)
|
|
conn._send_pending_data()
|
|
|
|
# Send trailers
|
|
trailers = [('grpc-status', '0'), ('grpc-message', 'OK')]
|
|
conn.send_trailers(1, trailers)
|
|
|
|
# Verify stream is closed
|
|
assert stream.response_complete is True
|
|
assert stream.response_trailers == [('grpc-status', '0'), ('grpc-message', 'OK')]
|
|
|
|
def test_send_trailers_pseudo_header_raises(self):
|
|
"""Test that pseudo-headers in trailers raise error."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
from gunicorn.http2.errors import HTTP2Error
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client_conn = create_client_connection()
|
|
client_data = client_conn.data_to_send()
|
|
conn.receive_data(client_data)
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send response
|
|
conn.send_response(1, 200, [('content-type', 'text/plain')], None)
|
|
|
|
# Try to send trailers with pseudo-header
|
|
with pytest.raises(HTTP2Error) as exc_info:
|
|
conn.send_trailers(1, [(':status', '200')])
|
|
assert "Pseudo-header" in str(exc_info.value)
|
|
|
|
def test_send_trailers_without_headers_returns_false(self):
|
|
"""Test that sending trailers without headers returns False."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client_conn = create_client_connection()
|
|
client_data = client_conn.data_to_send()
|
|
conn.receive_data(client_data)
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Try to send trailers without sending headers first - should return False
|
|
result = conn.send_trailers(1, [('trailer', 'value')])
|
|
assert result is False
|
|
|
|
def test_send_trailers_nonexistent_stream_returns_false(self):
|
|
"""Test that sending trailers on nonexistent stream returns False."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Sending trailers to non-existent stream should return False
|
|
result = conn.send_trailers(99, [('trailer', 'value')])
|
|
assert result is False
|
|
|
|
|
|
class TestHTTP2FlowControl:
|
|
"""Test HTTP/2 flow control handling."""
|
|
|
|
def test_send_data_respects_zero_window(self):
|
|
"""Test that send_data returns False when flow control window is 0."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send preface
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send response headers without ending stream (pass body=b'' placeholder)
|
|
# We need to send headers first, so use h2_conn directly
|
|
conn.h2_conn.send_headers(1, [
|
|
(':status', '200'),
|
|
('content-type', 'text/plain'),
|
|
], end_stream=False)
|
|
conn._send_pending_data()
|
|
conn.streams[1].send_headers([(':status', '200')], end_stream=False)
|
|
|
|
# Mock the flow control window to return 0
|
|
original_window = conn.h2_conn.local_flow_control_window
|
|
conn.h2_conn.local_flow_control_window = lambda stream_id: 0
|
|
|
|
# Try to send data - should return False (not raise)
|
|
result = conn.send_data(1, b'Hello, World!')
|
|
assert result is False
|
|
|
|
# Restore
|
|
conn.h2_conn.local_flow_control_window = original_window
|
|
|
|
def test_send_data_respects_flow_control(self):
|
|
"""Test that send_data chunks data according to flow control window."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send preface
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send response headers without ending stream
|
|
conn.h2_conn.send_headers(1, [
|
|
(':status', '200'),
|
|
('content-type', 'text/plain'),
|
|
], end_stream=False)
|
|
conn._send_pending_data()
|
|
conn.streams[1].send_headers([(':status', '200')], end_stream=False)
|
|
|
|
# Send small data - should succeed within window
|
|
small_data = b'Hello'
|
|
conn.send_data(1, small_data, end_stream=True)
|
|
|
|
# Verify data was sent
|
|
sent_data = sock.get_sent_data()
|
|
assert len(sent_data) > 0
|
|
|
|
|
|
class TestHTTP2StreamClosedHandling:
|
|
"""Test graceful handling of StreamClosedError."""
|
|
|
|
def test_send_response_on_closed_stream(self):
|
|
"""Test that send_response gracefully handles closed stream."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send preface
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Simulate client resetting the stream
|
|
client_conn.reset_stream(1)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Try to send response - should return False, not raise
|
|
result = conn.send_response(1, 200, [('content-type', 'text/plain')], b'Hello')
|
|
assert result is False
|
|
|
|
def test_send_data_on_reset_stream(self):
|
|
"""Test that send_data gracefully handles reset stream."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send preface
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send a request
|
|
client_conn.send_headers(1, [
|
|
(':method', 'GET'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Send response headers without ending stream
|
|
conn.h2_conn.send_headers(1, [
|
|
(':status', '200'),
|
|
('content-type', 'text/plain'),
|
|
], end_stream=False)
|
|
conn._send_pending_data()
|
|
conn.streams[1].send_headers([(':status', '200')], end_stream=False)
|
|
|
|
# Simulate client resetting the stream
|
|
client_conn.reset_stream(1)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Try to send data - should return False, not raise
|
|
result = conn.send_data(1, b'Hello, World!', end_stream=True)
|
|
assert result is False
|
|
|
|
|
|
class TestHTTP2WindowOverflowHandling:
|
|
"""Test window overflow handling."""
|
|
|
|
def test_window_overflow_sends_goaway(self):
|
|
"""Test that window overflow results in GOAWAY with FLOW_CONTROL_ERROR."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
from gunicorn.http2.errors import HTTP2ErrorCode
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send preface
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Mock increment_flow_control_window to raise ValueError (overflow)
|
|
original_increment = conn.h2_conn.increment_flow_control_window
|
|
|
|
def raise_overflow(increment, stream_id=None):
|
|
raise ValueError("Flow control window too large")
|
|
|
|
conn.h2_conn.increment_flow_control_window = raise_overflow
|
|
|
|
# Send a request with data to trigger the overflow
|
|
client_conn.send_headers(1, [
|
|
(':method', 'POST'),
|
|
(':path', '/'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
], end_stream=False)
|
|
client_conn.send_data(1, b'test data', end_stream=True)
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Connection should be closed with FLOW_CONTROL_ERROR
|
|
assert conn.is_closed is True
|
|
|
|
|
|
class TestHTTP2ProtocolErrorHandling:
|
|
"""Test protocol error handling sends proper GOAWAY."""
|
|
|
|
def test_protocol_error_sends_goaway(self):
|
|
"""Test that protocol errors result in GOAWAY being sent."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
from gunicorn.http2.errors import HTTP2ProtocolError, HTTP2ErrorCode
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
# Create client and send preface
|
|
client_conn = create_client_connection()
|
|
conn.receive_data(client_conn.data_to_send())
|
|
|
|
# Clear sent data to only capture new frames
|
|
sock._sent.clear()
|
|
|
|
# Mock h2_conn.receive_data to raise ProtocolError
|
|
def raise_protocol_error(data):
|
|
raise h2.exceptions.ProtocolError("Test protocol error")
|
|
|
|
conn.h2_conn.receive_data = raise_protocol_error
|
|
|
|
# This should send GOAWAY and raise ProtocolError
|
|
with pytest.raises(HTTP2ProtocolError) as exc_info:
|
|
conn.receive_data(b'dummy data')
|
|
|
|
assert "Test protocol error" in str(exc_info.value)
|
|
|
|
# Verify something was sent (GOAWAY frame)
|
|
sent_data = sock.get_sent_data()
|
|
assert len(sent_data) > 0
|
|
# Connection should be marked as closed
|
|
assert conn.is_closed is True
|
|
|
|
|
|
class TestHTTP2StreamEndedBodyComplete:
|
|
"""Test that _handle_stream_ended sets _body_complete on the stream."""
|
|
|
|
def test_stream_ended_sets_body_complete(self):
|
|
"""_handle_stream_ended must set stream._body_complete = True."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
conn.receive_data(client_preface)
|
|
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
# Client sends POST with body (separate HEADERS and DATA frames)
|
|
client.send_headers(
|
|
stream_id=1,
|
|
headers=[
|
|
(':method', 'POST'),
|
|
(':path', '/test'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
('content-type', 'application/json'),
|
|
],
|
|
end_stream=False,
|
|
)
|
|
client.send_data(stream_id=1, data=b'{"input": "test"}', end_stream=True)
|
|
request_data = client.data_to_send()
|
|
|
|
requests = conn.receive_data(request_data)
|
|
|
|
assert len(requests) == 1
|
|
stream = conn.streams.get(1)
|
|
assert stream is not None
|
|
assert stream._body_complete is True
|
|
assert stream.request_complete is True
|
|
|
|
def test_stream_ended_signals_body_event(self):
|
|
"""_handle_stream_ended must signal _body_event if it exists."""
|
|
import asyncio
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
conn.receive_data(client_preface)
|
|
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
# Client sends headers without end_stream to create the stream
|
|
client.send_headers(
|
|
stream_id=1,
|
|
headers=[
|
|
(':method', 'POST'),
|
|
(':path', '/test'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
],
|
|
end_stream=False,
|
|
)
|
|
headers_data = client.data_to_send()
|
|
conn.receive_data(headers_data)
|
|
|
|
# Manually set _body_event on the stream (simulates read_body_chunk
|
|
# having been called, which lazy-inits the event)
|
|
stream = conn.streams.get(1)
|
|
assert stream is not None
|
|
stream._body_event = asyncio.Event()
|
|
|
|
# Now send data + end_stream
|
|
client.send_data(stream_id=1, data=b'body', end_stream=True)
|
|
request_data = client.data_to_send()
|
|
conn.receive_data(request_data)
|
|
|
|
assert stream._body_event.is_set()
|
|
|
|
def test_stream_ended_without_body_event_does_not_raise(self):
|
|
"""_handle_stream_ended must not raise when _body_event is None."""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
conn.receive_data(client_preface)
|
|
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
# Send GET with end_stream (no body, _body_event never initialised)
|
|
client.send_headers(
|
|
stream_id=1,
|
|
headers=[
|
|
(':method', 'GET'),
|
|
(':path', '/test'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
],
|
|
end_stream=True,
|
|
)
|
|
request_data = client.data_to_send()
|
|
|
|
# Should not raise even though _body_event is None
|
|
requests = conn.receive_data(request_data)
|
|
assert len(requests) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_h2_post_body_not_duplicated(self):
|
|
"""Full flow: streaming read must not re-read body from BytesIO.
|
|
|
|
Simulates what the receive() closure in protocol.py does:
|
|
1. read_body_chunk() returns the body
|
|
2. read_body_chunk() returns None (body complete)
|
|
3. Total bytes received == original body length (not doubled)
|
|
"""
|
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
|
|
|
cfg = MockConfig()
|
|
sock = MockSocket()
|
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
|
conn.initiate_connection()
|
|
|
|
client = create_client_connection()
|
|
client_preface = client.data_to_send()
|
|
conn.receive_data(client_preface)
|
|
|
|
server_data = sock.get_sent_data()
|
|
if server_data:
|
|
client.receive_data(server_data)
|
|
|
|
body = b'{"input": ["hello world"]}'
|
|
client.send_headers(
|
|
stream_id=1,
|
|
headers=[
|
|
(':method', 'POST'),
|
|
(':path', '/embeddings'),
|
|
(':scheme', 'https'),
|
|
(':authority', 'localhost'),
|
|
('content-type', 'application/json'),
|
|
('content-length', str(len(body))),
|
|
],
|
|
end_stream=False,
|
|
)
|
|
client.send_data(stream_id=1, data=body, end_stream=True)
|
|
request_data = client.data_to_send()
|
|
|
|
requests = conn.receive_data(request_data)
|
|
assert len(requests) == 1
|
|
|
|
stream = conn.streams.get(1)
|
|
|
|
# Simulate what receive() does: read chunks via read_body_chunk()
|
|
received = bytearray()
|
|
while True:
|
|
chunk = await stream.read_body_chunk()
|
|
if chunk is None:
|
|
break
|
|
received.extend(chunk)
|
|
|
|
# The critical assertion: body must not be duplicated
|
|
assert bytes(received) == body
|
|
assert len(received) == len(body)
|
|
|
|
# _body_complete must be True so receive() knows to stop
|
|
assert stream._body_complete is True
|
|
|
|
# BytesIO must still have the data (for get_request_body compatibility)
|
|
# but read_body_chunk returning None prevents the fast path in receive()
|
|
# from ever being reached because body_received gets set to True
|
|
|
|
|
|
class TestHTTP2NotAvailable:
|
|
"""Test behavior when h2 is not available."""
|
|
|
|
def test_import_error_raises_not_available(self):
|
|
from gunicorn.http2 import errors
|
|
|
|
# Test that HTTP2NotAvailable can be raised
|
|
with pytest.raises(errors.HTTP2NotAvailable):
|
|
raise errors.HTTP2NotAvailable()
|