Add HTTP/2 dependency and configuration

- Add optional h2 dependency for HTTP/2 support
- Add http2 module skeleton with availability check and errors
- Add HTTP/2 configuration settings (max_concurrent_streams,
  initial_window_size, max_frame_size, max_header_list_size)
- Add ALPN support to SSL context for HTTP/2 negotiation
This commit is contained in:
Benoit Chesneau 2026-01-25 16:27:25 +01:00
parent 1fe9e5816e
commit c711d9fb6f
5 changed files with 448 additions and 0 deletions

View File

@ -2395,6 +2395,180 @@ class Ciphers(Setting):
"""
# HTTP/2 Protocol Settings
# Valid protocol identifiers
VALID_HTTP_PROTOCOLS = frozenset(["h1", "h2", "h3"])
# Map protocol identifiers to ALPN protocol names
ALPN_PROTOCOL_MAP = {
"h1": "http/1.1",
"h2": "h2",
"h3": "h3", # Future: HTTP/3 over QUIC
}
def validate_http_protocols(val):
"""Validate http_protocols setting.
Accepts comma-separated list of protocol identifiers.
Valid values: h1 (HTTP/1.1), h2 (HTTP/2), h3 (HTTP/3 - future)
Order indicates preference (first = most preferred).
"""
if val is None:
return ["h1"]
if not isinstance(val, str):
raise TypeError("http_protocols must be a string")
val = val.strip()
if not val:
return ["h1"]
protocols = [p.strip().lower() for p in val.split(",") if p.strip()]
if not protocols:
return ["h1"]
# Validate each protocol
for proto in protocols:
if proto not in VALID_HTTP_PROTOCOLS:
raise ValueError(
f"Invalid protocol '{proto}'. "
f"Valid protocols: {', '.join(sorted(VALID_HTTP_PROTOCOLS))}"
)
# Check for duplicates
if len(protocols) != len(set(protocols)):
raise ValueError("Duplicate protocols specified")
return protocols
class HTTPProtocols(Setting):
name = "http_protocols"
section = "HTTP/2"
cli = ["--http-protocols"]
meta = "STRING"
validator = validate_http_protocols
default = "h1"
desc = """\
HTTP protocol versions to support (comma-separated, order = preference).
Valid protocols:
* ``h1`` - HTTP/1.1 (default)
* ``h2`` - HTTP/2 (requires TLS with ALPN)
* ``h3`` - HTTP/3 (future, not yet implemented)
Examples::
# HTTP/1.1 only (default, backward compatible)
--http-protocols=h1
# Prefer HTTP/2, fallback to HTTP/1.1
--http-protocols=h2,h1
# HTTP/2 only (reject HTTP/1.1 clients)
--http-protocols=h2
HTTP/2 requires:
* TLS (--certfile and --keyfile)
* The h2 library: ``pip install gunicorn[http2]``
* ALPN-capable TLS client
.. note::
HTTP/2 cleartext (h2c) is not supported due to security concerns
and lack of browser support.
.. versionadded:: 24.0.0
"""
class HTTP2MaxConcurrentStreams(Setting):
name = "http2_max_concurrent_streams"
section = "HTTP/2"
cli = ["--http2-max-concurrent-streams"]
meta = "INT"
validator = validate_pos_int
type = int
default = 100
desc = """\
Maximum number of concurrent HTTP/2 streams per connection.
This limits how many requests can be processed simultaneously on a
single HTTP/2 connection. Higher values allow more parallelism but
use more memory.
Default is 100, which matches common server configurations.
The HTTP/2 specification allows up to 2^31-1.
.. versionadded:: 24.0.0
"""
class HTTP2InitialWindowSize(Setting):
name = "http2_initial_window_size"
section = "HTTP/2"
cli = ["--http2-initial-window-size"]
meta = "INT"
validator = validate_pos_int
type = int
default = 65535
desc = """\
Initial HTTP/2 flow control window size in bytes.
This controls how much data can be in-flight before the receiver
sends WINDOW_UPDATE frames. Larger values can improve throughput
for large transfers but use more memory.
Default is 65535 (64KB - 1), the HTTP/2 specification default.
Maximum is 2^31-1 (2147483647).
.. versionadded:: 24.0.0
"""
class HTTP2MaxFrameSize(Setting):
name = "http2_max_frame_size"
section = "HTTP/2"
cli = ["--http2-max-frame-size"]
meta = "INT"
validator = validate_pos_int
type = int
default = 16384
desc = """\
Maximum HTTP/2 frame payload size in bytes.
This is the largest frame payload the server will accept.
Larger frames reduce framing overhead but may increase latency
for small messages.
Default is 16384 (16KB), the HTTP/2 specification minimum.
Range is 16384 to 16777215 (16MB - 1).
.. versionadded:: 24.0.0
"""
class HTTP2MaxHeaderListSize(Setting):
name = "http2_max_header_list_size"
section = "HTTP/2"
cli = ["--http2-max-header-list-size"]
meta = "INT"
validator = validate_pos_int
type = int
default = 65536
desc = """\
Maximum size of HTTP/2 header list in bytes (HPACK protection).
This limits the total size of headers after HPACK decompression.
Protects against compression bombs and excessive memory use.
Default is 65536 (64KB). Set to 0 for unlimited (not recommended).
.. versionadded:: 24.0.0
"""
class PasteGlobalConf(Setting):
name = "raw_paste_global_conf"
action = "append"

View File

@ -0,0 +1,58 @@
# -*- 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
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
__all__ = [
'is_http2_available',
'get_h2_version',
'H2_MIN_VERSION',
]

153
gunicorn/http2/errors.py Normal file
View File

@ -0,0 +1,153 @@
# -*- 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 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."""
pass
class HTTP2ConfigurationError(HTTP2Error):
"""Invalid HTTP/2 configuration."""
pass
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__ = [
'HTTP2Error',
'HTTP2ProtocolError',
'HTTP2InternalError',
'HTTP2FlowControlError',
'HTTP2SettingsTimeout',
'HTTP2StreamClosed',
'HTTP2FrameSizeError',
'HTTP2RefusedStream',
'HTTP2Cancel',
'HTTP2CompressionError',
'HTTP2ConnectError',
'HTTP2EnhanceYourCalm',
'HTTP2InadequateSecurity',
'HTTP2RequiresHTTP11',
'HTTP2StreamError',
'HTTP2ConnectionError',
'HTTP2ConfigurationError',
'HTTP2NotAvailable',
]

View File

@ -235,6 +235,35 @@ def close_sockets(listeners, unlink=True):
os.unlink(sock_name)
def _get_alpn_protocols(conf):
"""Get ALPN protocol list from configuration.
Returns list of ALPN protocol identifiers based on http_protocols setting.
Returns empty list if HTTP/2 is not configured or available.
"""
from gunicorn.config import ALPN_PROTOCOL_MAP
http_protocols = conf.http_protocols
if not http_protocols:
return []
# Only configure ALPN if h2 is in the protocol list
if "h2" not in http_protocols:
return []
# Check if h2 library is available
from gunicorn.http2 import is_http2_available
if not is_http2_available():
return []
# Map to ALPN identifiers, maintaining preference order
alpn_protocols = []
for proto in http_protocols:
if proto in ALPN_PROTOCOL_MAP:
alpn_protocols.append(ALPN_PROTOCOL_MAP[proto])
return alpn_protocols
def ssl_context(conf):
def default_ssl_context_factory():
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH, cafile=conf.ca_certs)
@ -242,6 +271,12 @@ def ssl_context(conf):
context.verify_mode = conf.cert_reqs
if conf.ciphers:
context.set_ciphers(conf.ciphers)
# Configure ALPN for HTTP/2 if enabled
alpn_protocols = _get_alpn_protocols(conf)
if alpn_protocols:
context.set_alpn_protocols(alpn_protocols)
return context
return conf.ssl_context(conf, default_ssl_context_factory)
@ -252,3 +287,29 @@ def ssl_wrap_socket(sock, conf):
server_side=True,
suppress_ragged_eofs=conf.suppress_ragged_eofs,
do_handshake_on_connect=conf.do_handshake_on_connect)
def get_negotiated_protocol(ssl_socket):
"""Get the negotiated ALPN protocol from an SSL socket.
Returns:
str: The negotiated protocol name ('h2', 'http/1.1', etc.)
or None if no protocol was negotiated.
"""
if not isinstance(ssl_socket, ssl.SSLSocket):
return None
try:
return ssl_socket.selected_alpn_protocol()
except (AttributeError, ssl.SSLError):
return None
def is_http2_negotiated(ssl_socket):
"""Check if HTTP/2 was negotiated on an SSL socket.
Returns:
bool: True if HTTP/2 was negotiated via ALPN.
"""
protocol = get_negotiated_protocol(ssl_socket)
return protocol == "h2"

View File

@ -52,9 +52,11 @@ eventlet = ["eventlet>=0.40.3"]
tornado = ["tornado>=6.5.0"]
gthread = []
setproctitle = ["setproctitle"]
http2 = ["h2>=4.1.0"]
testing = [
"gevent>=24.10.1",
"eventlet>=0.40.3",
"h2>=4.1.0",
"coverage",
"pytest",
"pytest-cov",