mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-04 11:41:32 +08:00
feat(http2): add response trailer support
This commit is contained in:
parent
655716a181
commit
0f298e4838
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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''
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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')])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user