mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-02 10:41:30 +08:00
fix(http2): achieve 100% h2spec compliance (146/146 tests)
- Send GOAWAY with correct error codes for protocol violations - Handle StreamClosedError and FlowControlError gracefully - Return False instead of raising for missing/closed streams - Handle flow control window overflow per RFC 7540 - Fix reader race condition and add h2 exception handling - Wait for WINDOW_UPDATE when flow control window is zero/negative - Use h2 exception's error_code for INITIAL_WINDOW_SIZE violations
This commit is contained in:
parent
fa5e319f15
commit
4e3245a0df
@ -66,7 +66,9 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
if ssl_object and hasattr(ssl_object, 'selected_alpn_protocol'):
|
if ssl_object and hasattr(ssl_object, 'selected_alpn_protocol'):
|
||||||
alpn = ssl_object.selected_alpn_protocol()
|
alpn = ssl_object.selected_alpn_protocol()
|
||||||
if alpn == 'h2':
|
if alpn == 'h2':
|
||||||
# HTTP/2 connection - switch to HTTP/2 handler
|
# HTTP/2 connection - create reader immediately to avoid race condition
|
||||||
|
# data_received may be called before _handle_http2_connection starts
|
||||||
|
self.reader = asyncio.StreamReader()
|
||||||
self._task = self.worker.loop.create_task(
|
self._task = self.worker.loop.create_task(
|
||||||
self._handle_http2_connection(transport, ssl_object)
|
self._handle_http2_connection(transport, ssl_object)
|
||||||
)
|
)
|
||||||
@ -548,8 +550,9 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
peername = transport.get_extra_info('peername')
|
peername = transport.get_extra_info('peername')
|
||||||
sockname = transport.get_extra_info('sockname')
|
sockname = transport.get_extra_info('sockname')
|
||||||
|
|
||||||
# Create async reader/writer from transport
|
# Use the reader created in connection_made
|
||||||
reader = asyncio.StreamReader()
|
# (data_received feeds data to self.reader)
|
||||||
|
reader = self.reader
|
||||||
protocol = asyncio.StreamReaderProtocol(reader)
|
protocol = asyncio.StreamReaderProtocol(reader)
|
||||||
writer = asyncio.StreamWriter(
|
writer = asyncio.StreamWriter(
|
||||||
transport, protocol, reader, self.worker.loop
|
transport, protocol, reader, self.worker.loop
|
||||||
@ -561,8 +564,6 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
)
|
)
|
||||||
await h2_conn.initiate_connection()
|
await h2_conn.initiate_connection()
|
||||||
|
|
||||||
# Store for data_received
|
|
||||||
self.reader = reader
|
|
||||||
self._h2_conn = h2_conn
|
self._h2_conn = h2_conn
|
||||||
|
|
||||||
# Main loop - receive and handle requests
|
# Main loop - receive and handle requests
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import asyncio
|
|||||||
|
|
||||||
from .errors import (
|
from .errors import (
|
||||||
HTTP2Error, HTTP2ProtocolError, HTTP2ConnectionError,
|
HTTP2Error, HTTP2ProtocolError, HTTP2ConnectionError,
|
||||||
HTTP2NotAvailable,
|
HTTP2NotAvailable, HTTP2ErrorCode,
|
||||||
)
|
)
|
||||||
from .stream import HTTP2Stream
|
from .stream import HTTP2Stream
|
||||||
from .request import HTTP2Request
|
from .request import HTTP2Request
|
||||||
@ -152,6 +152,30 @@ class AsyncHTTP2Connection:
|
|||||||
try:
|
try:
|
||||||
events = self.h2_conn.receive_data(data)
|
events = self.h2_conn.receive_data(data)
|
||||||
except _h2_exceptions.ProtocolError as e:
|
except _h2_exceptions.ProtocolError as e:
|
||||||
|
# Send GOAWAY with PROTOCOL_ERROR before raising
|
||||||
|
await self.close(error_code=HTTP2ErrorCode.PROTOCOL_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.FlowControlError as e:
|
||||||
|
# Send GOAWAY with FLOW_CONTROL_ERROR
|
||||||
|
await self.close(error_code=HTTP2ErrorCode.FLOW_CONTROL_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.FrameTooLargeError as e:
|
||||||
|
# Send GOAWAY with FRAME_SIZE_ERROR
|
||||||
|
await self.close(error_code=HTTP2ErrorCode.FRAME_SIZE_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.InvalidSettingsValueError as e:
|
||||||
|
# Use error_code from h2 exception (RFC 7540 Section 6.5.2):
|
||||||
|
# INITIAL_WINDOW_SIZE > 2^31-1 gives FLOW_CONTROL_ERROR
|
||||||
|
# Other invalid settings give PROTOCOL_ERROR
|
||||||
|
error_code = getattr(e, 'error_code', None)
|
||||||
|
if error_code is not None:
|
||||||
|
await self.close(error_code=error_code)
|
||||||
|
else:
|
||||||
|
await self.close(error_code=HTTP2ErrorCode.PROTOCOL_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.TooManyStreamsError as e:
|
||||||
|
# Send GOAWAY with REFUSED_STREAM
|
||||||
|
await self.close(error_code=HTTP2ErrorCode.REFUSED_STREAM)
|
||||||
raise HTTP2ProtocolError(str(e))
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
|
||||||
# Process events
|
# Process events
|
||||||
@ -229,10 +253,19 @@ class AsyncHTTP2Connection:
|
|||||||
|
|
||||||
# Increment flow control windows (only if data received)
|
# Increment flow control windows (only if data received)
|
||||||
if len(data) > 0:
|
if len(data) > 0:
|
||||||
# Update stream-level window
|
try:
|
||||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=stream_id)
|
# Update stream-level window
|
||||||
# Update connection-level window
|
self.h2_conn.increment_flow_control_window(len(data), stream_id=stream_id)
|
||||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=None)
|
# Update connection-level window
|
||||||
|
self.h2_conn.increment_flow_control_window(len(data), stream_id=None)
|
||||||
|
except (ValueError, _h2_exceptions.FlowControlError):
|
||||||
|
# Window overflow - prepare GOAWAY with FLOW_CONTROL_ERROR
|
||||||
|
# (will be sent by receive_data's _send_pending_data call)
|
||||||
|
self._closed = True
|
||||||
|
try:
|
||||||
|
self.h2_conn.close_connection(error_code=HTTP2ErrorCode.FLOW_CONTROL_ERROR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -324,10 +357,14 @@ class AsyncHTTP2Connection:
|
|||||||
status: HTTP status code (int)
|
status: HTTP status code (int)
|
||||||
headers: List of (name, value) header tuples
|
headers: List of (name, value) header tuples
|
||||||
body: Optional response body bytes
|
body: Optional response body bytes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if response sent, False if stream was already closed
|
||||||
"""
|
"""
|
||||||
stream = self.streams.get(stream_id)
|
stream = self.streams.get(stream_id)
|
||||||
if stream is None:
|
if stream is None:
|
||||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||||
|
return False
|
||||||
|
|
||||||
# Build response headers with :status pseudo-header
|
# Build response headers with :status pseudo-header
|
||||||
response_headers = [(':status', str(status))]
|
response_headers = [(':status', str(status))]
|
||||||
@ -336,14 +373,21 @@ class AsyncHTTP2Connection:
|
|||||||
|
|
||||||
end_stream = body is None or len(body) == 0
|
end_stream = body is None or len(body) == 0
|
||||||
|
|
||||||
# Send headers
|
try:
|
||||||
self.h2_conn.send_headers(stream_id, response_headers, end_stream=end_stream)
|
# Send headers
|
||||||
stream.send_headers(response_headers, end_stream=end_stream)
|
self.h2_conn.send_headers(stream_id, response_headers, end_stream=end_stream)
|
||||||
await self._send_pending_data()
|
stream.send_headers(response_headers, end_stream=end_stream)
|
||||||
|
await self._send_pending_data()
|
||||||
|
|
||||||
# Send body if present
|
# Send body if present
|
||||||
if body and len(body) > 0:
|
if body and len(body) > 0:
|
||||||
await self.send_data(stream_id, body, end_stream=True)
|
await self.send_data(stream_id, body, end_stream=True)
|
||||||
|
return True
|
||||||
|
except _h2_exceptions.StreamClosedError:
|
||||||
|
# Stream was reset by client - clean up gracefully
|
||||||
|
stream.close()
|
||||||
|
self.cleanup_stream(stream_id)
|
||||||
|
return False
|
||||||
|
|
||||||
async def send_data(self, stream_id, data, end_stream=False):
|
async def send_data(self, stream_id, data, end_stream=False):
|
||||||
"""Send data on a stream.
|
"""Send data on a stream.
|
||||||
@ -352,38 +396,87 @@ class AsyncHTTP2Connection:
|
|||||||
stream_id: The stream ID
|
stream_id: The stream ID
|
||||||
data: Body data bytes
|
data: Body data bytes
|
||||||
end_stream: Whether this ends the stream
|
end_stream: Whether this ends the stream
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if data sent, False if stream was already closed
|
||||||
"""
|
"""
|
||||||
stream = self.streams.get(stream_id)
|
stream = self.streams.get(stream_id)
|
||||||
if stream is None:
|
if stream is None:
|
||||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||||
|
return False
|
||||||
|
|
||||||
# Send data in chunks respecting flow control and max frame size
|
# Send data in chunks respecting flow control and max frame size
|
||||||
data_to_send = data
|
data_to_send = data
|
||||||
while data_to_send:
|
try:
|
||||||
# Get available window size for this stream
|
while data_to_send:
|
||||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
# Get available window size for this stream
|
||||||
# Also respect max frame size
|
|
||||||
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
|
||||||
|
|
||||||
if chunk_size <= 0:
|
|
||||||
# No window available, send what we have and wait
|
|
||||||
await self._send_pending_data()
|
|
||||||
# Try again - the window might open after ACKs
|
|
||||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||||
if available <= 0:
|
# Also respect max frame size
|
||||||
# Still no window, just try to send a small chunk
|
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
||||||
chunk_size = min(self.max_frame_size, len(data_to_send))
|
|
||||||
|
|
||||||
chunk = data_to_send[:chunk_size]
|
if chunk_size <= 0:
|
||||||
data_to_send = data_to_send[chunk_size:]
|
# No window available - must wait for WINDOW_UPDATE
|
||||||
|
# Per RFC 7540 Section 6.9.2, we must track negative windows
|
||||||
|
# and wait for WINDOW_UPDATE to make the window positive
|
||||||
|
await self._send_pending_data()
|
||||||
|
|
||||||
# Only set end_stream on the final chunk
|
# Wait for incoming WINDOW_UPDATE by reading from connection
|
||||||
is_final = end_stream and len(data_to_send) == 0
|
# Use a reasonable timeout to avoid blocking forever
|
||||||
|
max_wait_attempts = 50 # ~5 seconds at 100ms per attempt
|
||||||
|
for _ in range(max_wait_attempts):
|
||||||
|
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||||
|
if available > 0:
|
||||||
|
break
|
||||||
|
|
||||||
self.h2_conn.send_data(stream_id, chunk, end_stream=is_final)
|
# Read more data from connection (may receive WINDOW_UPDATE)
|
||||||
await self._send_pending_data()
|
try:
|
||||||
|
incoming = await asyncio.wait_for(
|
||||||
|
self.reader.read(self.READ_BUFFER_SIZE),
|
||||||
|
timeout=0.1
|
||||||
|
)
|
||||||
|
if incoming:
|
||||||
|
events = self.h2_conn.receive_data(incoming)
|
||||||
|
# Process events but don't create new requests
|
||||||
|
for event in events:
|
||||||
|
if isinstance(event, _h2_events.StreamReset):
|
||||||
|
if event.stream_id == stream_id:
|
||||||
|
return False
|
||||||
|
elif isinstance(event, _h2_events.ConnectionTerminated):
|
||||||
|
self._closed = True
|
||||||
|
return False
|
||||||
|
await self._send_pending_data()
|
||||||
|
else:
|
||||||
|
# Connection closed
|
||||||
|
self._closed = True
|
||||||
|
return False
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
except _h2_exceptions.ProtocolError:
|
||||||
|
return False
|
||||||
|
|
||||||
stream.send_data(data, end_stream=end_stream)
|
# Re-check window after waiting
|
||||||
|
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||||
|
if available <= 0:
|
||||||
|
# Still no window after waiting - give up
|
||||||
|
return False
|
||||||
|
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
||||||
|
|
||||||
|
chunk = data_to_send[:chunk_size]
|
||||||
|
data_to_send = data_to_send[chunk_size:]
|
||||||
|
|
||||||
|
# Only set end_stream on the final chunk
|
||||||
|
is_final = end_stream and len(data_to_send) == 0
|
||||||
|
|
||||||
|
self.h2_conn.send_data(stream_id, chunk, end_stream=is_final)
|
||||||
|
await self._send_pending_data()
|
||||||
|
|
||||||
|
stream.send_data(data, end_stream=end_stream)
|
||||||
|
return True
|
||||||
|
except (_h2_exceptions.StreamClosedError, _h2_exceptions.FlowControlError):
|
||||||
|
# Stream was reset by client or flow control error - clean up gracefully
|
||||||
|
stream.close()
|
||||||
|
self.cleanup_stream(stream_id)
|
||||||
|
return False
|
||||||
|
|
||||||
async def send_trailers(self, stream_id, trailers):
|
async def send_trailers(self, stream_id, trailers):
|
||||||
"""Send trailing headers on a stream.
|
"""Send trailing headers on a stream.
|
||||||
@ -397,12 +490,17 @@ class AsyncHTTP2Connection:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTP2Error: If stream not found, headers not sent, or pseudo-headers used
|
HTTP2Error: If stream not found, headers not sent, or pseudo-headers used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if trailers sent, False if stream was already closed
|
||||||
"""
|
"""
|
||||||
stream = self.streams.get(stream_id)
|
stream = self.streams.get(stream_id)
|
||||||
if stream is None:
|
if stream is None:
|
||||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||||
|
return False
|
||||||
if not stream.response_headers_sent:
|
if not stream.response_headers_sent:
|
||||||
raise HTTP2Error("Must send headers before trailers")
|
# Can't send trailers without headers - return False
|
||||||
|
return False
|
||||||
|
|
||||||
# Validate and normalize trailer headers
|
# Validate and normalize trailer headers
|
||||||
trailer_headers = []
|
trailer_headers = []
|
||||||
@ -412,10 +510,17 @@ class AsyncHTTP2Connection:
|
|||||||
raise HTTP2Error(f"Pseudo-header '{name}' not allowed in trailers")
|
raise HTTP2Error(f"Pseudo-header '{name}' not allowed in trailers")
|
||||||
trailer_headers.append((lname, str(value)))
|
trailer_headers.append((lname, str(value)))
|
||||||
|
|
||||||
# Send trailers with end_stream=True
|
try:
|
||||||
self.h2_conn.send_headers(stream_id, trailer_headers, end_stream=True)
|
# Send trailers with end_stream=True
|
||||||
stream.send_trailers(trailer_headers)
|
self.h2_conn.send_headers(stream_id, trailer_headers, end_stream=True)
|
||||||
await self._send_pending_data()
|
stream.send_trailers(trailer_headers)
|
||||||
|
await self._send_pending_data()
|
||||||
|
return True
|
||||||
|
except _h2_exceptions.StreamClosedError:
|
||||||
|
# Stream was reset by client - clean up gracefully
|
||||||
|
stream.close()
|
||||||
|
self.cleanup_stream(stream_id)
|
||||||
|
return False
|
||||||
|
|
||||||
async def send_error(self, stream_id, status_code, message=None):
|
async def send_error(self, stream_id, status_code, message=None):
|
||||||
"""Send an error response on a stream."""
|
"""Send an error response on a stream."""
|
||||||
|
|||||||
@ -13,7 +13,7 @@ from io import BytesIO
|
|||||||
|
|
||||||
from .errors import (
|
from .errors import (
|
||||||
HTTP2Error, HTTP2ProtocolError, HTTP2ConnectionError,
|
HTTP2Error, HTTP2ProtocolError, HTTP2ConnectionError,
|
||||||
HTTP2NotAvailable,
|
HTTP2NotAvailable, HTTP2ErrorCode,
|
||||||
)
|
)
|
||||||
from .stream import HTTP2Stream
|
from .stream import HTTP2Stream
|
||||||
from .request import HTTP2Request
|
from .request import HTTP2Request
|
||||||
@ -146,6 +146,30 @@ class HTTP2ServerConnection:
|
|||||||
try:
|
try:
|
||||||
events = self.h2_conn.receive_data(data)
|
events = self.h2_conn.receive_data(data)
|
||||||
except _h2_exceptions.ProtocolError as e:
|
except _h2_exceptions.ProtocolError as e:
|
||||||
|
# Send GOAWAY with PROTOCOL_ERROR before raising
|
||||||
|
self.close(error_code=HTTP2ErrorCode.PROTOCOL_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.FlowControlError as e:
|
||||||
|
# Send GOAWAY with FLOW_CONTROL_ERROR
|
||||||
|
self.close(error_code=HTTP2ErrorCode.FLOW_CONTROL_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.FrameTooLargeError as e:
|
||||||
|
# Send GOAWAY with FRAME_SIZE_ERROR
|
||||||
|
self.close(error_code=HTTP2ErrorCode.FRAME_SIZE_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.InvalidSettingsValueError as e:
|
||||||
|
# Use error_code from h2 exception (RFC 7540 Section 6.5.2):
|
||||||
|
# INITIAL_WINDOW_SIZE > 2^31-1 gives FLOW_CONTROL_ERROR
|
||||||
|
# Other invalid settings give PROTOCOL_ERROR
|
||||||
|
error_code = getattr(e, 'error_code', None)
|
||||||
|
if error_code is not None:
|
||||||
|
self.close(error_code=error_code)
|
||||||
|
else:
|
||||||
|
self.close(error_code=HTTP2ErrorCode.PROTOCOL_ERROR)
|
||||||
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
except _h2_exceptions.TooManyStreamsError as e:
|
||||||
|
# Send GOAWAY with REFUSED_STREAM
|
||||||
|
self.close(error_code=HTTP2ErrorCode.REFUSED_STREAM)
|
||||||
raise HTTP2ProtocolError(str(e))
|
raise HTTP2ProtocolError(str(e))
|
||||||
|
|
||||||
# Process events
|
# Process events
|
||||||
@ -236,12 +260,16 @@ class HTTP2ServerConnection:
|
|||||||
|
|
||||||
# Increment flow control windows (only if data received)
|
# Increment flow control windows (only if data received)
|
||||||
if len(data) > 0:
|
if len(data) > 0:
|
||||||
# Update stream-level window
|
try:
|
||||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=stream_id)
|
# Update stream-level window
|
||||||
# Update connection-level window
|
self.h2_conn.increment_flow_control_window(len(data), stream_id=stream_id)
|
||||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=None)
|
# Update connection-level window
|
||||||
# Send WINDOW_UPDATE frames immediately
|
self.h2_conn.increment_flow_control_window(len(data), stream_id=None)
|
||||||
self._send_pending_data()
|
# Send WINDOW_UPDATE frames immediately
|
||||||
|
self._send_pending_data()
|
||||||
|
except (ValueError, _h2_exceptions.FlowControlError):
|
||||||
|
# Window overflow - send FLOW_CONTROL_ERROR and close
|
||||||
|
self.close(error_code=HTTP2ErrorCode.FLOW_CONTROL_ERROR)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -365,10 +393,14 @@ class HTTP2ServerConnection:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTP2Error: If stream not found or in invalid state
|
HTTP2Error: If stream not found or in invalid state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if response sent, False if stream was already closed
|
||||||
"""
|
"""
|
||||||
stream = self.streams.get(stream_id)
|
stream = self.streams.get(stream_id)
|
||||||
if stream is None:
|
if stream is None:
|
||||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||||
|
return False
|
||||||
|
|
||||||
# Build response headers with :status pseudo-header
|
# Build response headers with :status pseudo-header
|
||||||
response_headers = [(':status', str(status))]
|
response_headers = [(':status', str(status))]
|
||||||
@ -378,14 +410,21 @@ class HTTP2ServerConnection:
|
|||||||
|
|
||||||
end_stream = body is None or len(body) == 0
|
end_stream = body is None or len(body) == 0
|
||||||
|
|
||||||
# Send headers
|
try:
|
||||||
self.h2_conn.send_headers(stream_id, response_headers, end_stream=end_stream)
|
# Send headers
|
||||||
stream.send_headers(response_headers, end_stream=end_stream)
|
self.h2_conn.send_headers(stream_id, response_headers, end_stream=end_stream)
|
||||||
self._send_pending_data()
|
stream.send_headers(response_headers, end_stream=end_stream)
|
||||||
|
self._send_pending_data()
|
||||||
|
|
||||||
# Send body if present
|
# Send body if present
|
||||||
if body and len(body) > 0:
|
if body and len(body) > 0:
|
||||||
self.send_data(stream_id, body, end_stream=True)
|
self.send_data(stream_id, body, end_stream=True)
|
||||||
|
return True
|
||||||
|
except _h2_exceptions.StreamClosedError:
|
||||||
|
# Stream was reset by client - clean up gracefully
|
||||||
|
stream.close()
|
||||||
|
self.cleanup_stream(stream_id)
|
||||||
|
return False
|
||||||
|
|
||||||
def send_data(self, stream_id, data, end_stream=False):
|
def send_data(self, stream_id, data, end_stream=False):
|
||||||
"""Send data on a stream.
|
"""Send data on a stream.
|
||||||
@ -395,40 +434,98 @@ class HTTP2ServerConnection:
|
|||||||
data: Body data bytes
|
data: Body data bytes
|
||||||
end_stream: Whether this ends the stream
|
end_stream: Whether this ends the stream
|
||||||
|
|
||||||
Raises:
|
Returns:
|
||||||
HTTP2Error: If stream not found or in invalid state
|
bool: True if data sent, False if stream was already closed
|
||||||
"""
|
"""
|
||||||
|
import selectors
|
||||||
|
|
||||||
stream = self.streams.get(stream_id)
|
stream = self.streams.get(stream_id)
|
||||||
if stream is None:
|
if stream is None:
|
||||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||||
|
return False
|
||||||
|
|
||||||
# Send data in chunks respecting flow control and max frame size
|
# Send data in chunks respecting flow control and max frame size
|
||||||
data_to_send = data
|
data_to_send = data
|
||||||
while data_to_send:
|
try:
|
||||||
# Get available window size for this stream
|
while data_to_send:
|
||||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
# Get available window size for this stream
|
||||||
# Also respect max frame size
|
|
||||||
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
|
||||||
|
|
||||||
if chunk_size <= 0:
|
|
||||||
# No window available, send what we have and wait
|
|
||||||
self._send_pending_data()
|
|
||||||
# Try again - the window might open after ACKs
|
|
||||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||||
if available <= 0:
|
# Also respect max frame size
|
||||||
# Still no window, just try to send a small chunk
|
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
||||||
chunk_size = min(self.max_frame_size, len(data_to_send))
|
|
||||||
|
|
||||||
chunk = data_to_send[:chunk_size]
|
if chunk_size <= 0:
|
||||||
data_to_send = data_to_send[chunk_size:]
|
# No window available - must wait for WINDOW_UPDATE
|
||||||
|
# Per RFC 7540 Section 6.9.2, we must track negative windows
|
||||||
|
# and wait for WINDOW_UPDATE to make the window positive
|
||||||
|
self._send_pending_data()
|
||||||
|
|
||||||
# Only set end_stream on the final chunk
|
# Wait for incoming WINDOW_UPDATE by reading from connection
|
||||||
is_final = end_stream and len(data_to_send) == 0
|
# Use selectors for portable non-blocking wait
|
||||||
|
max_wait_attempts = 50 # ~5 seconds at 100ms per attempt
|
||||||
|
try:
|
||||||
|
sel = selectors.DefaultSelector()
|
||||||
|
sel.register(self.sock, selectors.EVENT_READ)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
# Socket doesn't support selectors (e.g., mock socket)
|
||||||
|
# Fall back to returning False immediately
|
||||||
|
return False
|
||||||
|
|
||||||
self.h2_conn.send_data(stream_id, chunk, end_stream=is_final)
|
try:
|
||||||
self._send_pending_data()
|
for _ in range(max_wait_attempts):
|
||||||
|
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||||
|
if available > 0:
|
||||||
|
break
|
||||||
|
|
||||||
stream.send_data(data, end_stream=end_stream)
|
# Check if socket has data ready
|
||||||
|
ready = sel.select(timeout=0.1)
|
||||||
|
if ready:
|
||||||
|
try:
|
||||||
|
incoming = self.sock.recv(self.READ_BUFFER_SIZE)
|
||||||
|
if incoming:
|
||||||
|
events = self.h2_conn.receive_data(incoming)
|
||||||
|
# Process events but don't create new requests
|
||||||
|
for event in events:
|
||||||
|
if isinstance(event, _h2_events.StreamReset):
|
||||||
|
if event.stream_id == stream_id:
|
||||||
|
return False
|
||||||
|
elif isinstance(event, _h2_events.ConnectionTerminated):
|
||||||
|
self._closed = True
|
||||||
|
return False
|
||||||
|
self._send_pending_data()
|
||||||
|
else:
|
||||||
|
# Connection closed
|
||||||
|
self._closed = True
|
||||||
|
return False
|
||||||
|
except (OSError, IOError):
|
||||||
|
return False
|
||||||
|
except _h2_exceptions.ProtocolError:
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
sel.close()
|
||||||
|
|
||||||
|
# Re-check window after waiting
|
||||||
|
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||||
|
if available <= 0:
|
||||||
|
# Still no window after waiting - give up
|
||||||
|
return False
|
||||||
|
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
||||||
|
|
||||||
|
chunk = data_to_send[:chunk_size]
|
||||||
|
data_to_send = data_to_send[chunk_size:]
|
||||||
|
|
||||||
|
# Only set end_stream on the final chunk
|
||||||
|
is_final = end_stream and len(data_to_send) == 0
|
||||||
|
|
||||||
|
self.h2_conn.send_data(stream_id, chunk, end_stream=is_final)
|
||||||
|
self._send_pending_data()
|
||||||
|
|
||||||
|
stream.send_data(data, end_stream=end_stream)
|
||||||
|
return True
|
||||||
|
except (_h2_exceptions.StreamClosedError, _h2_exceptions.FlowControlError):
|
||||||
|
# Stream was reset by client or flow control error - clean up gracefully
|
||||||
|
stream.close()
|
||||||
|
self.cleanup_stream(stream_id)
|
||||||
|
return False
|
||||||
|
|
||||||
def send_trailers(self, stream_id, trailers):
|
def send_trailers(self, stream_id, trailers):
|
||||||
"""Send trailing headers on a stream.
|
"""Send trailing headers on a stream.
|
||||||
@ -442,14 +539,17 @@ class HTTP2ServerConnection:
|
|||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTP2Error: If stream not found, headers not sent, or pseudo-headers used
|
HTTP2Error: If stream not found, headers not sent, or pseudo-headers used
|
||||||
"""
|
|
||||||
from .errors import HTTP2Error
|
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if trailers sent, False if stream was already closed
|
||||||
|
"""
|
||||||
stream = self.streams.get(stream_id)
|
stream = self.streams.get(stream_id)
|
||||||
if stream is None:
|
if stream is None:
|
||||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||||
|
return False
|
||||||
if not stream.response_headers_sent:
|
if not stream.response_headers_sent:
|
||||||
raise HTTP2Error("Must send headers before trailers")
|
# Can't send trailers without headers - return False
|
||||||
|
return False
|
||||||
|
|
||||||
# Validate and normalize trailer headers
|
# Validate and normalize trailer headers
|
||||||
trailer_headers = []
|
trailer_headers = []
|
||||||
@ -459,10 +559,17 @@ class HTTP2ServerConnection:
|
|||||||
raise HTTP2Error(f"Pseudo-header '{name}' not allowed in trailers")
|
raise HTTP2Error(f"Pseudo-header '{name}' not allowed in trailers")
|
||||||
trailer_headers.append((lname, str(value)))
|
trailer_headers.append((lname, str(value)))
|
||||||
|
|
||||||
# Send trailers with end_stream=True
|
try:
|
||||||
self.h2_conn.send_headers(stream_id, trailer_headers, end_stream=True)
|
# Send trailers with end_stream=True
|
||||||
stream.send_trailers(trailer_headers)
|
self.h2_conn.send_headers(stream_id, trailer_headers, end_stream=True)
|
||||||
self._send_pending_data()
|
stream.send_trailers(trailer_headers)
|
||||||
|
self._send_pending_data()
|
||||||
|
return True
|
||||||
|
except _h2_exceptions.StreamClosedError:
|
||||||
|
# Stream was reset by client - clean up gracefully
|
||||||
|
stream.close()
|
||||||
|
self.cleanup_stream(stream_id)
|
||||||
|
return False
|
||||||
|
|
||||||
def send_error(self, stream_id, status_code, message=None):
|
def send_error(self, stream_id, status_code, message=None):
|
||||||
"""Send an error response on a stream.
|
"""Send an error response on a stream.
|
||||||
|
|||||||
@ -10,6 +10,25 @@ These exceptions map to HTTP/2 error codes defined in RFC 7540.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HTTP2ErrorCode:
|
||||||
|
"""HTTP/2 Error Codes (RFC 7540 Section 7)."""
|
||||||
|
|
||||||
|
NO_ERROR = 0x0
|
||||||
|
PROTOCOL_ERROR = 0x1
|
||||||
|
INTERNAL_ERROR = 0x2
|
||||||
|
FLOW_CONTROL_ERROR = 0x3
|
||||||
|
SETTINGS_TIMEOUT = 0x4
|
||||||
|
STREAM_CLOSED = 0x5
|
||||||
|
FRAME_SIZE_ERROR = 0x6
|
||||||
|
REFUSED_STREAM = 0x7
|
||||||
|
CANCEL = 0x8
|
||||||
|
COMPRESSION_ERROR = 0x9
|
||||||
|
CONNECT_ERROR = 0xa
|
||||||
|
ENHANCE_YOUR_CALM = 0xb
|
||||||
|
INADEQUATE_SECURITY = 0xc
|
||||||
|
HTTP_1_1_REQUIRED = 0xd
|
||||||
|
|
||||||
|
|
||||||
class HTTP2Error(Exception):
|
class HTTP2Error(Exception):
|
||||||
"""Base exception for HTTP/2 errors."""
|
"""Base exception for HTTP/2 errors."""
|
||||||
|
|
||||||
@ -128,6 +147,7 @@ class HTTP2NotAvailable(HTTP2Error):
|
|||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
'HTTP2ErrorCode',
|
||||||
'HTTP2Error',
|
'HTTP2Error',
|
||||||
'HTTP2ProtocolError',
|
'HTTP2ProtocolError',
|
||||||
'HTTP2InternalError',
|
'HTTP2InternalError',
|
||||||
|
|||||||
@ -305,6 +305,7 @@ class TestAsyncHTTP2ConnectionSendResponse:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_response_invalid_stream(self):
|
async def test_send_response_invalid_stream(self):
|
||||||
|
"""Test that sending response on invalid stream returns False."""
|
||||||
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
|
||||||
cfg = MockConfig()
|
cfg = MockConfig()
|
||||||
@ -313,8 +314,9 @@ class TestAsyncHTTP2ConnectionSendResponse:
|
|||||||
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
await conn.initiate_connection()
|
await conn.initiate_connection()
|
||||||
|
|
||||||
with pytest.raises(HTTP2Error):
|
# Sending to a non-existent stream should return False gracefully
|
||||||
await conn.send_response(stream_id=999, status=200, headers=[], body=None)
|
result = await conn.send_response(stream_id=999, status=200, headers=[], body=None)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestAsyncHTTP2ConnectionSendData:
|
class TestAsyncHTTP2ConnectionSendData:
|
||||||
@ -726,8 +728,8 @@ class TestAsyncHTTP2ConnectionTrailers:
|
|||||||
assert "Pseudo-header" in str(exc_info.value)
|
assert "Pseudo-header" in str(exc_info.value)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_trailers_without_headers_raises(self):
|
async def test_send_trailers_without_headers_returns_false(self):
|
||||||
"""Test that sending trailers without headers raises error."""
|
"""Test that sending trailers without headers returns False."""
|
||||||
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
|
||||||
cfg = MockConfig()
|
cfg = MockConfig()
|
||||||
@ -750,7 +752,265 @@ class TestAsyncHTTP2ConnectionTrailers:
|
|||||||
reader.set_data(client_conn.data_to_send())
|
reader.set_data(client_conn.data_to_send())
|
||||||
await conn.receive_data()
|
await conn.receive_data()
|
||||||
|
|
||||||
# Try to send trailers without sending headers first
|
# Try to send trailers without sending headers first - should return False
|
||||||
with pytest.raises(HTTP2Error) as exc_info:
|
result = await conn.send_trailers(1, [('trailer', 'value')])
|
||||||
await conn.send_trailers(1, [('trailer', 'value')])
|
assert result is False
|
||||||
assert "Must send headers before trailers" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
class TestAsyncHTTP2FlowControl:
|
||||||
|
"""Test async HTTP/2 flow control handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_data_respects_zero_window(self):
|
||||||
|
"""Test that send_data returns False when flow control window is 0."""
|
||||||
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
|
||||||
|
cfg = MockConfig()
|
||||||
|
reader = MockAsyncReader()
|
||||||
|
writer = MockAsyncWriter()
|
||||||
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
|
|
||||||
|
# Create client and send preface
|
||||||
|
client_conn = create_client_connection()
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.initiate_connection()
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send a request
|
||||||
|
client_conn.send_headers(1, [
|
||||||
|
(':method', 'GET'),
|
||||||
|
(':path', '/'),
|
||||||
|
(':scheme', 'https'),
|
||||||
|
(':authority', 'localhost'),
|
||||||
|
], end_stream=True)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send response headers without ending stream
|
||||||
|
conn.h2_conn.send_headers(1, [
|
||||||
|
(':status', '200'),
|
||||||
|
('content-type', 'text/plain'),
|
||||||
|
], end_stream=False)
|
||||||
|
await 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 = await conn.send_data(1, b'Hello, World!')
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
conn.h2_conn.local_flow_control_window = original_window
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_data_respects_flow_control(self):
|
||||||
|
"""Test that send_data chunks data according to flow control window."""
|
||||||
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
|
||||||
|
cfg = MockConfig()
|
||||||
|
reader = MockAsyncReader()
|
||||||
|
writer = MockAsyncWriter()
|
||||||
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
|
|
||||||
|
# Create client and send preface
|
||||||
|
client_conn = create_client_connection()
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.initiate_connection()
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send a request
|
||||||
|
client_conn.send_headers(1, [
|
||||||
|
(':method', 'GET'),
|
||||||
|
(':path', '/'),
|
||||||
|
(':scheme', 'https'),
|
||||||
|
(':authority', 'localhost'),
|
||||||
|
], end_stream=True)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send response headers without ending stream
|
||||||
|
conn.h2_conn.send_headers(1, [
|
||||||
|
(':status', '200'),
|
||||||
|
('content-type', 'text/plain'),
|
||||||
|
], end_stream=False)
|
||||||
|
await 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'
|
||||||
|
await conn.send_data(1, small_data, end_stream=True)
|
||||||
|
|
||||||
|
# Verify data was sent
|
||||||
|
sent_data = writer.get_written_data()
|
||||||
|
assert len(sent_data) > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncHTTP2StreamClosedHandling:
|
||||||
|
"""Test graceful handling of StreamClosedError in async connection."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_response_on_closed_stream(self):
|
||||||
|
"""Test that send_response gracefully handles closed stream."""
|
||||||
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
|
||||||
|
cfg = MockConfig()
|
||||||
|
reader = MockAsyncReader()
|
||||||
|
writer = MockAsyncWriter()
|
||||||
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
|
|
||||||
|
# Create client and send preface
|
||||||
|
client_conn = create_client_connection()
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.initiate_connection()
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send a request
|
||||||
|
client_conn.send_headers(1, [
|
||||||
|
(':method', 'GET'),
|
||||||
|
(':path', '/'),
|
||||||
|
(':scheme', 'https'),
|
||||||
|
(':authority', 'localhost'),
|
||||||
|
], end_stream=True)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Simulate client resetting the stream
|
||||||
|
client_conn.reset_stream(1)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Try to send response - should return False, not raise
|
||||||
|
result = await conn.send_response(1, 200, [('content-type', 'text/plain')], b'Hello')
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_data_on_reset_stream(self):
|
||||||
|
"""Test that send_data gracefully handles reset stream."""
|
||||||
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
|
||||||
|
cfg = MockConfig()
|
||||||
|
reader = MockAsyncReader()
|
||||||
|
writer = MockAsyncWriter()
|
||||||
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
|
|
||||||
|
# Create client and send preface
|
||||||
|
client_conn = create_client_connection()
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.initiate_connection()
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send a request
|
||||||
|
client_conn.send_headers(1, [
|
||||||
|
(':method', 'GET'),
|
||||||
|
(':path', '/'),
|
||||||
|
(':scheme', 'https'),
|
||||||
|
(':authority', 'localhost'),
|
||||||
|
], end_stream=True)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Send response headers without ending stream
|
||||||
|
conn.h2_conn.send_headers(1, [
|
||||||
|
(':status', '200'),
|
||||||
|
('content-type', 'text/plain'),
|
||||||
|
], end_stream=False)
|
||||||
|
await conn._send_pending_data()
|
||||||
|
conn.streams[1].send_headers([(':status', '200')], end_stream=False)
|
||||||
|
|
||||||
|
# Simulate client resetting the stream
|
||||||
|
client_conn.reset_stream(1)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Try to send data - should return False, not raise
|
||||||
|
result = await conn.send_data(1, b'Hello, World!', end_stream=True)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncHTTP2WindowOverflowHandling:
|
||||||
|
"""Test window overflow handling in async connection."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_window_overflow_sends_goaway(self):
|
||||||
|
"""Test that window overflow results in connection close."""
|
||||||
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
from gunicorn.http2.errors import HTTP2ErrorCode
|
||||||
|
|
||||||
|
cfg = MockConfig()
|
||||||
|
reader = MockAsyncReader()
|
||||||
|
writer = MockAsyncWriter()
|
||||||
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
|
|
||||||
|
# Create client and send preface
|
||||||
|
client_conn = create_client_connection()
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.initiate_connection()
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Mock increment_flow_control_window to raise ValueError (overflow)
|
||||||
|
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)
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Connection should be closed with FLOW_CONTROL_ERROR
|
||||||
|
assert conn.is_closed is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncHTTP2ProtocolErrorHandling:
|
||||||
|
"""Test protocol error handling sends proper GOAWAY."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_protocol_error_sends_goaway(self):
|
||||||
|
"""Test that protocol errors result in GOAWAY being sent."""
|
||||||
|
from gunicorn.http2.async_connection import AsyncHTTP2Connection
|
||||||
|
from gunicorn.http2.errors import HTTP2ProtocolError, HTTP2ErrorCode
|
||||||
|
|
||||||
|
cfg = MockConfig()
|
||||||
|
reader = MockAsyncReader()
|
||||||
|
writer = MockAsyncWriter()
|
||||||
|
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
|
||||||
|
|
||||||
|
# Create client and send preface
|
||||||
|
client_conn = create_client_connection()
|
||||||
|
reader.set_data(client_conn.data_to_send())
|
||||||
|
await conn.initiate_connection()
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
# Clear sent data to only capture new frames
|
||||||
|
writer.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
|
||||||
|
|
||||||
|
# Set some dummy data for the reader
|
||||||
|
reader.set_data(b'dummy data')
|
||||||
|
|
||||||
|
# This should send GOAWAY and raise ProtocolError
|
||||||
|
with pytest.raises(HTTP2ProtocolError) as exc_info:
|
||||||
|
await conn.receive_data()
|
||||||
|
|
||||||
|
assert "Test protocol error" in str(exc_info.value)
|
||||||
|
|
||||||
|
# Verify something was sent (GOAWAY frame)
|
||||||
|
sent_data = writer.get_written_data()
|
||||||
|
assert len(sent_data) > 0
|
||||||
|
# Connection should be marked as closed
|
||||||
|
assert conn.is_closed is True
|
||||||
|
|||||||
@ -343,6 +343,7 @@ class TestHTTP2ServerConnectionSendResponse:
|
|||||||
assert len(stream_ended) == 1
|
assert len(stream_ended) == 1
|
||||||
|
|
||||||
def test_send_response_invalid_stream(self):
|
def test_send_response_invalid_stream(self):
|
||||||
|
"""Test that sending response on invalid stream returns False."""
|
||||||
from gunicorn.http2.connection import HTTP2ServerConnection
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
||||||
|
|
||||||
cfg = MockConfig()
|
cfg = MockConfig()
|
||||||
@ -350,8 +351,9 @@ class TestHTTP2ServerConnectionSendResponse:
|
|||||||
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
|
||||||
conn.initiate_connection()
|
conn.initiate_connection()
|
||||||
|
|
||||||
with pytest.raises(HTTP2Error):
|
# Sending to a non-existent stream should return False gracefully
|
||||||
conn.send_response(stream_id=999, status=200, headers=[], body=None)
|
result = conn.send_response(stream_id=999, status=200, headers=[], body=None)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
class TestHTTP2ServerConnectionSendError:
|
class TestHTTP2ServerConnectionSendError:
|
||||||
@ -718,10 +720,9 @@ class TestHTTP2ServerConnectionTrailers:
|
|||||||
conn.send_trailers(1, [(':status', '200')])
|
conn.send_trailers(1, [(':status', '200')])
|
||||||
assert "Pseudo-header" in str(exc_info.value)
|
assert "Pseudo-header" in str(exc_info.value)
|
||||||
|
|
||||||
def test_send_trailers_without_headers_raises(self):
|
def test_send_trailers_without_headers_returns_false(self):
|
||||||
"""Test that sending trailers without headers raises error."""
|
"""Test that sending trailers without headers returns False."""
|
||||||
from gunicorn.http2.connection import HTTP2ServerConnection
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
||||||
from gunicorn.http2.errors import HTTP2Error
|
|
||||||
|
|
||||||
cfg = MockConfig()
|
cfg = MockConfig()
|
||||||
sock = MockSocket()
|
sock = MockSocket()
|
||||||
@ -741,15 +742,13 @@ class TestHTTP2ServerConnectionTrailers:
|
|||||||
], end_stream=True)
|
], end_stream=True)
|
||||||
conn.receive_data(client_conn.data_to_send())
|
conn.receive_data(client_conn.data_to_send())
|
||||||
|
|
||||||
# Try to send trailers without sending headers first
|
# Try to send trailers without sending headers first - should return False
|
||||||
with pytest.raises(HTTP2Error) as exc_info:
|
result = conn.send_trailers(1, [('trailer', 'value')])
|
||||||
conn.send_trailers(1, [('trailer', 'value')])
|
assert result is False
|
||||||
assert "Must send headers before trailers" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_send_trailers_nonexistent_stream_raises(self):
|
def test_send_trailers_nonexistent_stream_returns_false(self):
|
||||||
"""Test that sending trailers on nonexistent stream raises error."""
|
"""Test that sending trailers on nonexistent stream returns False."""
|
||||||
from gunicorn.http2.connection import HTTP2ServerConnection
|
from gunicorn.http2.connection import HTTP2ServerConnection
|
||||||
from gunicorn.http2.errors import HTTP2Error
|
|
||||||
|
|
||||||
cfg = MockConfig()
|
cfg = MockConfig()
|
||||||
sock = MockSocket()
|
sock = MockSocket()
|
||||||
@ -759,9 +758,243 @@ class TestHTTP2ServerConnectionTrailers:
|
|||||||
client_conn = create_client_connection()
|
client_conn = create_client_connection()
|
||||||
conn.receive_data(client_conn.data_to_send())
|
conn.receive_data(client_conn.data_to_send())
|
||||||
|
|
||||||
with pytest.raises(HTTP2Error) as exc_info:
|
# Sending trailers to non-existent stream should return False
|
||||||
conn.send_trailers(99, [('trailer', 'value')])
|
result = conn.send_trailers(99, [('trailer', 'value')])
|
||||||
assert "Stream 99 not found" in str(exc_info.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 TestHTTP2NotAvailable:
|
class TestHTTP2NotAvailable:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user