Little Known ASP.NET Core Features - HTTP Trailers

Every ASP.NET Core release is accompanied by a number of blog posts, demos, and videos showcasing the most important new features. But, at the same time, in every release, there are small features that nobody talks about (usually because it has a narrow usage). One such feature is HTTP trailers. Despite that support for them has been introduced in ASP.NET Core 2.2, you won't find too many mentions about it.

What Are Trailers?

Trailers are part of the HTTP standard since 1.1, yet they've never truly become popular. The idea behind them is quite interesting - to allow sending headers after the body has already been sent. The problem is implementation. In HTTP/1.1 trailers need to be sent after the last chunk of the chunked-encoded response. That's what prevented their adoption. In HTTP/2 things have changed. In HTTP/2 there is an option to send one optional HEADER frame after the last DATA frame. This makes trailers implementation a lot less problematic.

Trailers Support in ASP.NET Core

As I've already mentioned, the support for trailers in ASP.NET Core has been introduced in version 2.2 - together with support for HTTP/2. In fact, trailers in ASP.NET Core are supported only for HTTP/2 (which makes perfect sense in the context of the introduction above). How to use the API? The best, as usual, is to show it through an example. There is one modern Web API, which explicitly mentions the possibility of using trailers - Server Timing API. As I've already published a post about Server Timing API in ASP.NET Core, trying to add trailers support to that implementation might be a very good example.

The main challenge of using Server Timing API is gathering the timing information before the value for the Server-Timing response header has to be set. The solution I've used to give as much time as possible for that was setting the header value in HttpResponse.OnStarting.

public class ServerTimingMiddleware
{
    ...

    public Task Invoke(HttpContext context, IServerTiming serverTiming)
    {
        ...

        return HandleServerTimingAsync(context, serverTiming);
    }

    private async Task HandleServerTimingAsync(HttpContext context, IServerTiming serverTiming)
    {
        context.Response.OnStarting(() => {
            if (serverTiming.Metrics.Count > 0)
            {
                context.ResponseSetResponseHeader(
                    "Server-Timing",
                    new ServerTimingHeaderValue(serverTiming.Metrics).ToString());
            }

            return _completedTask;
        });

        return _next(context);
    }
}

That's the last possible moment before response headers have to be sent to the client, but it's still quite early. There might be many interesting things happening while the body of the response is being generated. Providing timings for those operations is possible only with trailers.

The first step to using trailers in ASP.NET Core is checking if they are supported for the current response (which generally means that HTTP/2 is being used). If trailers aren't supported, the old logic is still the best thing that can be done.

public class ServerTimingMiddleware
{
    ...

    private async Task HandleServerTimingAsync(HttpContext context, IServerTiming serverTiming)
    {
        if (context.Response.SupportsTrailers())
        {
            await HandleServerTimingAsTrailerHeaderAsync(context, serverTiming);
        }
        else
        {
            await HandleServerTimingAsResponseHeaderAsync(context, serverTiming);
        }
    }

    private Task HandleServerTimingAsResponseHeaderAsync(HttpContext context,
        IServerTiming serverTiming)
    {
        context.Response.OnStarting(() => {
            if (serverTiming.Metrics.Count > 0)
            {
                context.ResponseSetResponseHeader(
                    "Server-Timing",
                    new ServerTimingHeaderValue(serverTiming.Metrics).ToString());
            }

            return _completedTask;
        });

        return _next(context);
    }
}

Sending a trailer happens in two steps. Before the actual value can be sent, the trailer must be declared. From a protocol perspective, this means providing the header name as part of Trailer response header. This means that HttpResponse.DeclareTrailer must be called before the regular headers have to been sent to the client. The value for the trailer can be provided by calling HttpResponse.ResponseAppendTrailer at a later time.

public class ServerTimingMiddleware
{
    ...

    private async Task HandleServerTimingAsTrailerHeaderAsync(HttpContext context,
        IServerTiming serverTiming)
    {
        context.Response.DeclareTrailer("Server-Timing");

        await _next(context);

        context.Response.ResponseAppendTrailer(
            "Server-Timing",
            new ServerTimingHeaderValue(serverTiming.Metrics).ToString());
    }

    ...
}

This is the correct implementation. Unfortunately, if you would test it hoping to see timings in a browser dev tools, it most likely wouldn't work...

Trailers Support in Browsers

If you would take a look at support table for trailers, you would see that all major browsers support it. The truth is, that they only "technically" support them - as a result of supporting HTTP/2. They will ignore any provided values. The exception is Firefox, which has implemented support only for the Server-Timing trailer.

The problem with this situation is that the above implementation will make use of trailers in Firefox, but would result in the absence of functionality in other browsers. Luckily Firefox implementation follows the requirement of providing information that trailers are supported through TE request header. Based on this header, a check can be implemented.

internal static class HttpRequestHeadersExtensions
{
    public static bool AllowsTrailers(this HttpRequest request)
    {
        return request.Headers.ContainsKey("TE")
            && request.Headers["TE"].Contains("trailers");
    }
}

Adding this check to the middleware will allow us to benefit from trailers when possible and fall back to the old implementation in all other cases.

public class ServerTimingMiddleware
{
    ...

    private async Task HandleServerTimingAsync(HttpContext context, IServerTiming serverTiming)
    {
        if (context.Request.AllowsTrailers() && context.Response.SupportsTrailers())
        {
            await HandleServerTimingAsTrailerHeaderAsync(context, serverTiming);
        }
        else
        {
            await HandleServerTimingAsResponseHeaderAsync(context, serverTiming);
        }
    }

    ...
}

Trailers Support in HttpClient

Since .NET Core 3.0, trailers are also supported in HttpClient. To make a request similar to the one from Firefox it is enough to set the default protocol and add TE to default request headers.

using (HttpClient httpClient = new HttpClient())
{
    httpClient.DefaultRequestVersion = HttpVersion.Version20;
    httpClient.DefaultRequestHeaders.Add("TE", "trailers");

    ...
}

This configuration isn't perfect. If HttpClient fails to negotiate HTTP/2 it will use HTTP/1.1, but the TE header will still be sent. That shouldn't cause an issue and it will certainly work with the sample server above. Values will be available through HttpResponseMessage.TrailingHeaders property.

using (HttpClient httpClient = new HttpClient())
{
    ...

    HttpResponseMessage response = await httpClient.GetAsync(...);

    string serverTiming = null;
    if (response.TrailingHeaders.TryGetValues("Server-Timing",
        out IEnumerable<string> serverTimingTrailingHeaderValues))
    {
        serverTiming = String.Join(',', serverTimingTrailingHeaderValues);
    }
    else if (response.Headers.TryGetValues("Server-Timing",
        out IEnumerable<string> serverTimingHeaderValues))
    {
        serverTiming = String.Join(',', serverTimingHeaderValues);
    }
}

The HttpClient doesn't have any kind of limitations when it comes to headers supported as trailers. This means, that in a case when you control both ends, you have an option to use trailers for additional diagnostic or whatever you'll find fitting.