feat(http2): add response trailer support

This commit is contained in:
Benoit Chesneau 2026-01-27 12:33:12 +01:00
parent 655716a181
commit 0f298e4838
11 changed files with 692 additions and 16 deletions

View File

@ -253,6 +253,87 @@ async def app(scope, receive, send):
but Gunicorn does not enforce priority-based request ordering. Priority
information is only present for HTTP/2 requests.
## Response Trailers
HTTP/2 supports trailing headers (trailers) sent after the response body.
This is commonly used for gRPC status codes, checksums, and timing information.
### WSGI Applications
For WSGI applications, use the `gunicorn.http2.send_trailers` callback in the environ:
```python
def app(environ, start_response):
# Get trailer callback (HTTP/2 only)
send_trailers = environ.get('gunicorn.http2.send_trailers')
# Announce trailers in response headers
headers = [
('Content-Type', 'application/grpc'),
('Trailer', 'grpc-status, grpc-message'),
]
start_response('200 OK', headers)
# Yield response body
yield b'response data'
# Send trailers after body (if available)
if send_trailers:
send_trailers([
('grpc-status', '0'),
('grpc-message', 'OK'),
])
```
### ASGI Applications
For ASGI applications, use the `http.response.trailers` extension:
```python
async def app(scope, receive, send):
# Send response with trailers flag
await send({
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/grpc"),
(b"trailer", b"grpc-status, grpc-message"),
],
})
# Send body
await send({
"type": "http.response.body",
"body": b"response data",
"more_body": False,
})
# Send trailers (HTTP/2 only)
if "http.response.trailers" in scope.get("extensions", {}):
await send({
"type": "http.response.trailers",
"headers": [
(b"grpc-status", b"0"),
(b"grpc-message", b"OK"),
],
})
```
### Trailer Rules (RFC 7540)
- Trailers MUST NOT include pseudo-headers (`:status`, `:path`, etc.)
- Announce trailers using the `Trailer` response header
- Trailers are only available in HTTP/2 (HTTP/1.1 chunked encoding not supported)
### Common Use Cases
| Use Case | Trailer Headers |
|----------|-----------------|
| gRPC | `grpc-status`, `grpc-message` |
| Checksums | `Content-MD5`, `Digest` |
| Timing | `Server-Timing` |
| Signatures | `Signature` |
## Production Deployment
### With Nginx

View File

@ -624,6 +624,7 @@ class ASGIProtocol(asyncio.Protocol):
response_status = 500
response_headers = []
response_body = b''
response_trailers = []
async def receive():
# For HTTP/2, the body is already buffered in the stream
@ -680,6 +681,21 @@ class ASGIProtocol(asyncio.Protocol):
if not more_body:
response_complete = True
elif msg_type == "http.response.trailers":
if not response_complete:
exc_to_raise = RuntimeError("Cannot send trailers before body complete")
return
trailer_headers = message.get("headers", [])
# Convert to list of tuples with string values
trailers = []
for name, value in trailer_headers:
if isinstance(name, bytes):
name = name.decode("latin-1")
if isinstance(value, bytes):
value = value.decode("latin-1")
trailers.append((name, value))
response_trailers.extend(trailers)
# Build environ for logging
environ = self._build_http2_environ(request, sockname, peername)
request_start = datetime.now()
@ -702,9 +718,30 @@ class ASGIProtocol(asyncio.Protocol):
value = value.decode("latin-1")
headers.append((name, value))
await h2_conn.send_response(
stream_id, response_status, headers, response_body
)
if response_trailers:
# Send headers, body, then trailers separately
response_hdrs = [(':status', str(response_status))]
for name, value in headers:
response_hdrs.append((name.lower(), str(value)))
# Send headers without ending stream
h2_conn.h2_conn.send_headers(stream_id, response_hdrs, end_stream=False)
stream = h2_conn.streams[stream_id]
stream.send_headers(response_hdrs, end_stream=False)
await h2_conn._send_pending_data()
# Send body without ending stream
if response_body:
h2_conn.h2_conn.send_data(stream_id, response_body, end_stream=False)
stream.send_data(response_body, end_stream=False)
await h2_conn._send_pending_data()
# Send trailers (ends stream)
await h2_conn.send_trailers(stream_id, response_trailers)
else:
await h2_conn.send_response(
stream_id, response_status, headers, response_body
)
else:
await h2_conn.send_error(stream_id, 500, "Internal Server Error")
response_status = 500
@ -752,14 +789,16 @@ class ASGIProtocol(asyncio.Protocol):
if hasattr(self.worker, 'state'):
scope["state"] = self.worker.state
# Add HTTP/2 priority extension
# Add HTTP/2 extensions
extensions = {}
if hasattr(request, 'priority_weight'):
scope["extensions"] = {
"http.response.priority": {
"weight": request.priority_weight,
"depends_on": request.priority_depends_on,
}
extensions["http.response.priority"] = {
"weight": request.priority_weight,
"depends_on": request.priority_depends_on,
}
# Add trailer support extension for HTTP/2
extensions["http.response.trailers"] = {}
scope["extensions"] = extensions
return scope

View File

@ -385,6 +385,38 @@ class AsyncHTTP2Connection:
stream.send_data(data, end_stream=end_stream)
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
"""
stream = self.streams.get(stream_id)
if stream is None:
raise HTTP2Error(f"Stream {stream_id} not found")
if not stream.response_headers_sent:
raise HTTP2Error("Must send headers before trailers")
# 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)))
# 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()
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''

View File

@ -430,6 +430,40 @@ class HTTP2ServerConnection:
stream.send_data(data, end_stream=end_stream)
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
"""
from .errors import HTTP2Error
stream = self.streams.get(stream_id)
if stream is None:
raise HTTP2Error(f"Stream {stream_id} not found")
if not stream.response_headers_sent:
raise HTTP2Error("Must send headers before trailers")
# 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)))
# 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()
def send_error(self, stream_id, status_code, message=None):
"""Send an error response on a stream.

View File

@ -60,9 +60,12 @@ class HTTP2Stream:
# Flow control
self.window_size = connection.initial_window_size
# Trailers
# 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
@ -199,6 +202,24 @@ class HTTP2Stream:
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.

View File

@ -482,6 +482,15 @@ class ThreadWorker(base.Worker):
environ["wsgi.early_hints"] = send_early_hints_h2
# Add HTTP/2 trailer support
pending_trailers = []
def send_trailers_h2(trailers):
"""Queue trailers to be sent after response body."""
pending_trailers.extend(trailers)
environ["gunicorn.http2.send_trailers"] = send_trailers_h2
self.nr += 1
if self.nr >= self.max_requests:
if self.alive:
@ -503,12 +512,35 @@ class ThreadWorker(base.Worker):
respiter.close()
# Send response via HTTP/2
h2_conn.send_response(
stream_id,
resp.status_code,
resp.headers,
response_body
)
if pending_trailers:
# Send headers, body, then trailers separately
# Build response headers with :status pseudo-header
response_headers = [(':status', str(resp.status_code))]
for name, value in resp.headers:
response_headers.append((name.lower(), str(value)))
# Send headers without ending stream
h2_conn.h2_conn.send_headers(stream_id, response_headers, end_stream=False)
stream = h2_conn.streams[stream_id]
stream.send_headers(response_headers, end_stream=False)
h2_conn._send_pending_data()
# Send body without ending stream
if response_body:
h2_conn.h2_conn.send_data(stream_id, response_body, end_stream=False)
stream.send_data(response_body, end_stream=False)
h2_conn._send_pending_data()
# Send trailers (ends stream)
h2_conn.send_trailers(stream_id, pending_trailers)
else:
# No trailers, use standard response
h2_conn.send_response(
stream_id,
resp.status_code,
resp.headers,
response_body
)
request_time = datetime.now() - request_start
self.log.access(resp, req, environ, request_time)

View File

@ -746,3 +746,75 @@ class TestASGIHTTP2Priority:
# HTTP/1.1 requests should not have extensions with priority
assert "extensions" not in scope or "http.response.priority" not in scope.get("extensions", {})
# ============================================================================
# HTTP/2 Trailers Tests
# ============================================================================
class TestASGIHTTP2Trailers:
"""Test HTTP/2 response trailer support in ASGI."""
def test_http2_trailers_extension_in_scope(self):
"""Test that HTTP/2 scope includes http.response.trailers extension."""
from gunicorn.asgi.protocol import ASGIProtocol
worker = mock.Mock()
worker.cfg = Config()
worker.log = mock.Mock()
worker.asgi = mock.Mock()
protocol = ASGIProtocol(worker)
# Create mock HTTP/2 request
request = mock.Mock()
request.method = "GET"
request.path = "/api"
request.query = ""
request.uri = "/api"
request.scheme = "https"
request.headers = [("HOST", "localhost")]
request.priority_weight = 16
request.priority_depends_on = 0
scope = protocol._build_http2_scope(
request,
("127.0.0.1", 8443),
("127.0.0.1", 12345),
)
# HTTP/2 scope should have trailers extension
assert "extensions" in scope
assert "http.response.trailers" in scope["extensions"]
def test_http2_scope_has_both_priority_and_trailers(self):
"""Test that HTTP/2 scope includes both priority and trailers extensions."""
from gunicorn.asgi.protocol import ASGIProtocol
worker = mock.Mock()
worker.cfg = Config()
worker.log = mock.Mock()
worker.asgi = mock.Mock()
protocol = ASGIProtocol(worker)
request = mock.Mock()
request.method = "POST"
request.path = "/grpc"
request.query = ""
request.uri = "/grpc"
request.scheme = "https"
request.headers = [("HOST", "localhost"), ("CONTENT-TYPE", "application/grpc")]
request.priority_weight = 128
request.priority_depends_on = 1
scope = protocol._build_http2_scope(
request,
("127.0.0.1", 8443),
("127.0.0.1", 54321),
)
extensions = scope.get("extensions", {})
assert "http.response.priority" in extensions
assert "http.response.trailers" in extensions
assert extensions["http.response.priority"]["weight"] == 128

View File

@ -1514,3 +1514,53 @@ class TestFinishBodySSL:
# Verify body.read was called once and returned empty
mock_body.read.assert_called_once_with(1024)
class TestHTTP2TrailerCallback:
"""Tests for HTTP/2 response trailer callback."""
def test_trailer_callback_stores_trailers(self):
"""Test that the trailer callback stores trailers for later sending."""
# Simulate the trailer callback pattern used in handle_http2_request
pending_trailers = []
def send_trailers_h2(trailers):
"""Queue trailers to be sent after response body."""
pending_trailers.extend(trailers)
# Call the callback with trailers
send_trailers_h2([('grpc-status', '0'), ('grpc-message', 'OK')])
assert len(pending_trailers) == 2
assert pending_trailers[0] == ('grpc-status', '0')
assert pending_trailers[1] == ('grpc-message', 'OK')
def test_trailer_callback_multiple_calls(self):
"""Test that multiple calls to trailer callback accumulate trailers."""
pending_trailers = []
def send_trailers_h2(trailers):
pending_trailers.extend(trailers)
# Call multiple times
send_trailers_h2([('grpc-status', '0')])
send_trailers_h2([('grpc-message', 'OK')])
send_trailers_h2([('server-timing', 'total;dur=100')])
assert len(pending_trailers) == 3
assert pending_trailers == [
('grpc-status', '0'),
('grpc-message', 'OK'),
('server-timing', 'total;dur=100'),
]
def test_trailer_callback_empty_list(self):
"""Test that empty trailer list is handled correctly."""
pending_trailers = []
def send_trailers_h2(trailers):
pending_trailers.extend(trailers)
send_trailers_h2([])
assert len(pending_trailers) == 0

View File

@ -638,3 +638,119 @@ class TestAsyncHTTP2ConnectionPriority:
# Should not raise
await conn.receive_data()
class TestAsyncHTTP2ConnectionTrailers:
"""Test async HTTP/2 response trailer support."""
@pytest.mark.asyncio
async def test_send_trailers_after_headers_and_body(self):
"""Test sending trailers after response headers and body."""
from gunicorn.http2.async_connection import AsyncHTTP2Connection
cfg = MockConfig()
reader = MockAsyncReader()
writer = MockAsyncWriter()
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
# Create a client connection
client_conn = create_client_connection()
client_data = client_conn.data_to_send()
reader.set_data(client_data)
await conn.initiate_connection()
await conn.receive_data()
writer.clear()
# Send a request
client_conn.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'localhost'),
], end_stream=True)
reader.set_data(client_conn.data_to_send())
await conn.receive_data()
# Manually send headers without ending stream (for trailer support)
stream = conn.streams[1]
response_headers = [(':status', '200'), ('content-type', 'text/plain')]
conn.h2_conn.send_headers(1, response_headers, end_stream=False)
stream.send_headers(response_headers, end_stream=False)
await conn._send_pending_data()
# Send body without ending stream
conn.h2_conn.send_data(1, b'Hello World', end_stream=False)
stream.send_data(b'Hello World', end_stream=False)
await conn._send_pending_data()
# Send trailers
trailers = [('grpc-status', '0'), ('grpc-message', 'OK')]
await conn.send_trailers(1, trailers)
# Verify stream is closed
assert stream.response_complete is True
assert stream.response_trailers == [('grpc-status', '0'), ('grpc-message', 'OK')]
@pytest.mark.asyncio
async def test_send_trailers_pseudo_header_raises(self):
"""Test that pseudo-headers in trailers raise error."""
from gunicorn.http2.async_connection import AsyncHTTP2Connection
cfg = MockConfig()
reader = MockAsyncReader()
writer = MockAsyncWriter()
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
client_conn = create_client_connection()
reader.set_data(client_conn.data_to_send())
await conn.initiate_connection()
await conn.receive_data()
# Send a request
client_conn.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'localhost'),
], end_stream=True)
reader.set_data(client_conn.data_to_send())
await conn.receive_data()
# Send response
await conn.send_response(1, 200, [('content-type', 'text/plain')], None)
# Try to send trailers with pseudo-header
with pytest.raises(HTTP2Error) as exc_info:
await conn.send_trailers(1, [(':status', '200')])
assert "Pseudo-header" in str(exc_info.value)
@pytest.mark.asyncio
async def test_send_trailers_without_headers_raises(self):
"""Test that sending trailers without headers raises error."""
from gunicorn.http2.async_connection import AsyncHTTP2Connection
cfg = MockConfig()
reader = MockAsyncReader()
writer = MockAsyncWriter()
conn = AsyncHTTP2Connection(cfg, reader, writer, ('127.0.0.1', 12345))
client_conn = create_client_connection()
reader.set_data(client_conn.data_to_send())
await conn.initiate_connection()
await conn.receive_data()
# Send a request
client_conn.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'localhost'),
], end_stream=True)
reader.set_data(client_conn.data_to_send())
await conn.receive_data()
# Try to send trailers without sending headers first
with pytest.raises(HTTP2Error) as exc_info:
await conn.send_trailers(1, [('trailer', 'value')])
assert "Must send headers before trailers" in str(exc_info.value)

View File

@ -639,6 +639,131 @@ class TestHTTP2ServerConnectionPriority:
conn.receive_data(priority_data)
class TestHTTP2ServerConnectionTrailers:
"""Test HTTP/2 response trailer support."""
def test_send_trailers_after_headers_and_body(self):
"""Test sending trailers after response headers and body."""
from gunicorn.http2.connection import HTTP2ServerConnection
cfg = MockConfig()
sock = MockSocket()
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
conn.initiate_connection()
# Create a client connection
client_conn = create_client_connection()
client_data = client_conn.data_to_send()
conn.receive_data(client_data)
sock._sent = bytearray()
# Send a request
client_conn.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'localhost'),
], end_stream=True)
request_data = client_conn.data_to_send()
conn.receive_data(request_data)
# Manually send headers without ending stream (for trailer support)
stream = conn.streams[1]
response_headers = [(':status', '200'), ('content-type', 'text/plain')]
conn.h2_conn.send_headers(1, response_headers, end_stream=False)
stream.send_headers(response_headers, end_stream=False)
conn._send_pending_data()
# Send body without ending stream
conn.h2_conn.send_data(1, b'Hello World', end_stream=False)
stream.send_data(b'Hello World', end_stream=False)
conn._send_pending_data()
# Send trailers
trailers = [('grpc-status', '0'), ('grpc-message', 'OK')]
conn.send_trailers(1, trailers)
# Verify stream is closed
assert stream.response_complete is True
assert stream.response_trailers == [('grpc-status', '0'), ('grpc-message', 'OK')]
def test_send_trailers_pseudo_header_raises(self):
"""Test that pseudo-headers in trailers raise error."""
from gunicorn.http2.connection import HTTP2ServerConnection
from gunicorn.http2.errors import HTTP2Error
cfg = MockConfig()
sock = MockSocket()
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
conn.initiate_connection()
client_conn = create_client_connection()
client_data = client_conn.data_to_send()
conn.receive_data(client_data)
# Send a request
client_conn.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'localhost'),
], end_stream=True)
conn.receive_data(client_conn.data_to_send())
# Send response
conn.send_response(1, 200, [('content-type', 'text/plain')], None)
# Try to send trailers with pseudo-header
with pytest.raises(HTTP2Error) as exc_info:
conn.send_trailers(1, [(':status', '200')])
assert "Pseudo-header" in str(exc_info.value)
def test_send_trailers_without_headers_raises(self):
"""Test that sending trailers without headers raises error."""
from gunicorn.http2.connection import HTTP2ServerConnection
from gunicorn.http2.errors import HTTP2Error
cfg = MockConfig()
sock = MockSocket()
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
conn.initiate_connection()
client_conn = create_client_connection()
client_data = client_conn.data_to_send()
conn.receive_data(client_data)
# Send a request
client_conn.send_headers(1, [
(':method', 'GET'),
(':path', '/'),
(':scheme', 'https'),
(':authority', 'localhost'),
], end_stream=True)
conn.receive_data(client_conn.data_to_send())
# Try to send trailers without sending headers first
with pytest.raises(HTTP2Error) as exc_info:
conn.send_trailers(1, [('trailer', 'value')])
assert "Must send headers before trailers" in str(exc_info.value)
def test_send_trailers_nonexistent_stream_raises(self):
"""Test that sending trailers on nonexistent stream raises error."""
from gunicorn.http2.connection import HTTP2ServerConnection
from gunicorn.http2.errors import HTTP2Error
cfg = MockConfig()
sock = MockSocket()
conn = HTTP2ServerConnection(cfg, sock, ('127.0.0.1', 12345))
conn.initiate_connection()
client_conn = create_client_connection()
conn.receive_data(client_conn.data_to_send())
with pytest.raises(HTTP2Error) as exc_info:
conn.send_trailers(99, [('trailer', 'value')])
assert "Stream 99 not found" in str(exc_info.value)
class TestHTTP2NotAvailable:
"""Test behavior when h2 is not available."""

View File

@ -716,3 +716,77 @@ class TestStreamPriority:
stream.update_priority(weight=1000)
assert stream.priority_weight == 256
class TestStreamResponseTrailers:
"""Test response trailer support."""
def test_response_trailers_default_none(self):
"""Test that response_trailers defaults to None."""
conn = MockConnection()
stream = HTTP2Stream(stream_id=1, connection=conn)
assert stream.response_trailers is None
def test_send_trailers_in_open_state(self):
"""Test sending trailers in OPEN state."""
conn = MockConnection()
stream = HTTP2Stream(stream_id=1, connection=conn)
# Open the stream
stream.receive_headers([(':method', 'GET'), (':path', '/')], end_stream=True)
assert stream.state == StreamState.HALF_CLOSED_REMOTE
# Send response headers
stream.send_headers([(':status', '200')], end_stream=False)
# Send trailers
trailers = [('grpc-status', '0'), ('grpc-message', 'OK')]
stream.send_trailers(trailers)
assert stream.response_trailers == trailers
assert stream.state == StreamState.CLOSED
assert stream.response_complete is True
def test_send_trailers_after_body(self):
"""Test sending trailers after response body."""
conn = MockConnection()
stream = HTTP2Stream(stream_id=1, connection=conn)
# Open the stream
stream.receive_headers([(':method', 'POST'), (':path', '/api')], end_stream=False)
stream.receive_data(b'request body', end_stream=True)
# Send response
stream.send_headers([(':status', '200')], end_stream=False)
stream.send_data(b'response body', end_stream=False)
# Send trailers
trailers = [('content-md5', 'abc123')]
stream.send_trailers(trailers)
assert stream.response_trailers == trailers
assert stream.state == StreamState.CLOSED
def test_send_trailers_closes_stream(self):
"""Test that trailers close the stream."""
conn = MockConnection()
stream = HTTP2Stream(stream_id=1, connection=conn)
stream.receive_headers([(':method', 'GET'), (':path', '/')], end_stream=True)
stream.send_headers([(':status', '200')], end_stream=False)
assert stream.can_send is True
stream.send_trailers([('trailer', 'value')])
assert stream.can_send is False
assert stream.response_complete is True
def test_send_trailers_invalid_state_raises(self):
"""Test that sending trailers in invalid state raises error."""
conn = MockConnection()
stream = HTTP2Stream(stream_id=1, connection=conn)
# Stream is IDLE, cannot send trailers
with pytest.raises(HTTP2StreamError):
stream.send_trailers([('trailer', 'value')])