actualizado 3-sept
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# -*- coding: utf-8 -
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
HTTP/2 support for Gunicorn.
|
||||
|
||||
This module provides HTTP/2 protocol support using the hyper-h2 library.
|
||||
HTTP/2 requires TLS with ALPN negotiation.
|
||||
"""
|
||||
|
||||
H2_MIN_VERSION = (4, 1, 0)
|
||||
|
||||
_h2_available = None
|
||||
_h2_version = None
|
||||
|
||||
|
||||
def is_http2_available():
|
||||
"""Check if HTTP/2 support is available.
|
||||
|
||||
Returns:
|
||||
bool: True if the h2 library is installed with minimum required version.
|
||||
"""
|
||||
global _h2_available, _h2_version # pylint: disable=global-statement
|
||||
|
||||
if _h2_available is not None:
|
||||
return _h2_available
|
||||
|
||||
try:
|
||||
import h2
|
||||
version_str = getattr(h2, '__version__', '0.0.0')
|
||||
version_parts = tuple(int(x) for x in version_str.split('.')[:3])
|
||||
_h2_version = version_parts
|
||||
_h2_available = version_parts >= H2_MIN_VERSION
|
||||
except ImportError:
|
||||
_h2_available = False
|
||||
_h2_version = None
|
||||
|
||||
return _h2_available
|
||||
|
||||
|
||||
def get_h2_version():
|
||||
"""Get the installed h2 library version.
|
||||
|
||||
Returns:
|
||||
tuple: Version tuple (major, minor, patch) or None if not installed.
|
||||
"""
|
||||
if _h2_version is None:
|
||||
is_http2_available() # Populate _h2_version
|
||||
return _h2_version
|
||||
|
||||
|
||||
def get_http2_connection_class():
|
||||
"""Get the HTTP2ServerConnection class if h2 is available.
|
||||
|
||||
Returns:
|
||||
HTTP2ServerConnection class, or raises HTTP2NotAvailable
|
||||
"""
|
||||
if not is_http2_available():
|
||||
from .errors import HTTP2NotAvailable
|
||||
raise HTTP2NotAvailable()
|
||||
from .connection import HTTP2ServerConnection
|
||||
return HTTP2ServerConnection
|
||||
|
||||
|
||||
def get_async_http2_connection_class():
|
||||
"""Get the AsyncHTTP2Connection class if h2 is available.
|
||||
|
||||
Returns:
|
||||
AsyncHTTP2Connection class, or raises HTTP2NotAvailable
|
||||
"""
|
||||
if not is_http2_available():
|
||||
from .errors import HTTP2NotAvailable
|
||||
raise HTTP2NotAvailable()
|
||||
from .async_connection import AsyncHTTP2Connection
|
||||
return AsyncHTTP2Connection
|
||||
|
||||
|
||||
__all__ = [
|
||||
'is_http2_available',
|
||||
'get_h2_version',
|
||||
'get_http2_connection_class',
|
||||
'get_async_http2_connection_class',
|
||||
'H2_MIN_VERSION',
|
||||
]
|
||||
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,656 @@
|
||||
# -*- coding: utf-8 -
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
Async HTTP/2 server connection implementation for ASGI workers.
|
||||
|
||||
Uses the hyper-h2 library for HTTP/2 protocol handling with
|
||||
asyncio for non-blocking I/O.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
|
||||
from .errors import (
|
||||
HTTP2Error, HTTP2ProtocolError, HTTP2ConnectionError,
|
||||
HTTP2NotAvailable, HTTP2ErrorCode,
|
||||
)
|
||||
from .stream import HTTP2Stream
|
||||
from .request import HTTP2Request
|
||||
|
||||
|
||||
# Import h2 lazily to allow graceful fallback
|
||||
_h2 = None
|
||||
_h2_config = None
|
||||
_h2_events = None
|
||||
_h2_exceptions = None
|
||||
_h2_settings = None
|
||||
|
||||
|
||||
def _import_h2():
|
||||
"""Lazily import h2 library components."""
|
||||
global _h2, _h2_config, _h2_events, _h2_exceptions, _h2_settings # pylint: disable=global-statement
|
||||
|
||||
if _h2 is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import h2.connection as _h2
|
||||
import h2.config as _h2_config
|
||||
import h2.events as _h2_events
|
||||
import h2.exceptions as _h2_exceptions
|
||||
import h2.settings as _h2_settings
|
||||
except ImportError:
|
||||
raise HTTP2NotAvailable()
|
||||
|
||||
|
||||
class AsyncHTTP2Connection:
|
||||
"""Async HTTP/2 server-side connection handler for ASGI.
|
||||
|
||||
Manages the HTTP/2 connection state and multiplexed streams
|
||||
using asyncio for non-blocking I/O operations.
|
||||
"""
|
||||
|
||||
# Default buffer size for socket reads
|
||||
READ_BUFFER_SIZE = 65536
|
||||
|
||||
def __init__(self, cfg, reader, writer, client_addr):
|
||||
"""Initialize an async HTTP/2 server connection.
|
||||
|
||||
Args:
|
||||
cfg: Gunicorn configuration object
|
||||
reader: asyncio StreamReader
|
||||
writer: asyncio StreamWriter
|
||||
client_addr: Client address tuple (host, port)
|
||||
|
||||
Raises:
|
||||
HTTP2NotAvailable: If h2 library is not installed
|
||||
"""
|
||||
_import_h2()
|
||||
|
||||
self.cfg = cfg
|
||||
self.reader = reader
|
||||
self.writer = writer
|
||||
self.client_addr = client_addr
|
||||
|
||||
# Active streams indexed by stream ID
|
||||
self.streams = {}
|
||||
# Events pulled off the wire while blocked on a flow-control window.
|
||||
# They have left the h2 state machine already, so they are held here
|
||||
# for the main receive loop rather than discarded.
|
||||
self._deferred_events = collections.deque()
|
||||
|
||||
# Queue of completed requests for the worker
|
||||
self._request_queue = asyncio.Queue()
|
||||
|
||||
# Connection settings from config
|
||||
self.initial_window_size = cfg.http2_initial_window_size
|
||||
self.max_concurrent_streams = cfg.http2_max_concurrent_streams
|
||||
self.max_frame_size = cfg.http2_max_frame_size
|
||||
self.max_header_list_size = cfg.http2_max_header_list_size
|
||||
|
||||
# Initialize h2 connection
|
||||
config = _h2_config.H2Configuration(
|
||||
client_side=False,
|
||||
header_encoding='utf-8',
|
||||
)
|
||||
self.h2_conn = _h2.H2Connection(config=config)
|
||||
|
||||
# Connection state
|
||||
self._closed = False
|
||||
self._initialized = False
|
||||
self._receive_task = None
|
||||
|
||||
async def initiate_connection(self):
|
||||
"""Send initial HTTP/2 settings to client.
|
||||
|
||||
Should be called after the SSL handshake completes and
|
||||
before processing any data.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# Update local settings before initiating
|
||||
self.h2_conn.update_settings({
|
||||
_h2_settings.SettingCodes.MAX_CONCURRENT_STREAMS: self.max_concurrent_streams,
|
||||
_h2_settings.SettingCodes.INITIAL_WINDOW_SIZE: self.initial_window_size,
|
||||
_h2_settings.SettingCodes.MAX_FRAME_SIZE: self.max_frame_size,
|
||||
_h2_settings.SettingCodes.MAX_HEADER_LIST_SIZE: self.max_header_list_size,
|
||||
})
|
||||
|
||||
self.h2_conn.initiate_connection()
|
||||
await self._send_pending_data()
|
||||
self._initialized = True
|
||||
|
||||
async def initiate_upgrade(self, settings_header, http1_req, body=b""):
|
||||
"""Switch a connection to HTTP/2 after an Upgrade: h2c request.
|
||||
|
||||
The async twin of HTTP2Connection.initiate_upgrade. The upgraded
|
||||
request becomes stream 1 (RFC 7540 section 3.2): h2 opens it in the
|
||||
state machine, and the matching gunicorn stream is built here from the
|
||||
HTTP/1 request that carried the upgrade, so the worker sees an
|
||||
ordinary HTTP/2 request.
|
||||
|
||||
The body is passed in rather than read off the request: the callback
|
||||
parser hands body chunks to the protocol, not to the request object.
|
||||
|
||||
Returns the HTTP2Request for stream 1.
|
||||
"""
|
||||
self.h2_conn.update_settings({
|
||||
_h2_settings.SettingCodes.MAX_CONCURRENT_STREAMS: self.max_concurrent_streams,
|
||||
_h2_settings.SettingCodes.INITIAL_WINDOW_SIZE: self.initial_window_size,
|
||||
_h2_settings.SettingCodes.MAX_FRAME_SIZE: self.max_frame_size,
|
||||
_h2_settings.SettingCodes.MAX_HEADER_LIST_SIZE: self.max_header_list_size,
|
||||
})
|
||||
self.h2_conn.initiate_upgrade_connection(settings_header=settings_header)
|
||||
await self._send_pending_data()
|
||||
self._initialized = True
|
||||
|
||||
stream = HTTP2Stream(stream_id=1, connection=self)
|
||||
authority = ""
|
||||
for name, value in http1_req.headers:
|
||||
if name == "HOST":
|
||||
authority = value
|
||||
break
|
||||
pseudo = [
|
||||
(':method', http1_req.method),
|
||||
(':path', http1_req.uri),
|
||||
(':scheme', http1_req.scheme),
|
||||
]
|
||||
if authority:
|
||||
pseudo.append((':authority', authority))
|
||||
regular = [(name.lower(), value) for name, value in http1_req.headers
|
||||
if name not in ("CONNECTION", "UPGRADE", "HTTP2-SETTINGS")]
|
||||
|
||||
stream.receive_headers(pseudo + regular, end_stream=not body)
|
||||
if body:
|
||||
stream.receive_data(body, end_stream=True)
|
||||
self.streams[1] = stream
|
||||
return HTTP2Request(stream, self.cfg, self.client_addr)
|
||||
|
||||
async def receive_data(self, timeout=None):
|
||||
"""Receive data and return completed requests.
|
||||
|
||||
Args:
|
||||
timeout: Optional timeout in seconds for read operation
|
||||
|
||||
Returns:
|
||||
list: List of HTTP2Request objects for completed requests
|
||||
|
||||
Raises:
|
||||
HTTP2ConnectionError: On protocol or connection errors
|
||||
asyncio.TimeoutError: If timeout expires
|
||||
"""
|
||||
try:
|
||||
if timeout is not None:
|
||||
data = await asyncio.wait_for(
|
||||
self.reader.read(self.READ_BUFFER_SIZE),
|
||||
timeout=timeout
|
||||
)
|
||||
else:
|
||||
data = await self.reader.read(self.READ_BUFFER_SIZE)
|
||||
except (OSError, IOError) as e:
|
||||
raise HTTP2ConnectionError(f"Socket read error: {e}")
|
||||
|
||||
if not data:
|
||||
# Connection closed by peer
|
||||
self._closed = True
|
||||
return []
|
||||
|
||||
# Feed data to h2
|
||||
# Note: Specific exceptions must come before ProtocolError (their parent class)
|
||||
try:
|
||||
events = self.h2_conn.receive_data(data)
|
||||
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))
|
||||
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))
|
||||
|
||||
# Process events, oldest first: anything set aside during a
|
||||
# flow-control wait arrived before this batch.
|
||||
completed_requests = []
|
||||
if self._deferred_events:
|
||||
events = list(self._deferred_events) + list(events)
|
||||
self._deferred_events.clear()
|
||||
for event in events:
|
||||
request = self._handle_event(event)
|
||||
if request is not None:
|
||||
completed_requests.append(request)
|
||||
|
||||
# Send any pending data (WINDOW_UPDATE, etc.)
|
||||
await self._send_pending_data()
|
||||
|
||||
return completed_requests
|
||||
|
||||
def _handle_event(self, event):
|
||||
"""Handle a single h2 event.
|
||||
|
||||
Args:
|
||||
event: h2 event object
|
||||
|
||||
Returns:
|
||||
HTTP2Request if a request is complete, None otherwise
|
||||
"""
|
||||
if isinstance(event, _h2_events.RequestReceived):
|
||||
return self._handle_request_received(event)
|
||||
|
||||
elif isinstance(event, _h2_events.DataReceived):
|
||||
return self._handle_data_received(event)
|
||||
|
||||
elif isinstance(event, _h2_events.StreamEnded):
|
||||
return self._handle_stream_ended(event)
|
||||
|
||||
elif isinstance(event, _h2_events.StreamReset):
|
||||
self._handle_stream_reset(event)
|
||||
|
||||
elif isinstance(event, _h2_events.WindowUpdated):
|
||||
pass # Flow control update, handled by h2
|
||||
|
||||
elif isinstance(event, _h2_events.PriorityUpdated):
|
||||
self._handle_priority_updated(event)
|
||||
|
||||
elif isinstance(event, _h2_events.SettingsAcknowledged):
|
||||
pass # Settings ACK received
|
||||
|
||||
elif isinstance(event, _h2_events.ConnectionTerminated):
|
||||
self._handle_connection_terminated(event)
|
||||
|
||||
elif isinstance(event, _h2_events.TrailersReceived):
|
||||
return self._handle_trailers_received(event)
|
||||
|
||||
return None
|
||||
|
||||
def _handle_request_received(self, event):
|
||||
"""Handle RequestReceived event (HEADERS frame)."""
|
||||
stream_id = event.stream_id
|
||||
headers = event.headers
|
||||
|
||||
# Create new stream
|
||||
stream = HTTP2Stream(stream_id, self)
|
||||
self.streams[stream_id] = stream
|
||||
|
||||
# Process headers
|
||||
stream.receive_headers(headers, end_stream=False)
|
||||
|
||||
def _handle_data_received(self, event):
|
||||
"""Handle DataReceived event."""
|
||||
stream_id = event.stream_id
|
||||
data = event.data
|
||||
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is None:
|
||||
return None
|
||||
|
||||
stream.receive_data(data, end_stream=False)
|
||||
|
||||
# Increment flow control windows (only if data received)
|
||||
if len(data) > 0:
|
||||
try:
|
||||
# Update stream-level window
|
||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=stream_id)
|
||||
# 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
|
||||
|
||||
def _handle_stream_ended(self, event):
|
||||
"""Handle StreamEnded event."""
|
||||
stream_id = event.stream_id
|
||||
stream = self.streams.get(stream_id)
|
||||
|
||||
if stream is None:
|
||||
return None
|
||||
|
||||
# Mark stream as request complete and body complete so the
|
||||
# receive() closure's _body_complete guard fires, preventing
|
||||
# the fast path from re-reading already-consumed data from BytesIO.
|
||||
stream.request_complete = True
|
||||
stream._body_complete = True
|
||||
if stream._body_event:
|
||||
stream._body_event.set()
|
||||
|
||||
return HTTP2Request(stream, self.cfg, self.client_addr)
|
||||
|
||||
def _handle_stream_reset(self, event):
|
||||
"""Handle StreamReset event."""
|
||||
stream_id = event.stream_id
|
||||
stream = self.streams.get(stream_id)
|
||||
|
||||
if stream is not None:
|
||||
stream.reset(event.error_code)
|
||||
|
||||
def _handle_connection_terminated(self, event):
|
||||
"""Handle ConnectionTerminated event."""
|
||||
self._closed = True
|
||||
|
||||
def _handle_trailers_received(self, event):
|
||||
"""Handle TrailersReceived event."""
|
||||
stream_id = event.stream_id
|
||||
stream = self.streams.get(stream_id)
|
||||
|
||||
if stream is None:
|
||||
return None
|
||||
|
||||
stream.receive_trailers(event.headers)
|
||||
return HTTP2Request(stream, self.cfg, self.client_addr)
|
||||
|
||||
def _handle_priority_updated(self, event):
|
||||
"""Handle PriorityUpdated event (PRIORITY frame).
|
||||
|
||||
Args:
|
||||
event: PriorityUpdated event with priority info
|
||||
"""
|
||||
stream = self.streams.get(event.stream_id)
|
||||
if stream is not None:
|
||||
stream.update_priority(
|
||||
weight=event.weight,
|
||||
depends_on=event.depends_on,
|
||||
exclusive=event.exclusive
|
||||
)
|
||||
|
||||
async def send_informational(self, stream_id, status, headers):
|
||||
"""Send an informational response (1xx) on a stream.
|
||||
|
||||
This is used for 103 Early Hints and other 1xx responses.
|
||||
Informational responses are sent before the final response
|
||||
and do not end the stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
status: HTTP status code (100-199)
|
||||
headers: List of (name, value) header tuples
|
||||
|
||||
Raises:
|
||||
HTTP2Error: If status is not in 1xx range
|
||||
"""
|
||||
if status < 100 or status >= 200:
|
||||
raise HTTP2Error(f"Invalid informational status: {status}")
|
||||
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is None:
|
||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
||||
|
||||
# Build headers with :status pseudo-header
|
||||
response_headers = [(':status', str(status))]
|
||||
for name, value in headers:
|
||||
# HTTP/2 headers must be lowercase
|
||||
response_headers.append((name.lower(), str(value)))
|
||||
|
||||
# Send headers with end_stream=False (informational, more to follow)
|
||||
self.h2_conn.send_headers(stream_id, response_headers, end_stream=False)
|
||||
await self._send_pending_data()
|
||||
|
||||
async def send_response(self, stream_id, status, headers, body=None):
|
||||
"""Send a response on a stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID to respond on
|
||||
status: HTTP status code (int)
|
||||
headers: List of (name, value) header tuples
|
||||
body: Optional response body bytes
|
||||
|
||||
Returns:
|
||||
bool: True if response sent, False if stream was already closed
|
||||
"""
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is None:
|
||||
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||
return False
|
||||
|
||||
# Build response headers with :status pseudo-header
|
||||
response_headers = [(':status', str(status))]
|
||||
for name, value in headers:
|
||||
response_headers.append((name.lower(), str(value)))
|
||||
|
||||
end_stream = body is None or len(body) == 0
|
||||
|
||||
try:
|
||||
# Send headers
|
||||
self.h2_conn.send_headers(stream_id, response_headers, end_stream=end_stream)
|
||||
stream.send_headers(response_headers, end_stream=end_stream)
|
||||
await self._send_pending_data()
|
||||
|
||||
# Send body if present
|
||||
if body and len(body) > 0:
|
||||
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 _wait_for_flow_control_window(self, stream_id):
|
||||
"""Wait for flow control window to become positive.
|
||||
|
||||
Returns:
|
||||
int: Available window size, or -1 if waiting failed
|
||||
"""
|
||||
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:
|
||||
return available
|
||||
|
||||
# Read more data from connection (may receive WINDOW_UPDATE)
|
||||
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 -1
|
||||
elif isinstance(event, _h2_events.ConnectionTerminated):
|
||||
self._closed = True
|
||||
return -1
|
||||
else:
|
||||
# Anything else arriving alongside the
|
||||
# WINDOW_UPDATE belongs to the main loop. It has
|
||||
# already left the h2 state machine, so dropping
|
||||
# it here loses a request or its body for good.
|
||||
self._deferred_events.append(event)
|
||||
await self._send_pending_data()
|
||||
else:
|
||||
# Connection closed
|
||||
self._closed = True
|
||||
return -1
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except _h2_exceptions.ProtocolError:
|
||||
return -1
|
||||
|
||||
return self.h2_conn.local_flow_control_window(stream_id)
|
||||
|
||||
async def send_data(self, stream_id, data, end_stream=False):
|
||||
"""Send data on a stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
data: Body data bytes
|
||||
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)
|
||||
if stream is None:
|
||||
return False
|
||||
|
||||
data_to_send = data
|
||||
try:
|
||||
while data_to_send:
|
||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
||||
|
||||
if chunk_size <= 0:
|
||||
# Wait for WINDOW_UPDATE per RFC 7540 Section 6.9.2
|
||||
await self._send_pending_data()
|
||||
available = await self._wait_for_flow_control_window(stream_id)
|
||||
if available <= 0:
|
||||
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:]
|
||||
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.close()
|
||||
self.cleanup_stream(stream_id)
|
||||
return False
|
||||
|
||||
async def send_trailers(self, stream_id, trailers):
|
||||
"""Send trailing headers on a stream.
|
||||
|
||||
Trailers are headers sent after the response body, commonly used
|
||||
for gRPC status codes, checksums, and timing information.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
trailers: List of (name, value) trailer tuples
|
||||
|
||||
Raises:
|
||||
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)
|
||||
if stream is None:
|
||||
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||
return False
|
||||
if not stream.response_headers_sent:
|
||||
# Can't send trailers without headers - return False
|
||||
return False
|
||||
|
||||
# Validate and normalize trailer headers
|
||||
trailer_headers = []
|
||||
for name, value in trailers:
|
||||
lname = name.lower()
|
||||
if lname.startswith(':'):
|
||||
raise HTTP2Error(f"Pseudo-header '{name}' not allowed in trailers")
|
||||
trailer_headers.append((lname, str(value)))
|
||||
|
||||
try:
|
||||
# Send trailers with end_stream=True
|
||||
self.h2_conn.send_headers(stream_id, trailer_headers, end_stream=True)
|
||||
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):
|
||||
"""Send an error response on a stream."""
|
||||
body = message.encode() if message else b''
|
||||
headers = [('content-length', str(len(body)))]
|
||||
if body:
|
||||
headers.append(('content-type', 'text/plain; charset=utf-8'))
|
||||
|
||||
await self.send_response(stream_id, status_code, headers, body)
|
||||
|
||||
async def reset_stream(self, stream_id, error_code=0x8):
|
||||
"""Reset a stream with RST_STREAM."""
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is not None:
|
||||
stream.reset(error_code)
|
||||
|
||||
self.h2_conn.reset_stream(stream_id, error_code=error_code)
|
||||
await self._send_pending_data()
|
||||
|
||||
async def close(self, error_code=0x0, last_stream_id=None):
|
||||
"""Close the connection gracefully with GOAWAY."""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
|
||||
if last_stream_id is None:
|
||||
last_stream_id = max(self.streams.keys()) if self.streams else 0
|
||||
|
||||
try:
|
||||
self.h2_conn.close_connection(error_code=error_code)
|
||||
await self._send_pending_data()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _send_pending_data(self):
|
||||
"""Send any pending data from h2 to the socket."""
|
||||
data = self.h2_conn.data_to_send()
|
||||
if data:
|
||||
try:
|
||||
self.writer.write(data)
|
||||
await self.writer.drain()
|
||||
except (OSError, IOError) as e:
|
||||
self._closed = True
|
||||
raise HTTP2ConnectionError(f"Socket write error: {e}")
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Check if connection is closed."""
|
||||
return self._closed
|
||||
|
||||
def cleanup_stream(self, stream_id):
|
||||
"""Remove a stream after processing is complete."""
|
||||
self.streams.pop(stream_id, None)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<AsyncHTTP2Connection "
|
||||
f"streams={len(self.streams)} "
|
||||
f"closed={self._closed}>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['AsyncHTTP2Connection']
|
||||
@@ -0,0 +1,765 @@
|
||||
# -*- coding: utf-8 -
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
HTTP/2 server connection implementation.
|
||||
|
||||
Uses the hyper-h2 library for HTTP/2 protocol handling.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import selectors
|
||||
from io import BytesIO
|
||||
|
||||
from .errors import (
|
||||
HTTP2Error, HTTP2ProtocolError, HTTP2ConnectionError,
|
||||
HTTP2NotAvailable, HTTP2ErrorCode,
|
||||
)
|
||||
from .stream import HTTP2Stream
|
||||
from .request import HTTP2Request
|
||||
|
||||
|
||||
# Import h2 lazily to allow graceful fallback
|
||||
_h2 = None
|
||||
_h2_config = None
|
||||
_h2_events = None
|
||||
_h2_exceptions = None
|
||||
_h2_settings = None
|
||||
|
||||
|
||||
def _import_h2():
|
||||
"""Lazily import h2 library components."""
|
||||
global _h2, _h2_config, _h2_events, _h2_exceptions, _h2_settings # pylint: disable=global-statement
|
||||
|
||||
if _h2 is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import h2.connection as _h2
|
||||
import h2.config as _h2_config
|
||||
import h2.events as _h2_events
|
||||
import h2.exceptions as _h2_exceptions
|
||||
import h2.settings as _h2_settings
|
||||
except ImportError:
|
||||
raise HTTP2NotAvailable()
|
||||
|
||||
|
||||
class HTTP2ServerConnection:
|
||||
"""HTTP/2 server-side connection handler.
|
||||
|
||||
Manages the HTTP/2 connection state and multiplexed streams.
|
||||
This class wraps the h2 library and provides a higher-level
|
||||
interface for gunicorn workers.
|
||||
"""
|
||||
|
||||
# Default buffer size for socket reads
|
||||
READ_BUFFER_SIZE = 65536
|
||||
|
||||
def __init__(self, cfg, sock, client_addr):
|
||||
"""Initialize an HTTP/2 server connection.
|
||||
|
||||
Args:
|
||||
cfg: Gunicorn configuration object
|
||||
sock: SSL socket with completed handshake
|
||||
client_addr: Client address tuple (host, port)
|
||||
|
||||
Raises:
|
||||
HTTP2NotAvailable: If h2 library is not installed
|
||||
"""
|
||||
_import_h2()
|
||||
|
||||
self.cfg = cfg
|
||||
self.sock = sock
|
||||
self.client_addr = client_addr
|
||||
|
||||
# Active streams indexed by stream ID
|
||||
self.streams = {}
|
||||
# Events pulled off the wire while blocked on a flow-control window.
|
||||
# They have left the h2 state machine already, so they are held here
|
||||
# for the main receive loop rather than discarded.
|
||||
self._deferred_events = collections.deque()
|
||||
|
||||
# Completed requests ready for processing
|
||||
self._pending_requests = []
|
||||
|
||||
# Connection settings from config
|
||||
self.initial_window_size = cfg.http2_initial_window_size
|
||||
self.max_concurrent_streams = cfg.http2_max_concurrent_streams
|
||||
self.max_frame_size = cfg.http2_max_frame_size
|
||||
self.max_header_list_size = cfg.http2_max_header_list_size
|
||||
|
||||
# Initialize h2 connection
|
||||
config = _h2_config.H2Configuration(
|
||||
client_side=False,
|
||||
header_encoding='utf-8',
|
||||
)
|
||||
self.h2_conn = _h2.H2Connection(config=config)
|
||||
|
||||
# Read buffer for partial frames
|
||||
self._read_buffer = BytesIO()
|
||||
|
||||
# Connection state
|
||||
self._closed = False
|
||||
self._initialized = False
|
||||
|
||||
def initiate_connection(self):
|
||||
"""Send initial HTTP/2 settings to client.
|
||||
|
||||
Should be called after the SSL handshake completes and
|
||||
before processing any data.
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# Update local settings before initiating
|
||||
self.h2_conn.update_settings({
|
||||
_h2_settings.SettingCodes.MAX_CONCURRENT_STREAMS: self.max_concurrent_streams,
|
||||
_h2_settings.SettingCodes.INITIAL_WINDOW_SIZE: self.initial_window_size,
|
||||
_h2_settings.SettingCodes.MAX_FRAME_SIZE: self.max_frame_size,
|
||||
_h2_settings.SettingCodes.MAX_HEADER_LIST_SIZE: self.max_header_list_size,
|
||||
})
|
||||
|
||||
self.h2_conn.initiate_connection()
|
||||
self._send_pending_data()
|
||||
self._initialized = True
|
||||
|
||||
def initiate_upgrade(self, settings_header, http1_req, body=None):
|
||||
"""Switch a connection to HTTP/2 after an Upgrade: h2c request.
|
||||
|
||||
The upgraded request becomes stream 1 (RFC 7540 section 3.2). h2
|
||||
opens it in the state machine; the matching gunicorn stream is built
|
||||
here from the HTTP/1 request that carried the upgrade, so the worker
|
||||
sees an ordinary HTTP/2 request.
|
||||
|
||||
``body`` is the request payload. A caller that has to drain it before
|
||||
collecting the bytes pipelined behind the request passes it here;
|
||||
leaving it None reads it off the request.
|
||||
|
||||
Returns the HTTP2Request for stream 1.
|
||||
"""
|
||||
self.h2_conn.update_settings({
|
||||
_h2_settings.SettingCodes.MAX_CONCURRENT_STREAMS: self.max_concurrent_streams,
|
||||
_h2_settings.SettingCodes.INITIAL_WINDOW_SIZE: self.initial_window_size,
|
||||
_h2_settings.SettingCodes.MAX_FRAME_SIZE: self.max_frame_size,
|
||||
_h2_settings.SettingCodes.MAX_HEADER_LIST_SIZE: self.max_header_list_size,
|
||||
})
|
||||
self.h2_conn.initiate_upgrade_connection(settings_header=settings_header)
|
||||
self._send_pending_data()
|
||||
self._initialized = True
|
||||
|
||||
stream = HTTP2Stream(stream_id=1, connection=self)
|
||||
authority = ""
|
||||
for name, value in http1_req.headers:
|
||||
if name == "HOST":
|
||||
authority = value
|
||||
break
|
||||
pseudo = [
|
||||
(':method', http1_req.method),
|
||||
(':path', http1_req.uri),
|
||||
(':scheme', http1_req.scheme),
|
||||
]
|
||||
if authority:
|
||||
pseudo.append((':authority', authority))
|
||||
regular = [(name.lower(), value) for name, value in http1_req.headers
|
||||
if name not in ("CONNECTION", "UPGRADE", "HTTP2-SETTINGS")]
|
||||
|
||||
if body is None:
|
||||
body = b""
|
||||
if http1_req.body is not None:
|
||||
body = http1_req.body.read() or b""
|
||||
stream.receive_headers(pseudo + regular, end_stream=not body)
|
||||
if body:
|
||||
stream.receive_data(body, end_stream=True)
|
||||
self.streams[1] = stream
|
||||
return HTTP2Request(stream, self.cfg, self.client_addr)
|
||||
|
||||
def receive_data(self, data=None):
|
||||
"""Process received data and return completed requests.
|
||||
|
||||
Args:
|
||||
data: Optional bytes to process. If None, reads from socket.
|
||||
|
||||
Returns:
|
||||
list: List of HTTP2Request objects for completed requests
|
||||
|
||||
Raises:
|
||||
HTTP2ConnectionError: On protocol or connection errors
|
||||
"""
|
||||
if data is None:
|
||||
try:
|
||||
data = self.sock.recv(self.READ_BUFFER_SIZE)
|
||||
except (OSError, IOError) as e:
|
||||
raise HTTP2ConnectionError(f"Socket read error: {e}")
|
||||
|
||||
if not data:
|
||||
# Connection closed by peer
|
||||
self._closed = True
|
||||
return []
|
||||
|
||||
# Feed data to h2
|
||||
# Note: Specific exceptions must come before ProtocolError (their parent class)
|
||||
try:
|
||||
events = self.h2_conn.receive_data(data)
|
||||
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))
|
||||
except _h2_exceptions.ProtocolError as e:
|
||||
# Send GOAWAY with PROTOCOL_ERROR before raising
|
||||
self.close(error_code=HTTP2ErrorCode.PROTOCOL_ERROR)
|
||||
raise HTTP2ProtocolError(str(e))
|
||||
|
||||
# Process events, oldest first: anything set aside during a
|
||||
# flow-control wait arrived before this batch.
|
||||
completed_requests = []
|
||||
if self._deferred_events:
|
||||
events = list(self._deferred_events) + list(events)
|
||||
self._deferred_events.clear()
|
||||
for event in events:
|
||||
request = self._handle_event(event)
|
||||
if request is not None:
|
||||
completed_requests.append(request)
|
||||
|
||||
# Send any pending data (WINDOW_UPDATE, etc.)
|
||||
self._send_pending_data()
|
||||
|
||||
return completed_requests
|
||||
|
||||
def _handle_event(self, event):
|
||||
"""Handle a single h2 event.
|
||||
|
||||
Args:
|
||||
event: h2 event object
|
||||
|
||||
Returns:
|
||||
HTTP2Request if a request is complete, None otherwise
|
||||
"""
|
||||
if isinstance(event, _h2_events.RequestReceived):
|
||||
return self._handle_request_received(event)
|
||||
|
||||
elif isinstance(event, _h2_events.DataReceived):
|
||||
return self._handle_data_received(event)
|
||||
|
||||
elif isinstance(event, _h2_events.StreamEnded):
|
||||
return self._handle_stream_ended(event)
|
||||
|
||||
elif isinstance(event, _h2_events.StreamReset):
|
||||
self._handle_stream_reset(event)
|
||||
|
||||
elif isinstance(event, _h2_events.WindowUpdated):
|
||||
pass # Flow control update, handled by h2
|
||||
|
||||
elif isinstance(event, _h2_events.PriorityUpdated):
|
||||
self._handle_priority_updated(event)
|
||||
|
||||
elif isinstance(event, _h2_events.SettingsAcknowledged):
|
||||
pass # Settings ACK received
|
||||
|
||||
elif isinstance(event, _h2_events.ConnectionTerminated):
|
||||
self._handle_connection_terminated(event)
|
||||
|
||||
elif isinstance(event, _h2_events.TrailersReceived):
|
||||
return self._handle_trailers_received(event)
|
||||
|
||||
return None
|
||||
|
||||
def _handle_request_received(self, event):
|
||||
"""Handle RequestReceived event (HEADERS frame).
|
||||
|
||||
Args:
|
||||
event: RequestReceived event with headers
|
||||
"""
|
||||
stream_id = event.stream_id
|
||||
headers = event.headers
|
||||
|
||||
# Create new stream
|
||||
stream = HTTP2Stream(stream_id, self)
|
||||
self.streams[stream_id] = stream
|
||||
|
||||
# Process headers
|
||||
# The StreamEnded event will come separately for GET/HEAD with no body
|
||||
stream.receive_headers(headers, end_stream=False)
|
||||
|
||||
def _handle_data_received(self, event):
|
||||
"""Handle DataReceived event.
|
||||
|
||||
Args:
|
||||
event: DataReceived event with body data
|
||||
|
||||
Returns:
|
||||
None (request completion handled by StreamEnded)
|
||||
"""
|
||||
stream_id = event.stream_id
|
||||
data = event.data
|
||||
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is None:
|
||||
# Stream was reset or doesn't exist
|
||||
return None
|
||||
|
||||
stream.receive_data(data, end_stream=False)
|
||||
|
||||
# Increment flow control windows (only if data received)
|
||||
if len(data) > 0:
|
||||
try:
|
||||
# Update stream-level window
|
||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=stream_id)
|
||||
# Update connection-level window
|
||||
self.h2_conn.increment_flow_control_window(len(data), stream_id=None)
|
||||
# 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
|
||||
|
||||
def _handle_stream_ended(self, event):
|
||||
"""Handle StreamEnded event.
|
||||
|
||||
Args:
|
||||
event: StreamEnded event
|
||||
|
||||
Returns:
|
||||
HTTP2Request for the completed request
|
||||
"""
|
||||
stream_id = event.stream_id
|
||||
stream = self.streams.get(stream_id)
|
||||
|
||||
if stream is None:
|
||||
return None
|
||||
|
||||
# Mark stream as request complete and body complete so the
|
||||
# receive() closure's _body_complete guard fires, preventing
|
||||
# the fast path from re-reading already-consumed data from BytesIO.
|
||||
stream.request_complete = True
|
||||
stream._body_complete = True
|
||||
if stream._body_event:
|
||||
stream._body_event.set()
|
||||
|
||||
# Create request object
|
||||
return HTTP2Request(stream, self.cfg, self.client_addr)
|
||||
|
||||
def _handle_stream_reset(self, event):
|
||||
"""Handle StreamReset event (RST_STREAM frame).
|
||||
|
||||
Args:
|
||||
event: StreamReset event
|
||||
"""
|
||||
stream_id = event.stream_id
|
||||
stream = self.streams.get(stream_id)
|
||||
|
||||
if stream is not None:
|
||||
stream.reset(event.error_code)
|
||||
# Keep stream in dict for potential cleanup
|
||||
|
||||
def _handle_connection_terminated(self, event):
|
||||
"""Handle ConnectionTerminated event (GOAWAY frame).
|
||||
|
||||
Args:
|
||||
event: ConnectionTerminated event
|
||||
"""
|
||||
self._closed = True
|
||||
# Could log event.error_code and event.additional_data
|
||||
|
||||
def _handle_trailers_received(self, event):
|
||||
"""Handle TrailersReceived event.
|
||||
|
||||
Args:
|
||||
event: TrailersReceived event with trailer headers
|
||||
|
||||
Returns:
|
||||
HTTP2Request if this completes the request
|
||||
"""
|
||||
stream_id = event.stream_id
|
||||
stream = self.streams.get(stream_id)
|
||||
|
||||
if stream is None:
|
||||
return None
|
||||
|
||||
stream.receive_trailers(event.headers)
|
||||
|
||||
# Trailers always end the request
|
||||
return HTTP2Request(stream, self.cfg, self.client_addr)
|
||||
|
||||
def _handle_priority_updated(self, event):
|
||||
"""Handle PriorityUpdated event (PRIORITY frame).
|
||||
|
||||
Args:
|
||||
event: PriorityUpdated event with priority info
|
||||
"""
|
||||
stream = self.streams.get(event.stream_id)
|
||||
if stream is not None:
|
||||
stream.update_priority(
|
||||
weight=event.weight,
|
||||
depends_on=event.depends_on,
|
||||
exclusive=event.exclusive
|
||||
)
|
||||
|
||||
def send_informational(self, stream_id, status, headers):
|
||||
"""Send an informational response (1xx) on a stream.
|
||||
|
||||
This is used for 103 Early Hints and other 1xx responses.
|
||||
Informational responses are sent before the final response
|
||||
and do not end the stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
status: HTTP status code (100-199)
|
||||
headers: List of (name, value) header tuples
|
||||
|
||||
Raises:
|
||||
HTTP2Error: If status is not in 1xx range
|
||||
"""
|
||||
if status < 100 or status >= 200:
|
||||
raise HTTP2Error(f"Invalid informational status: {status}")
|
||||
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is None:
|
||||
raise HTTP2Error(f"Stream {stream_id} not found")
|
||||
|
||||
# Build headers with :status pseudo-header
|
||||
response_headers = [(':status', str(status))]
|
||||
for name, value in headers:
|
||||
# HTTP/2 headers must be lowercase
|
||||
response_headers.append((name.lower(), str(value)))
|
||||
|
||||
# Send headers with end_stream=False (informational, more to follow)
|
||||
self.h2_conn.send_headers(stream_id, response_headers, end_stream=False)
|
||||
self._send_pending_data()
|
||||
|
||||
def send_response_headers(self, stream_id, status, headers,
|
||||
end_stream=False):
|
||||
"""Send response headers on a stream without ending it.
|
||||
|
||||
Returns False if the stream is already gone. Split out of
|
||||
send_response() so a response can be streamed: headers first, then
|
||||
any number of data frames, then end_stream().
|
||||
"""
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is None:
|
||||
# Stream was already cleaned up (reset/closed)
|
||||
return False
|
||||
|
||||
# Build response headers with :status pseudo-header
|
||||
response_headers = [(':status', str(status))]
|
||||
for name, value in headers:
|
||||
# HTTP/2 headers must be lowercase
|
||||
response_headers.append((name.lower(), str(value)))
|
||||
|
||||
self.h2_conn.send_headers(stream_id, response_headers,
|
||||
end_stream=end_stream)
|
||||
stream.send_headers(response_headers, end_stream=end_stream)
|
||||
self._send_pending_data()
|
||||
return True
|
||||
|
||||
def end_stream(self, stream_id, trailers=None):
|
||||
"""Close the sending half of a stream, with trailers if given."""
|
||||
if self.streams.get(stream_id) is None:
|
||||
return False
|
||||
if trailers:
|
||||
self.send_trailers(stream_id, trailers)
|
||||
return True
|
||||
# Not send_data(): it chunks against the flow-control window and an
|
||||
# empty payload skips that loop entirely, so END_STREAM would never
|
||||
# reach the peer and the client would wait for a response that is
|
||||
# already finished.
|
||||
# Not send_data(): it chunks against the flow-control window and an
|
||||
# empty payload skips that loop entirely, so END_STREAM would never
|
||||
# reach the peer and the client would wait for a response that is
|
||||
# already finished.
|
||||
try:
|
||||
self.h2_conn.send_data(stream_id, b"", end_stream=True)
|
||||
self.streams[stream_id].send_data(b"", end_stream=True)
|
||||
self._send_pending_data()
|
||||
except _h2_exceptions.StreamClosedError:
|
||||
self.cleanup_stream(stream_id)
|
||||
return False
|
||||
return True
|
||||
|
||||
def send_response(self, stream_id, status, headers, body=None):
|
||||
"""Send a response on a stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID to respond on
|
||||
status: HTTP status code (int)
|
||||
headers: List of (name, value) header tuples
|
||||
body: Optional response body bytes
|
||||
|
||||
Raises:
|
||||
HTTP2Error: If stream not found or in invalid state
|
||||
|
||||
Returns:
|
||||
bool: True if response sent, False if stream was already closed
|
||||
"""
|
||||
end_stream = body is None or len(body) == 0
|
||||
try:
|
||||
if not self.send_response_headers(stream_id, status, headers,
|
||||
end_stream=end_stream):
|
||||
return False
|
||||
# Send body if present
|
||||
if body and len(body) > 0:
|
||||
self.send_data(stream_id, body, end_stream=True)
|
||||
return True
|
||||
except _h2_exceptions.StreamClosedError:
|
||||
# Stream was reset by client - clean up gracefully
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
self.cleanup_stream(stream_id)
|
||||
return False
|
||||
|
||||
def _wait_for_flow_control_window(self, stream_id):
|
||||
"""Wait for flow control window to become positive.
|
||||
|
||||
Returns:
|
||||
int: Available window size, or -1 if waiting failed
|
||||
"""
|
||||
|
||||
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)
|
||||
return -1
|
||||
|
||||
result = -1
|
||||
try:
|
||||
for _ in range(max_wait_attempts):
|
||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||
if available > 0:
|
||||
result = available
|
||||
break
|
||||
|
||||
ready = sel.select(timeout=0.1)
|
||||
if ready:
|
||||
try:
|
||||
incoming = self.sock.recv(self.READ_BUFFER_SIZE)
|
||||
except (OSError, IOError, _h2_exceptions.ProtocolError):
|
||||
break
|
||||
if not incoming:
|
||||
self._closed = True
|
||||
break
|
||||
try:
|
||||
events = self.h2_conn.receive_data(incoming)
|
||||
except _h2_exceptions.ProtocolError:
|
||||
break
|
||||
for event in events:
|
||||
if isinstance(event, _h2_events.StreamReset):
|
||||
if event.stream_id == stream_id:
|
||||
result = -1
|
||||
break
|
||||
elif isinstance(event, _h2_events.ConnectionTerminated):
|
||||
self._closed = True
|
||||
result = -1
|
||||
break
|
||||
else:
|
||||
# Anything else arriving alongside the
|
||||
# WINDOW_UPDATE belongs to the main loop. It has
|
||||
# already left the h2 state machine, so dropping
|
||||
# it here loses a request or its body for good.
|
||||
self._deferred_events.append(event)
|
||||
else:
|
||||
self._send_pending_data()
|
||||
continue
|
||||
break # Break outer loop if inner loop broke
|
||||
else:
|
||||
# Loop completed without break - check final window
|
||||
result = self.h2_conn.local_flow_control_window(stream_id)
|
||||
finally:
|
||||
sel.close()
|
||||
|
||||
return result
|
||||
|
||||
def send_data(self, stream_id, data, end_stream=False):
|
||||
"""Send data on a stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
data: Body data bytes
|
||||
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)
|
||||
if stream is None:
|
||||
return False
|
||||
|
||||
data_to_send = data
|
||||
try:
|
||||
while data_to_send:
|
||||
available = self.h2_conn.local_flow_control_window(stream_id)
|
||||
chunk_size = min(available, self.max_frame_size, len(data_to_send))
|
||||
|
||||
if chunk_size <= 0:
|
||||
# Wait for WINDOW_UPDATE per RFC 7540 Section 6.9.2
|
||||
self._send_pending_data()
|
||||
available = self._wait_for_flow_control_window(stream_id)
|
||||
if available <= 0:
|
||||
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:]
|
||||
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):
|
||||
"""Send trailing headers on a stream.
|
||||
|
||||
Trailers are headers sent after the response body, commonly used
|
||||
for gRPC status codes, checksums, and timing information.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
trailers: List of (name, value) trailer tuples
|
||||
|
||||
Raises:
|
||||
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)
|
||||
if stream is None:
|
||||
# Stream was already cleaned up (reset/closed) - return gracefully
|
||||
return False
|
||||
if not stream.response_headers_sent:
|
||||
# Can't send trailers without headers - return False
|
||||
return False
|
||||
|
||||
# Validate and normalize trailer headers
|
||||
trailer_headers = []
|
||||
for name, value in trailers:
|
||||
lname = name.lower()
|
||||
if lname.startswith(':'):
|
||||
raise HTTP2Error(f"Pseudo-header '{name}' not allowed in trailers")
|
||||
trailer_headers.append((lname, str(value)))
|
||||
|
||||
try:
|
||||
# Send trailers with end_stream=True
|
||||
self.h2_conn.send_headers(stream_id, trailer_headers, end_stream=True)
|
||||
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):
|
||||
"""Send an error response on a stream.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID
|
||||
status_code: HTTP status code
|
||||
message: Optional error message body
|
||||
"""
|
||||
body = message.encode() if message else b''
|
||||
headers = [('content-length', str(len(body)))]
|
||||
if body:
|
||||
headers.append(('content-type', 'text/plain; charset=utf-8'))
|
||||
|
||||
self.send_response(stream_id, status_code, headers, body)
|
||||
|
||||
def reset_stream(self, stream_id, error_code=0x8):
|
||||
"""Reset a stream with RST_STREAM.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID to reset
|
||||
error_code: HTTP/2 error code (default: CANCEL)
|
||||
"""
|
||||
stream = self.streams.get(stream_id)
|
||||
if stream is not None:
|
||||
stream.reset(error_code)
|
||||
|
||||
self.h2_conn.reset_stream(stream_id, error_code=error_code)
|
||||
self._send_pending_data()
|
||||
|
||||
def close(self, error_code=0x0, last_stream_id=None):
|
||||
"""Close the connection gracefully with GOAWAY.
|
||||
|
||||
Args:
|
||||
error_code: HTTP/2 error code (default: NO_ERROR)
|
||||
last_stream_id: Last processed stream ID (default: highest)
|
||||
"""
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
self._closed = True
|
||||
|
||||
if last_stream_id is None:
|
||||
# Use highest stream ID we've seen
|
||||
last_stream_id = max(self.streams.keys()) if self.streams else 0
|
||||
|
||||
try:
|
||||
self.h2_conn.close_connection(error_code=error_code)
|
||||
self._send_pending_data()
|
||||
except Exception:
|
||||
pass # Best effort
|
||||
|
||||
def _send_pending_data(self):
|
||||
"""Send any pending data from h2 to the socket."""
|
||||
data = self.h2_conn.data_to_send()
|
||||
if data:
|
||||
try:
|
||||
self.sock.sendall(data)
|
||||
except (OSError, IOError) as e:
|
||||
self._closed = True
|
||||
raise HTTP2ConnectionError(f"Socket write error: {e}")
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Check if connection is closed."""
|
||||
return self._closed
|
||||
|
||||
def cleanup_stream(self, stream_id):
|
||||
"""Remove a stream after processing is complete.
|
||||
|
||||
Args:
|
||||
stream_id: The stream ID to clean up
|
||||
"""
|
||||
self.streams.pop(stream_id, None)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<HTTP2ServerConnection "
|
||||
f"streams={len(self.streams)} "
|
||||
f"closed={self._closed}>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['HTTP2ServerConnection']
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
HTTP/2 specific exceptions.
|
||||
|
||||
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):
|
||||
"""Base exception for HTTP/2 errors."""
|
||||
|
||||
error_code = 0x0 # NO_ERROR
|
||||
|
||||
def __init__(self, message=None, error_code=None):
|
||||
self.message = message or self.__class__.__doc__
|
||||
if error_code is not None:
|
||||
self.error_code = error_code
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class HTTP2ProtocolError(HTTP2Error):
|
||||
"""Protocol error detected."""
|
||||
|
||||
error_code = 0x1 # PROTOCOL_ERROR
|
||||
|
||||
|
||||
class HTTP2InternalError(HTTP2Error):
|
||||
"""Internal error occurred."""
|
||||
|
||||
error_code = 0x2 # INTERNAL_ERROR
|
||||
|
||||
|
||||
class HTTP2FlowControlError(HTTP2Error):
|
||||
"""Flow control limits exceeded."""
|
||||
|
||||
error_code = 0x3 # FLOW_CONTROL_ERROR
|
||||
|
||||
|
||||
class HTTP2SettingsTimeout(HTTP2Error):
|
||||
"""Settings acknowledgment timeout."""
|
||||
|
||||
error_code = 0x4 # SETTINGS_TIMEOUT
|
||||
|
||||
|
||||
class HTTP2StreamClosed(HTTP2Error):
|
||||
"""Stream was closed."""
|
||||
|
||||
error_code = 0x5 # STREAM_CLOSED
|
||||
|
||||
|
||||
class HTTP2FrameSizeError(HTTP2Error):
|
||||
"""Frame size is incorrect."""
|
||||
|
||||
error_code = 0x6 # FRAME_SIZE_ERROR
|
||||
|
||||
|
||||
class HTTP2RefusedStream(HTTP2Error):
|
||||
"""Stream was refused."""
|
||||
|
||||
error_code = 0x7 # REFUSED_STREAM
|
||||
|
||||
|
||||
class HTTP2Cancel(HTTP2Error):
|
||||
"""Stream was cancelled."""
|
||||
|
||||
error_code = 0x8 # CANCEL
|
||||
|
||||
|
||||
class HTTP2CompressionError(HTTP2Error):
|
||||
"""Compression state error."""
|
||||
|
||||
error_code = 0x9 # COMPRESSION_ERROR
|
||||
|
||||
|
||||
class HTTP2ConnectError(HTTP2Error):
|
||||
"""Connection error during CONNECT."""
|
||||
|
||||
error_code = 0xa # CONNECT_ERROR
|
||||
|
||||
|
||||
class HTTP2EnhanceYourCalm(HTTP2Error):
|
||||
"""Peer is generating excessive load."""
|
||||
|
||||
error_code = 0xb # ENHANCE_YOUR_CALM
|
||||
|
||||
|
||||
class HTTP2InadequateSecurity(HTTP2Error):
|
||||
"""Transport security is inadequate."""
|
||||
|
||||
error_code = 0xc # INADEQUATE_SECURITY
|
||||
|
||||
|
||||
class HTTP2RequiresHTTP11(HTTP2Error):
|
||||
"""HTTP/1.1 is required for this request."""
|
||||
|
||||
error_code = 0xd # HTTP_1_1_REQUIRED
|
||||
|
||||
|
||||
class HTTP2StreamError(HTTP2Error):
|
||||
"""Error specific to a single stream."""
|
||||
|
||||
def __init__(self, stream_id, message=None, error_code=None):
|
||||
self.stream_id = stream_id
|
||||
super().__init__(message, error_code)
|
||||
|
||||
def __str__(self):
|
||||
return f"Stream {self.stream_id}: {self.message}"
|
||||
|
||||
|
||||
class HTTP2ConnectionError(HTTP2Error):
|
||||
"""Error affecting the entire connection."""
|
||||
|
||||
|
||||
class HTTP2ConfigurationError(HTTP2Error):
|
||||
"""Invalid HTTP/2 configuration."""
|
||||
|
||||
|
||||
class HTTP2NotAvailable(HTTP2Error):
|
||||
"""HTTP/2 support is not available (h2 library not installed)."""
|
||||
|
||||
def __init__(self, message=None):
|
||||
message = message or "HTTP/2 requires the h2 library: pip install gunicorn[http2]"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'HTTP2ErrorCode',
|
||||
'HTTP2Error',
|
||||
'HTTP2ProtocolError',
|
||||
'HTTP2InternalError',
|
||||
'HTTP2FlowControlError',
|
||||
'HTTP2SettingsTimeout',
|
||||
'HTTP2StreamClosed',
|
||||
'HTTP2FrameSizeError',
|
||||
'HTTP2RefusedStream',
|
||||
'HTTP2Cancel',
|
||||
'HTTP2CompressionError',
|
||||
'HTTP2ConnectError',
|
||||
'HTTP2EnhanceYourCalm',
|
||||
'HTTP2InadequateSecurity',
|
||||
'HTTP2RequiresHTTP11',
|
||||
'HTTP2StreamError',
|
||||
'HTTP2ConnectionError',
|
||||
'HTTP2ConfigurationError',
|
||||
'HTTP2NotAvailable',
|
||||
]
|
||||
@@ -0,0 +1,170 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""Cleartext HTTP/2 (h2c) negotiation, shared by every worker.
|
||||
|
||||
The I/O differs per worker: gthread and gevent read from a socket, the ASGI
|
||||
worker is handed bytes by asyncio. The decisions do not. Keeping the pure,
|
||||
I/O-free part here stops the blocking and push-based paths from drifting apart.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from gunicorn.http.message import _ip_in_allow_list
|
||||
|
||||
#: HTTP/2 connection preface sent by clients using prior knowledge,
|
||||
#: RFC 9113 section 3.4.
|
||||
H2C_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
|
||||
|
||||
#: How long to wait for the whole preface once the first bytes arrive. This
|
||||
#: is a budget for the entire preface, not per read.
|
||||
H2C_PREFACE_TIMEOUT = 1.0
|
||||
|
||||
MATCH = "match"
|
||||
PARTIAL = "partial"
|
||||
MISMATCH = "mismatch"
|
||||
|
||||
|
||||
def preface_match(buf):
|
||||
"""Compare buffered bytes against the connection preface.
|
||||
|
||||
Returns ``MATCH`` when the whole preface is present, ``PARTIAL`` when the
|
||||
bytes so far are a prefix of it and more could still arrive, and
|
||||
``MISMATCH`` as soon as a byte diverges. Never blocks and never reads.
|
||||
"""
|
||||
if len(buf) >= len(H2C_PREFACE):
|
||||
return MATCH if buf.startswith(H2C_PREFACE) else MISMATCH
|
||||
return PARTIAL if H2C_PREFACE.startswith(buf) else MISMATCH
|
||||
|
||||
|
||||
def peer_trusted_for_h2c(cfg, peer_addr):
|
||||
"""Whether this peer may negotiate cleartext HTTP/2.
|
||||
|
||||
Reuses the ``forwarded_allow_ips`` trust list: h2c is only ever expected
|
||||
from the TLS-terminating proxy in front of gunicorn, which is the same
|
||||
peer already trusted to set forwarded headers. Unix socket peers are
|
||||
trusted, matching that policy.
|
||||
"""
|
||||
if not isinstance(peer_addr, tuple):
|
||||
return True
|
||||
return _ip_in_allow_list(
|
||||
peer_addr[0], cfg.forwarded_allow_ips, cfg.forwarded_allow_networks()
|
||||
)
|
||||
|
||||
|
||||
def _h2c_available(cfg):
|
||||
"""Whether cleartext HTTP/2 could apply to this server at all."""
|
||||
return (
|
||||
"h2" in cfg.http_protocols
|
||||
and getattr(cfg, "protocol", "http") == "http"
|
||||
and not cfg.is_ssl
|
||||
)
|
||||
|
||||
|
||||
def prior_knowledge_allowed(cfg, peer_addr):
|
||||
"""Whether to sniff for the connection preface from this peer.
|
||||
|
||||
Deliberately separate from :func:`upgrade_allowed`: enabling one mechanism
|
||||
must not quietly enable the other.
|
||||
"""
|
||||
if cfg.http2_cleartext not in ("prior-knowledge", "both"):
|
||||
return False
|
||||
return _h2c_available(cfg) and peer_trusted_for_h2c(cfg, peer_addr)
|
||||
|
||||
|
||||
def mismatch_is_error(cfg):
|
||||
"""Whether a trusted peer failing to send the preface is a 400.
|
||||
|
||||
Only when prior knowledge is the sole mechanism: such a peer is expected
|
||||
to speak HTTP/2 and a silent downgrade would hide a misconfiguration.
|
||||
When upgrade is also enabled, an HTTP/1 request is not a mistake, it is
|
||||
how an upgrade begins, so it has to be allowed through.
|
||||
"""
|
||||
return cfg.http2_cleartext == "prior-knowledge"
|
||||
|
||||
|
||||
def upgrade_allowed(cfg, peer_addr):
|
||||
"""Whether to honour an ``Upgrade: h2c`` request from this peer."""
|
||||
if cfg.http2_cleartext not in ("upgrade", "both"):
|
||||
return False
|
||||
return _h2c_available(cfg) and peer_trusted_for_h2c(cfg, peer_addr)
|
||||
|
||||
|
||||
def read_preface_blocking(sock, timeout=None):
|
||||
"""Read up to the length of the preface from a blocking socket.
|
||||
|
||||
Returns ``(matched, consumed_bytes)``. The caller owns the consumed bytes
|
||||
and must hand them to whichever protocol wins, since they have already
|
||||
left the socket.
|
||||
|
||||
The timeout is an absolute budget for the whole preface, checked before
|
||||
every read. ``socket.settimeout()`` alone would bound each call instead,
|
||||
which lets a client trickle one byte per interval and hold the connection
|
||||
(and, on gthread, a pool slot) for as many intervals as the preface has
|
||||
bytes.
|
||||
"""
|
||||
if timeout is None:
|
||||
# read at call time so the module attribute stays adjustable
|
||||
timeout = H2C_PREFACE_TIMEOUT
|
||||
buf = b""
|
||||
deadline = time.monotonic() + timeout
|
||||
original = sock.gettimeout()
|
||||
try:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False, buf
|
||||
sock.settimeout(remaining)
|
||||
try:
|
||||
chunk = sock.recv(len(H2C_PREFACE) - len(buf))
|
||||
except (TimeoutError, OSError):
|
||||
return False, buf
|
||||
if not chunk:
|
||||
return False, buf
|
||||
buf += chunk
|
||||
state = preface_match(buf)
|
||||
if state is MATCH:
|
||||
return True, buf
|
||||
if state is MISMATCH:
|
||||
return False, buf
|
||||
finally:
|
||||
sock.settimeout(original)
|
||||
|
||||
|
||||
#: Sent before switching an HTTP/1.1 connection over to HTTP/2.
|
||||
UPGRADE_101 = (
|
||||
b"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
b"Connection: Upgrade\r\n"
|
||||
b"Upgrade: h2c\r\n"
|
||||
b"\r\n"
|
||||
)
|
||||
|
||||
|
||||
def upgrade_settings(req):
|
||||
"""Return the HTTP2-Settings payload if this request asks for h2c.
|
||||
|
||||
RFC 7540 section 3.2: the request must name ``h2c`` in Upgrade and carry
|
||||
exactly one HTTP2-Settings header, itself named in Connection. Returns
|
||||
None when the request is not a well-formed upgrade attempt, so the caller
|
||||
simply carries on with HTTP/1.
|
||||
"""
|
||||
upgrade = None
|
||||
settings = []
|
||||
connection = ""
|
||||
for name, value in req.headers:
|
||||
if name == "UPGRADE":
|
||||
upgrade = value.strip().lower()
|
||||
elif name == "HTTP2-SETTINGS":
|
||||
settings.append(value.strip())
|
||||
elif name == "CONNECTION":
|
||||
connection = value.lower()
|
||||
|
||||
if upgrade != "h2c":
|
||||
return None
|
||||
# Exactly one, per RFC 7540 3.2.1: a second one is ambiguous.
|
||||
if len(settings) != 1:
|
||||
return None
|
||||
if "http2-settings" not in connection or "upgrade" not in connection:
|
||||
return None
|
||||
return settings[0].encode("latin-1")
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
# -*- coding: utf-8 -
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
HTTP/2 request wrapper.
|
||||
|
||||
Provides a Request-compatible interface for HTTP/2 streams.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from gunicorn.http.message import (
|
||||
HeaderPolicy,
|
||||
RFC9110_5_5_INVALID_AND_DANGEROUS,
|
||||
)
|
||||
from gunicorn.http.errors import InvalidHeader
|
||||
from gunicorn.util import split_request_uri
|
||||
|
||||
|
||||
class HTTP2Body:
|
||||
"""Body wrapper for HTTP/2 request data.
|
||||
|
||||
Provides a file-like interface to the request body,
|
||||
compatible with gunicorn's Body class expectations.
|
||||
"""
|
||||
|
||||
def __init__(self, data):
|
||||
"""Initialize with body data.
|
||||
|
||||
Args:
|
||||
data: bytes containing the request body
|
||||
"""
|
||||
self._data = BytesIO(data)
|
||||
self._len = len(data)
|
||||
|
||||
def read(self, size=None):
|
||||
"""Read data from the body.
|
||||
|
||||
Args:
|
||||
size: Number of bytes to read, or None for all remaining
|
||||
|
||||
Returns:
|
||||
bytes: The requested data
|
||||
"""
|
||||
if size is None:
|
||||
return self._data.read()
|
||||
return self._data.read(size)
|
||||
|
||||
def readline(self, size=None):
|
||||
"""Read a line from the body.
|
||||
|
||||
Args:
|
||||
size: Maximum bytes to read
|
||||
|
||||
Returns:
|
||||
bytes: A line of data
|
||||
"""
|
||||
if size is None:
|
||||
return self._data.readline()
|
||||
return self._data.readline(size)
|
||||
|
||||
def readlines(self, hint=None):
|
||||
"""Read all lines from the body.
|
||||
|
||||
Args:
|
||||
hint: Approximate byte count hint
|
||||
|
||||
Returns:
|
||||
list: List of lines
|
||||
"""
|
||||
return self._data.readlines(hint)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over lines in the body."""
|
||||
return iter(self._data)
|
||||
|
||||
def __len__(self):
|
||||
"""Return the content length."""
|
||||
return self._len
|
||||
|
||||
def close(self):
|
||||
"""Close the body stream."""
|
||||
self._data.close()
|
||||
|
||||
|
||||
class HTTP2Request(HeaderPolicy):
|
||||
"""HTTP/2 request wrapper compatible with gunicorn Request interface.
|
||||
|
||||
Wraps an HTTP2Stream to provide the same interface as the HTTP/1.x
|
||||
Request class, allowing workers to handle HTTP/2 requests using
|
||||
existing code paths.
|
||||
"""
|
||||
|
||||
#: HTTP/2 carries no 100-continue handshake gunicorn can answer: the
|
||||
#: response would be written as HTTP/1 bytes onto an HTTP/2 connection.
|
||||
_policy_expect_continue = False
|
||||
|
||||
def __init__(self, stream, cfg, peer_addr):
|
||||
"""Initialize from an HTTP/2 stream.
|
||||
|
||||
Args:
|
||||
stream: HTTP2Stream instance with received headers/body
|
||||
cfg: Gunicorn configuration object
|
||||
peer_addr: Client address tuple (host, port)
|
||||
"""
|
||||
self.stream = stream
|
||||
self.cfg = cfg
|
||||
self.peer_addr = peer_addr
|
||||
self.remote_addr = peer_addr
|
||||
|
||||
# HTTP/2 version tuple
|
||||
self.version = (2, 0)
|
||||
|
||||
# Parse pseudo-headers
|
||||
pseudo = stream.get_pseudo_headers()
|
||||
self.method = pseudo.get(':method', 'GET')
|
||||
# Derive the scheme from the transport, as HTTP/1 does. A client
|
||||
# supplied :scheme is honoured only from a peer allowed to speak for
|
||||
# the connection; otherwise it is ignored rather than rejected, which
|
||||
# mirrors how an untrusted X-Forwarded-Proto is treated on HTTP/1.
|
||||
self.scheme = "https" if cfg.is_ssl else "http"
|
||||
claimed_scheme = pseudo.get(':scheme')
|
||||
if claimed_scheme and self._peer_is_trusted_proxy():
|
||||
self.scheme = claimed_scheme
|
||||
authority = pseudo.get(':authority', '')
|
||||
path = pseudo.get(':path', '/')
|
||||
|
||||
# Parse the path into components
|
||||
self.uri = path
|
||||
try:
|
||||
parts = split_request_uri(path)
|
||||
self.path = parts.path or ""
|
||||
self.query = parts.query or ""
|
||||
self.fragment = parts.fragment or ""
|
||||
except ValueError:
|
||||
self.path = path
|
||||
self.query = ""
|
||||
self.fragment = ""
|
||||
|
||||
# Store authority for Host header equivalent
|
||||
self._authority = authority
|
||||
|
||||
# Convert HTTP/2 headers to HTTP/1.1 style and put them through the
|
||||
# same policy as HTTP/1, so a rule cannot hold on one protocol and be
|
||||
# skipped on the other.
|
||||
self.headers = []
|
||||
scheme_state = [False]
|
||||
seen = set()
|
||||
secure_scheme_headers, forwarder_headers = \
|
||||
self._peer_trusted_for_forwarded()
|
||||
for name, value in stream.get_regular_headers():
|
||||
# Convert to uppercase for WSGI compatibility
|
||||
name = name.upper()
|
||||
if RFC9110_5_5_INVALID_AND_DANGEROUS.search(value):
|
||||
raise InvalidHeader(name, req=self)
|
||||
kept = self._apply_header_policy(
|
||||
name, value, scheme_state, seen,
|
||||
secure_scheme_headers, forwarder_headers,
|
||||
)
|
||||
if kept is None:
|
||||
continue
|
||||
self.headers.append(kept)
|
||||
|
||||
# Set Host header from :authority (RFC 9113 section 8.3.1)
|
||||
# :authority MUST take precedence over Host header. Runs after the
|
||||
# policy so a duplicate Host is still rejected rather than replaced.
|
||||
if authority:
|
||||
self.headers = [(n, v) for n, v in self.headers if n != 'HOST']
|
||||
self.headers.append(('HOST', authority))
|
||||
|
||||
# Trailers (if any)
|
||||
self.trailers = []
|
||||
if stream.trailers:
|
||||
self.trailers = [
|
||||
(name.upper(), value)
|
||||
for name, value in stream.trailers
|
||||
]
|
||||
|
||||
# Body - HTTP/2 streams have complete body data
|
||||
body_data = stream.get_request_body()
|
||||
self.body = HTTP2Body(body_data)
|
||||
|
||||
# Connection state
|
||||
self.must_close = False
|
||||
# Never set on HTTP/2: gunicorn answers it with HTTP/1 bytes written
|
||||
# straight to the socket, which would corrupt the connection.
|
||||
self._expected_100_continue = False
|
||||
|
||||
# Request numbering (for logging)
|
||||
self.req_number = stream.stream_id
|
||||
|
||||
# HTTP/2 does not use proxy protocol through the data stream
|
||||
self.proxy_protocol_info = None
|
||||
|
||||
# Stream priority (RFC 7540 Section 5.3)
|
||||
self.priority_weight = stream.priority_weight
|
||||
self.priority_depends_on = stream.priority_depends_on
|
||||
|
||||
def force_close(self):
|
||||
"""Force the connection to close after this request."""
|
||||
self.must_close = True
|
||||
|
||||
def should_close(self):
|
||||
"""Check if connection should close after this request.
|
||||
|
||||
HTTP/2 connections are persistent by design, but we may still
|
||||
need to close if explicitly requested.
|
||||
|
||||
Returns:
|
||||
bool: True if connection should close
|
||||
"""
|
||||
if self.must_close:
|
||||
return True
|
||||
# HTTP/2 connections are persistent, don't close by default
|
||||
return False
|
||||
|
||||
def get_header(self, name):
|
||||
"""Get a header value by name.
|
||||
|
||||
Args:
|
||||
name: Header name (case-insensitive)
|
||||
|
||||
Returns:
|
||||
str: Header value, or None if not found
|
||||
"""
|
||||
name = name.upper()
|
||||
for h_name, h_value in self.headers:
|
||||
if h_name == name:
|
||||
return h_value
|
||||
return None
|
||||
|
||||
@property
|
||||
def content_length(self):
|
||||
"""Get the Content-Length header value.
|
||||
|
||||
Returns:
|
||||
int: Content length, or None if not set
|
||||
"""
|
||||
cl = self.get_header('CONTENT-LENGTH')
|
||||
if cl is not None:
|
||||
try:
|
||||
return int(cl)
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@property
|
||||
def content_type(self):
|
||||
"""Get the Content-Type header value.
|
||||
|
||||
Returns:
|
||||
str: Content type, or None if not set
|
||||
"""
|
||||
return self.get_header('CONTENT-TYPE')
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<HTTP2Request "
|
||||
f"method={self.method} "
|
||||
f"path={self.path} "
|
||||
f"stream_id={self.stream.stream_id}>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['HTTP2Request', 'HTTP2Body']
|
||||
@@ -0,0 +1,59 @@
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""WSGI response writer for HTTP/2 streams."""
|
||||
|
||||
from gunicorn.http.wsgi import Response
|
||||
|
||||
|
||||
class HTTP2Response(Response):
|
||||
"""A WSGI Response that frames its output as HTTP/2 instead of HTTP/1.
|
||||
|
||||
Only the wire framing is overridden. Everything the WSGI protocol needs
|
||||
(``start_response``, header processing, the no-body rules for HEAD, 1xx,
|
||||
204 and 304, the Content-Length accounting in ``write()``) is inherited,
|
||||
so HTTP/2 responses obey the same rules as HTTP/1 ones rather than a
|
||||
parallel set that has to be kept in step by hand.
|
||||
"""
|
||||
|
||||
def __init__(self, req, sock, cfg, h2_conn, stream_id):
|
||||
# sock is unused: every write goes through the HTTP/2 connection.
|
||||
# The signature matches Response so wsgi.create() can build either.
|
||||
super().__init__(req, sock, cfg)
|
||||
self.h2_conn = h2_conn
|
||||
self.stream_id = stream_id
|
||||
self._stream_ended = False
|
||||
|
||||
def is_chunked(self):
|
||||
# HTTP/2 has its own framing; chunked transfer coding is forbidden
|
||||
# (RFC 9113 section 8.1).
|
||||
return False
|
||||
|
||||
def can_sendfile(self):
|
||||
# sendfile() writes raw bytes to a socket, which would bypass HTTP/2
|
||||
# framing entirely. Base Response guards this with cfg.is_ssl, which
|
||||
# happens to cover HTTP/2 over TLS but not over cleartext.
|
||||
return False
|
||||
|
||||
def send_headers(self):
|
||||
if self.headers_sent:
|
||||
return
|
||||
self.h2_conn.send_response_headers(
|
||||
self.stream_id, self.status_code, self.headers, end_stream=False
|
||||
)
|
||||
self.headers_sent = True
|
||||
|
||||
def _emit_body(self, data):
|
||||
if not data:
|
||||
return
|
||||
self.h2_conn.send_data(self.stream_id, data, end_stream=False)
|
||||
|
||||
def close(self):
|
||||
if not self.headers_sent:
|
||||
self.send_headers()
|
||||
if self._stream_ended:
|
||||
return
|
||||
self._stream_ended = True
|
||||
trailers = getattr(self, "trailers", None)
|
||||
self.h2_conn.end_stream(self.stream_id, trailers=trailers)
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
# -*- coding: utf-8 -
|
||||
#
|
||||
# This file is part of gunicorn released under the MIT license.
|
||||
# See the NOTICE for more information.
|
||||
|
||||
"""
|
||||
HTTP/2 stream state management.
|
||||
|
||||
Each HTTP/2 stream represents a single request/response exchange.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
from io import BytesIO
|
||||
|
||||
from .errors import HTTP2StreamError
|
||||
|
||||
|
||||
class StreamState(Enum):
|
||||
"""HTTP/2 stream states as defined in RFC 7540 Section 5.1."""
|
||||
|
||||
IDLE = auto()
|
||||
RESERVED_LOCAL = auto()
|
||||
RESERVED_REMOTE = auto()
|
||||
OPEN = auto()
|
||||
HALF_CLOSED_LOCAL = auto()
|
||||
HALF_CLOSED_REMOTE = auto()
|
||||
CLOSED = auto()
|
||||
|
||||
|
||||
class HTTP2Stream:
|
||||
"""Represents a single HTTP/2 stream.
|
||||
|
||||
Manages stream state, headers, and body data for a single
|
||||
request/response exchange within an HTTP/2 connection.
|
||||
"""
|
||||
|
||||
def __init__(self, stream_id, connection):
|
||||
"""Initialize an HTTP/2 stream.
|
||||
|
||||
Args:
|
||||
stream_id: The unique stream identifier (odd for client-initiated)
|
||||
connection: The parent HTTP2ServerConnection
|
||||
"""
|
||||
self.stream_id = stream_id
|
||||
self.connection = connection
|
||||
|
||||
# Stream state
|
||||
self.state = StreamState.IDLE
|
||||
|
||||
# Request data
|
||||
self.request_headers = []
|
||||
self.request_body = BytesIO()
|
||||
self.request_complete = False
|
||||
|
||||
# Response data
|
||||
self.response_started = False
|
||||
self.response_headers_sent = False
|
||||
self.response_complete = False
|
||||
|
||||
# Flow control
|
||||
self.window_size = connection.initial_window_size
|
||||
|
||||
# Request trailers
|
||||
self.trailers = None
|
||||
|
||||
# Response trailers
|
||||
self.response_trailers = None
|
||||
|
||||
# Stream priority (RFC 7540 Section 5.3)
|
||||
self.priority_weight = 16
|
||||
self.priority_depends_on = 0
|
||||
self.priority_exclusive = False
|
||||
|
||||
# Streaming body support (avoids buffering entire uploads)
|
||||
self._body_chunks = []
|
||||
self._body_event = None # Lazy-init asyncio.Event
|
||||
self._body_complete = False
|
||||
|
||||
@property
|
||||
def is_client_stream(self):
|
||||
"""Check if this is a client-initiated stream (odd stream ID)."""
|
||||
return self.stream_id % 2 == 1
|
||||
|
||||
@property
|
||||
def is_server_stream(self):
|
||||
"""Check if this is a server-initiated stream (even stream ID)."""
|
||||
return self.stream_id % 2 == 0
|
||||
|
||||
@property
|
||||
def can_receive(self):
|
||||
"""Check if this stream can receive data."""
|
||||
return self.state in (
|
||||
StreamState.OPEN,
|
||||
StreamState.HALF_CLOSED_LOCAL,
|
||||
)
|
||||
|
||||
@property
|
||||
def can_send(self):
|
||||
"""Check if this stream can send data."""
|
||||
return self.state in (
|
||||
StreamState.OPEN,
|
||||
StreamState.HALF_CLOSED_REMOTE,
|
||||
)
|
||||
|
||||
def receive_headers(self, headers, end_stream=False):
|
||||
"""Process received HEADERS frame.
|
||||
|
||||
Args:
|
||||
headers: List of (name, value) tuples
|
||||
end_stream: True if END_STREAM flag is set
|
||||
|
||||
Raises:
|
||||
HTTP2StreamError: If headers received in invalid state
|
||||
"""
|
||||
if self.state == StreamState.IDLE:
|
||||
self.state = StreamState.OPEN
|
||||
elif self.state not in (StreamState.OPEN, StreamState.HALF_CLOSED_LOCAL):
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot receive headers in state {self.state.name}"
|
||||
)
|
||||
|
||||
self.request_headers.extend(headers)
|
||||
|
||||
if end_stream:
|
||||
self._half_close_remote()
|
||||
self.request_complete = True
|
||||
|
||||
def receive_data(self, data, end_stream=False):
|
||||
"""Process received DATA frame with streaming support.
|
||||
|
||||
Args:
|
||||
data: Bytes received
|
||||
end_stream: True if END_STREAM flag is set
|
||||
|
||||
Raises:
|
||||
HTTP2StreamError: If data received in invalid state
|
||||
"""
|
||||
if not self.can_receive:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot receive data in state {self.state.name}"
|
||||
)
|
||||
|
||||
# Add to chunks queue for streaming reads
|
||||
if data:
|
||||
self._body_chunks.append(data)
|
||||
if self._body_event:
|
||||
self._body_event.set()
|
||||
|
||||
# Also write to legacy BytesIO for compatibility
|
||||
self.request_body.write(data)
|
||||
|
||||
if end_stream:
|
||||
self._half_close_remote()
|
||||
self.request_complete = True
|
||||
self._body_complete = True
|
||||
if self._body_event:
|
||||
self._body_event.set()
|
||||
|
||||
def receive_trailers(self, trailers):
|
||||
"""Process received trailing headers.
|
||||
|
||||
Args:
|
||||
trailers: List of (name, value) tuples
|
||||
"""
|
||||
if not self.can_receive:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot receive trailers in state {self.state.name}"
|
||||
)
|
||||
|
||||
self.trailers = trailers
|
||||
self._half_close_remote()
|
||||
self.request_complete = True
|
||||
|
||||
def send_headers(self, headers, end_stream=False):
|
||||
"""Mark headers as sent.
|
||||
|
||||
Args:
|
||||
headers: List of (name, value) tuples to send
|
||||
end_stream: True if this completes the response
|
||||
|
||||
Raises:
|
||||
HTTP2StreamError: If headers cannot be sent in current state
|
||||
"""
|
||||
if not self.can_send:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot send headers in state {self.state.name}"
|
||||
)
|
||||
|
||||
self.response_started = True
|
||||
self.response_headers_sent = True
|
||||
|
||||
if end_stream:
|
||||
self._half_close_local()
|
||||
self.response_complete = True
|
||||
|
||||
def send_data(self, data, end_stream=False):
|
||||
"""Mark data as sent.
|
||||
|
||||
Args:
|
||||
data: Bytes to send
|
||||
end_stream: True if this completes the response
|
||||
|
||||
Raises:
|
||||
HTTP2StreamError: If data cannot be sent in current state
|
||||
"""
|
||||
if not self.can_send:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot send data in state {self.state.name}"
|
||||
)
|
||||
|
||||
if end_stream:
|
||||
self._half_close_local()
|
||||
self.response_complete = True
|
||||
|
||||
def send_trailers(self, trailers):
|
||||
"""Mark trailers as sent and close the stream.
|
||||
|
||||
Args:
|
||||
trailers: List of (name, value) trailer tuples
|
||||
|
||||
Raises:
|
||||
HTTP2StreamError: If trailers cannot be sent in current state
|
||||
"""
|
||||
if not self.can_send:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot send trailers in state {self.state.name}"
|
||||
)
|
||||
self.response_trailers = trailers
|
||||
self._half_close_local()
|
||||
self.response_complete = True
|
||||
|
||||
def reset(self, error_code=0x8):
|
||||
"""Reset this stream with RST_STREAM.
|
||||
|
||||
Args:
|
||||
error_code: HTTP/2 error code (default: CANCEL)
|
||||
"""
|
||||
self.state = StreamState.CLOSED
|
||||
self.response_complete = True
|
||||
self.request_complete = True
|
||||
|
||||
def close(self):
|
||||
"""Close this stream normally."""
|
||||
self.state = StreamState.CLOSED
|
||||
self.response_complete = True
|
||||
self.request_complete = True
|
||||
|
||||
def update_priority(self, weight=None, depends_on=None, exclusive=None):
|
||||
"""Update stream priority from PRIORITY frame.
|
||||
|
||||
Args:
|
||||
weight: Priority weight (1-256), higher = more resources
|
||||
depends_on: Stream ID this stream depends on
|
||||
exclusive: Whether this is an exclusive dependency
|
||||
"""
|
||||
if weight is not None:
|
||||
self.priority_weight = max(1, min(256, weight))
|
||||
if depends_on is not None:
|
||||
self.priority_depends_on = depends_on
|
||||
if exclusive is not None:
|
||||
self.priority_exclusive = exclusive
|
||||
|
||||
def _half_close_local(self):
|
||||
"""Transition to half-closed (local) state."""
|
||||
if self.state == StreamState.OPEN:
|
||||
self.state = StreamState.HALF_CLOSED_LOCAL
|
||||
elif self.state == StreamState.HALF_CLOSED_REMOTE:
|
||||
self.state = StreamState.CLOSED
|
||||
else:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot half-close local in state {self.state.name}"
|
||||
)
|
||||
|
||||
def _half_close_remote(self):
|
||||
"""Transition to half-closed (remote) state."""
|
||||
if self.state == StreamState.OPEN:
|
||||
self.state = StreamState.HALF_CLOSED_REMOTE
|
||||
elif self.state == StreamState.HALF_CLOSED_LOCAL:
|
||||
self.state = StreamState.CLOSED
|
||||
else:
|
||||
raise HTTP2StreamError(
|
||||
self.stream_id,
|
||||
f"Cannot half-close remote in state {self.state.name}"
|
||||
)
|
||||
|
||||
def get_request_body(self):
|
||||
"""Get the complete request body.
|
||||
|
||||
Returns:
|
||||
bytes: The request body data
|
||||
"""
|
||||
return self.request_body.getvalue()
|
||||
|
||||
async def read_body_chunk(self):
|
||||
"""Read next body chunk asynchronously for streaming.
|
||||
|
||||
Returns:
|
||||
bytes: Next chunk of body data, or None if body is complete.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Initialize event lazily (avoids event loop issues at construction)
|
||||
if self._body_event is None:
|
||||
self._body_event = asyncio.Event()
|
||||
# If data already arrived before event existed, set it now
|
||||
# This prevents race where DATA frames arrive before first read
|
||||
if self._body_chunks or self._body_complete:
|
||||
self._body_event.set()
|
||||
|
||||
while True:
|
||||
# Return chunk if available
|
||||
if self._body_chunks:
|
||||
return self._body_chunks.pop(0)
|
||||
|
||||
# No more data expected
|
||||
if self._body_complete:
|
||||
return None
|
||||
|
||||
# Wait for more data
|
||||
self._body_event.clear()
|
||||
await self._body_event.wait()
|
||||
|
||||
def get_pseudo_headers(self):
|
||||
"""Extract HTTP/2 pseudo-headers from request headers.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of pseudo-header names to values
|
||||
(e.g., {':method': 'GET', ':path': '/'})
|
||||
"""
|
||||
pseudo = {}
|
||||
for name, value in self.request_headers:
|
||||
if name.startswith(':'):
|
||||
pseudo[name] = value
|
||||
return pseudo
|
||||
|
||||
def get_regular_headers(self):
|
||||
"""Get regular (non-pseudo) headers from request.
|
||||
|
||||
Returns:
|
||||
list: List of (name, value) tuples for regular headers
|
||||
"""
|
||||
return [
|
||||
(name, value)
|
||||
for name, value in self.request_headers
|
||||
if not name.startswith(':')
|
||||
]
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<HTTP2Stream id={self.stream_id} "
|
||||
f"state={self.state.name} "
|
||||
f"req_complete={self.request_complete} "
|
||||
f"resp_complete={self.response_complete}>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ['HTTP2Stream', 'StreamState']
|
||||
Reference in New Issue
Block a user