diff --git a/docs/content/guides/http2.md b/docs/content/guides/http2.md index 0ffda327..b703e9d0 100644 --- a/docs/content/guides/http2.md +++ b/docs/content/guides/http2.md @@ -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 diff --git a/gunicorn/asgi/protocol.py b/gunicorn/asgi/protocol.py index 4d0f544a..75bf2743 100644 --- a/gunicorn/asgi/protocol.py +++ b/gunicorn/asgi/protocol.py @@ -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 diff --git a/gunicorn/http2/async_connection.py b/gunicorn/http2/async_connection.py index d278d090..d66f0425 100644 --- a/gunicorn/http2/async_connection.py +++ b/gunicorn/http2/async_connection.py @@ -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'' diff --git a/gunicorn/http2/connection.py b/gunicorn/http2/connection.py index 279f135c..6416007c 100644 --- a/gunicorn/http2/connection.py +++ b/gunicorn/http2/connection.py @@ -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. diff --git a/gunicorn/http2/stream.py b/gunicorn/http2/stream.py index 2e7a7ab4..18ba40f4 100644 --- a/gunicorn/http2/stream.py +++ b/gunicorn/http2/stream.py @@ -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. diff --git a/gunicorn/workers/gthread.py b/gunicorn/workers/gthread.py index 20e3d60c..dcd238f9 100644 --- a/gunicorn/workers/gthread.py +++ b/gunicorn/workers/gthread.py @@ -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) diff --git a/tests/test_asgi_worker.py b/tests/test_asgi_worker.py index 0cf616a5..fdccf8b1 100644 --- a/tests/test_asgi_worker.py +++ b/tests/test_asgi_worker.py @@ -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 diff --git a/tests/test_gthread.py b/tests/test_gthread.py index b8839fa1..130e539a 100644 --- a/tests/test_gthread.py +++ b/tests/test_gthread.py @@ -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 diff --git a/tests/test_http2_async_connection.py b/tests/test_http2_async_connection.py index 8278626b..26961b47 100644 --- a/tests/test_http2_async_connection.py +++ b/tests/test_http2_async_connection.py @@ -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) diff --git a/tests/test_http2_connection.py b/tests/test_http2_connection.py index 8d325e26..529277ec 100644 --- a/tests/test_http2_connection.py +++ b/tests/test_http2_connection.py @@ -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.""" diff --git a/tests/test_http2_stream.py b/tests/test_http2_stream.py index e4bd8517..042bc0bf 100644 --- a/tests/test_http2_stream.py +++ b/tests/test_http2_stream.py @@ -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')])