mirror of
https://github.com/frappe/gunicorn.git
synced 2026-07-07 21:21:30 +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
|
but Gunicorn does not enforce priority-based request ordering. Priority
|
||||||
information is only present for HTTP/2 requests.
|
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
|
## Production Deployment
|
||||||
|
|
||||||
### With Nginx
|
### With Nginx
|
||||||
|
|||||||
@ -624,6 +624,7 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
response_status = 500
|
response_status = 500
|
||||||
response_headers = []
|
response_headers = []
|
||||||
response_body = b''
|
response_body = b''
|
||||||
|
response_trailers = []
|
||||||
|
|
||||||
async def receive():
|
async def receive():
|
||||||
# For HTTP/2, the body is already buffered in the stream
|
# For HTTP/2, the body is already buffered in the stream
|
||||||
@ -680,6 +681,21 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
if not more_body:
|
if not more_body:
|
||||||
response_complete = True
|
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
|
# Build environ for logging
|
||||||
environ = self._build_http2_environ(request, sockname, peername)
|
environ = self._build_http2_environ(request, sockname, peername)
|
||||||
request_start = datetime.now()
|
request_start = datetime.now()
|
||||||
@ -702,9 +718,30 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
value = value.decode("latin-1")
|
value = value.decode("latin-1")
|
||||||
headers.append((name, value))
|
headers.append((name, value))
|
||||||
|
|
||||||
await h2_conn.send_response(
|
if response_trailers:
|
||||||
stream_id, response_status, headers, response_body
|
# 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:
|
else:
|
||||||
await h2_conn.send_error(stream_id, 500, "Internal Server Error")
|
await h2_conn.send_error(stream_id, 500, "Internal Server Error")
|
||||||
response_status = 500
|
response_status = 500
|
||||||
@ -752,14 +789,16 @@ class ASGIProtocol(asyncio.Protocol):
|
|||||||
if hasattr(self.worker, 'state'):
|
if hasattr(self.worker, 'state'):
|
||||||
scope["state"] = self.worker.state
|
scope["state"] = self.worker.state
|
||||||
|
|
||||||
# Add HTTP/2 priority extension
|
# Add HTTP/2 extensions
|
||||||
|
extensions = {}
|
||||||
if hasattr(request, 'priority_weight'):
|
if hasattr(request, 'priority_weight'):
|
||||||
scope["extensions"] = {
|
extensions["http.response.priority"] = {
|
||||||
"http.response.priority": {
|
"weight": request.priority_weight,
|
||||||
"weight": request.priority_weight,
|
"depends_on": request.priority_depends_on,
|
||||||
"depends_on": request.priority_depends_on,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
# Add trailer support extension for HTTP/2
|
||||||
|
extensions["http.response.trailers"] = {}
|
||||||
|
scope["extensions"] = extensions
|
||||||
|
|
||||||
return scope
|
return scope
|
||||||
|
|
||||||
|
|||||||
@ -385,6 +385,38 @@ class AsyncHTTP2Connection:
|
|||||||
|
|
||||||
stream.send_data(data, end_stream=end_stream)
|
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):
|
async def send_error(self, stream_id, status_code, message=None):
|
||||||
"""Send an error response on a stream."""
|
"""Send an error response on a stream."""
|
||||||
body = message.encode() if message else b''
|
body = message.encode() if message else b''
|
||||||
|
|||||||
@ -430,6 +430,40 @@ class HTTP2ServerConnection:
|
|||||||
|
|
||||||
stream.send_data(data, end_stream=end_stream)
|
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):
|
def send_error(self, stream_id, status_code, message=None):
|
||||||
"""Send an error response on a stream.
|
"""Send an error response on a stream.
|
||||||
|
|
||||||
|
|||||||
@ -60,9 +60,12 @@ class HTTP2Stream:
|
|||||||
# Flow control
|
# Flow control
|
||||||
self.window_size = connection.initial_window_size
|
self.window_size = connection.initial_window_size
|
||||||
|
|
||||||
# Trailers
|
# Request trailers
|
||||||
self.trailers = None
|
self.trailers = None
|
||||||
|
|
||||||
|
# Response trailers
|
||||||
|
self.response_trailers = None
|
||||||
|
|
||||||
# Stream priority (RFC 7540 Section 5.3)
|
# Stream priority (RFC 7540 Section 5.3)
|
||||||
self.priority_weight = 16
|
self.priority_weight = 16
|
||||||
self.priority_depends_on = 0
|
self.priority_depends_on = 0
|
||||||
@ -199,6 +202,24 @@ class HTTP2Stream:
|
|||||||
self._half_close_local()
|
self._half_close_local()
|
||||||
self.response_complete = True
|
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):
|
def reset(self, error_code=0x8):
|
||||||
"""Reset this stream with RST_STREAM.
|
"""Reset this stream with RST_STREAM.
|
||||||
|
|
||||||
|
|||||||
@ -482,6 +482,15 @@ class ThreadWorker(base.Worker):
|
|||||||
|
|
||||||
environ["wsgi.early_hints"] = send_early_hints_h2
|
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
|
self.nr += 1
|
||||||
if self.nr >= self.max_requests:
|
if self.nr >= self.max_requests:
|
||||||
if self.alive:
|
if self.alive:
|
||||||
@ -503,12 +512,35 @@ class ThreadWorker(base.Worker):
|
|||||||
respiter.close()
|
respiter.close()
|
||||||
|
|
||||||
# Send response via HTTP/2
|
# Send response via HTTP/2
|
||||||
h2_conn.send_response(
|
if pending_trailers:
|
||||||
stream_id,
|
# Send headers, body, then trailers separately
|
||||||
resp.status_code,
|
# Build response headers with :status pseudo-header
|
||||||
resp.headers,
|
response_headers = [(':status', str(resp.status_code))]
|
||||||
response_body
|
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
|
request_time = datetime.now() - request_start
|
||||||
self.log.access(resp, req, environ, request_time)
|
self.log.access(resp, req, environ, request_time)
|
||||||
|
|||||||
@ -746,3 +746,75 @@ class TestASGIHTTP2Priority:
|
|||||||
|
|
||||||
# HTTP/1.1 requests should not have extensions with priority
|
# HTTP/1.1 requests should not have extensions with priority
|
||||||
assert "extensions" not in scope or "http.response.priority" not in scope.get("extensions", {})
|
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
|
# Verify body.read was called once and returned empty
|
||||||
mock_body.read.assert_called_once_with(1024)
|
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
|
# Should not raise
|
||||||
await conn.receive_data()
|
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)
|
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:
|
class TestHTTP2NotAvailable:
|
||||||
"""Test behavior when h2 is not available."""
|
"""Test behavior when h2 is not available."""
|
||||||
|
|
||||||
|
|||||||
@ -716,3 +716,77 @@ class TestStreamPriority:
|
|||||||
|
|
||||||
stream.update_priority(weight=1000)
|
stream.update_priority(weight=1000)
|
||||||
assert stream.priority_weight == 256
|
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