Large HTTP/2 Header Frames
10/19/2024
3 minutes
In a previous post I explored how HTTP2 handles header frames in .NET 8. In this post I explore how it is planned to handle larger HTTP2 response header values.
Recap
RFC7540 describes the HTTP/2 protocol's related details.
In HTTP/2 a request-response pair is serialized in a stream.
A stream consists of message frames.
Frames have a type, frame header, a given size and corresponding data. Frames are associated with a given stream with the stream ID.
HTTP/2 requests start with HEADER frame. A header frame may be followed by CONTINUATION frames containing further headers.
The HTTP/2 request headers HPack encoded and split into HEADER and CONTINUATION frames.
In ASP.NET Core's Kestrel
HPackHeaderWriter
static class writes the headers.HPackHeaderWriter
iterates over the headers/trailers (except for the response status header which is written separately) using theHttp2FrameWriter
types.
Http2FrameWriter
creates the buffer which it passes as aSpan<byte>
to the header writer. The default size of the buffer is 16K, but it can be updated via Kestrel's option by setting theHttp2Limits
's MaxFrameSize property. Clients can also influence the frame size by sending a value in the SETTING frame.
Planned changes
HPackHeaderWriter
can return 3 types of responses:all headers written (
Done
)there are more headers to write (
MoreHeaders
)could not write any header because the provided buffer is too small (
BufferToSmall
)
Http2FrameWriter
understands these returned values. Its behavior remains when a header can be written or more headers can be written as before. By default a frame sized buffer is allocated by every writer at objection construction (each connection creates a newHttp2FrameWriter
). In thee cases, the buffer fits into a single frame, hence the buffer can be directly flushed to output in HEADER and following CONTINUATION frames.When
BufferToSmall
response is returned,Http2FrameWriter
tries to rent a large buffer fromArrayPool<byte>.Shared
. When the buffer is too small it retries by doubling the requested buffer size.Writing the response headers and trailers consists of 2 actions:
First, writing the initial HEADER frame. In this case writing to the buffer always succeeds with
MoreHeaders
orDone
because the status code is always written. This is handled as a fast-path. Notice, that when writing the trailers,HPackHeaderWriter
can returnBufferToSmall
even for the first frame in the case the trailers collection only contains a single large value.
Once the initial frame is written to a buffer (and flushed to the output), the remaining values are written by the
FinishWritingHeadersUnsynchronized
method.
FinishWritingHeadersUnsynchronized
increasing the temporary buffer size:
while (writeResult != HPackHeaderWriter.HeaderWriteResult.Done) { writeResult = HPackHeaderWriter.ContinueEncodeHeaders(_hpackEncoder, _headersEnumerator, buffer, out payloadLength); if (writeResult == HPackHeaderWriter.HeaderWriteResult.BufferTooSmall) { // Return the array that was rented in the previous iteration of the loop. if (largeHeaderBuffer != null) { ArrayPool<byte>.Shared.Return(largeHeaderBuffer); // Increases the buffer size 👇 Avoiding overflow. _headersEncodingLargeBufferSize = checked(_headersEncodingLargeBufferSize * HeaderBufferSizeMultiplier); } // Rents a large buffer 👇 largeHeaderBuffer = ArrayPool<byte>.Shared.Rent(_headersEncodingLargeBufferSize); buffer = largeHeaderBuffer.AsSpan(0, _headersEncodingLargeBufferSize); } else { // Split a large buffer into frames 👇 // In case of Done or MoreHeaders: write to output. SplitHeaderAcrossFrames(streamId, buffer[..payloadLength], endOfHeaders: writeResult == HPackHeaderWriter.HeaderWriteResult.Done, isFramePrepared: false); } } if (largeHeaderBuffer != null) { ArrayPool<byte>.Shared.Return(largeHeaderBuffer); }
When the headers are successfully written into the buffer,
SplitHeaderAcrossFrames
splits this buffer to_maxFrameSize
sized frames. This method also prepares the CONTINUATION frames.