.NET & C# Development · Lesson 73 of 92

Delegating Handlers — Propagate Auth Tokens Automatically

What Is a DelegatingHandler?

DelegatingHandler sits in the HttpClient pipeline between your code and the actual HTTP socket. It can inspect and modify requests before they go out, and responses when they come back — exactly like ASP.NET Core middleware, but for outbound calls.

Your Code
    │
    ▼
DelegatingHandler 1  (e.g. auth)
    │
    ▼
DelegatingHandler 2  (e.g. logging)
    │
    ▼
DelegatingHandler 3  (e.g. correlation ID)
    │
    ▼
HttpClientHandler   (actual socket)
    │
    ▼
Network

Auth Header Handler — Bearer Token

The most common use case: attach a Bearer token from a token service without touching every call site.

C#
public interface ITokenService
{
    Task<string> GetTokenAsync(string scope, CancellationToken ct = default);
}

public class BearerTokenHandler(ITokenService tokenService) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var token = await tokenService.GetTokenAsync("payment-api", ct);

        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        return await base.SendAsync(request, ct);
    }
}

If your token service caches tokens and handles refresh, this handler stays simple. The token logic lives in one place.


Handling 401 — Retry With Fresh Token

C#
public class BearerTokenHandler(ITokenService tokenService) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var token = await tokenService.GetTokenAsync("payment-api", ct);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var response = await base.SendAsync(request, ct);

        if (response.StatusCode == HttpStatusCode.Unauthorized)
        {
            // Token may have been revoked — force a refresh
            var freshToken = await tokenService.GetFreshTokenAsync("payment-api", ct);
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", freshToken);

            // Clone the request — HttpRequestMessage can only be sent once
            var retryRequest = await CloneRequestAsync(request, ct);
            response.Dispose();
            response = await base.SendAsync(retryRequest, ct);
        }

        return response;
    }

    private static async Task<HttpRequestMessage> CloneRequestAsync(
        HttpRequestMessage original, CancellationToken ct)
    {
        var clone = new HttpRequestMessage(original.Method, original.RequestUri);

        foreach (var header in original.Headers)
            clone.Headers.TryAddWithoutValidation(header.Key, header.Value);

        if (original.Content is not null)
        {
            var bytes = await original.Content.ReadAsByteArrayAsync(ct);
            clone.Content = new ByteArrayContent(bytes);

            foreach (var header in original.Content.Headers)
                clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        return clone;
    }
}

Request/Response Logging Handler

C#
public class HttpLoggingHandler(ILogger<HttpLoggingHandler> logger) : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var stopwatch = Stopwatch.StartNew();

        logger.LogInformation(
            "HTTP {Method} {Uri}",
            request.Method,
            request.RequestUri);

        HttpResponseMessage response;
        try
        {
            response = await base.SendAsync(request, ct);
        }
        catch (Exception ex)
        {
            logger.LogError(ex,
                "HTTP {Method} {Uri} failed after {ElapsedMs}ms",
                request.Method, request.RequestUri, stopwatch.ElapsedMilliseconds);
            throw;
        }

        stopwatch.Stop();

        if (response.IsSuccessStatusCode)
        {
            logger.LogInformation(
                "HTTP {Method} {Uri} → {StatusCode} in {ElapsedMs}ms",
                request.Method, request.RequestUri,
                (int)response.StatusCode, stopwatch.ElapsedMilliseconds);
        }
        else
        {
            var body = await response.Content.ReadAsStringAsync(ct);
            logger.LogWarning(
                "HTTP {Method} {Uri} → {StatusCode} in {ElapsedMs}ms — Body: {Body}",
                request.Method, request.RequestUri,
                (int)response.StatusCode, stopwatch.ElapsedMilliseconds,
                body[..Math.Min(body.Length, 500)]); // truncate long bodies
        }

        return response;
    }
}

Correlation ID Forwarding Handler

Propagate trace context from incoming requests to outgoing calls so logs correlate across services.

C#
public class CorrelationIdHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
    private const string CorrelationIdHeader = "X-Correlation-ID";

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var correlationId = httpContextAccessor.HttpContext?
            .Request.Headers[CorrelationIdHeader]
            .FirstOrDefault();

        if (correlationId is not null)
        {
            request.Headers.TryAddWithoutValidation(CorrelationIdHeader, correlationId);
        }
        else
        {
            // Generate a new one if no incoming correlation ID
            request.Headers.TryAddWithoutValidation(
                CorrelationIdHeader, Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N"));
        }

        return base.SendAsync(request, ct);
    }
}

Registering Handlers — Order Matters

C#
// Register handlers with DI
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<HttpLoggingHandler>();
builder.Services.AddTransient<CorrelationIdHandler>();

// Attach to a typed client
builder.Services.AddHttpClient<PaymentApiClient>(c =>
    c.BaseAddress = new Uri("https://payments.example.com"))
    .AddHttpMessageHandler<BearerTokenHandler>()    // first in chain
    .AddHttpMessageHandler<CorrelationIdHandler>()  // second
    .AddHttpMessageHandler<HttpLoggingHandler>()    // closest to socket
    .AddStandardResilienceHandler();                // Polly wraps everything

Handlers execute in registration order on the way out, reverse order on the way back. BearerTokenHandler fires first — the token is attached before logging sees the request.

Polly's resilience pipeline wraps all of them — a retry fires the entire handler chain again, including re-fetching the auth token.


Registering for Refit Clients

Identical — AddRefitClient<T>() returns the same IHttpClientBuilder:

C#
builder.Services
    .AddRefitClient<IPaymentApi>()
    .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://payments.example.com"))
    .AddHttpMessageHandler<BearerTokenHandler>()
    .AddHttpMessageHandler<CorrelationIdHandler>()
    .AddStandardResilienceHandler();

Testing Handlers with MockHttpMessageHandler

Install:

Bash
dotnet add package RichardSzalay.MockHttp

Test the BearerTokenHandler in isolation:

C#
public class BearerTokenHandlerTests
{
    [Fact]
    public async Task Adds_Authorization_Header_To_Request()
    {
        // Arrange
        var mockHttp = new MockHttpMessageHandler();
        mockHttp.When("https://api.example.com/*")
                .Respond(HttpStatusCode.OK);

        var tokenService = Substitute.For<ITokenService>();
        tokenService.GetTokenAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
                    .Returns("test-token-abc");

        var handler = new BearerTokenHandler(tokenService)
        {
            InnerHandler = mockHttp  // inject mock as the inner handler
        };

        var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };

        // Act
        await client.GetAsync("/payments/123");

        // Assert
        var sentRequest = mockHttp.GetMatchingRequests().Single();
        sentRequest.Headers.Authorization!.Scheme.Should().Be("Bearer");
        sentRequest.Headers.Authorization.Parameter.Should().Be("test-token-abc");
    }

    [Fact]
    public async Task Retries_With_Fresh_Token_On_401()
    {
        var mockHttp = new MockHttpMessageHandler();
        // First call returns 401, second returns 200
        mockHttp.When("https://api.example.com/payments/123")
                .Respond(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
        mockHttp.When("https://api.example.com/payments/123")
                .Respond(HttpStatusCode.OK);

        var tokenService = Substitute.For<ITokenService>();
        tokenService.GetTokenAsync(default, default).ReturnsForAnyArgs("stale-token");
        tokenService.GetFreshTokenAsync(default, default).ReturnsForAnyArgs("fresh-token");

        var handler = new BearerTokenHandler(tokenService) { InnerHandler = mockHttp };
        var client = new HttpClient(handler) { BaseAddress = new Uri("https://api.example.com") };

        var response = await client.GetAsync("/payments/123");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        await tokenService.Received(1).GetFreshTokenAsync("payment-api", Arg.Any<CancellationToken>());
    }
}

Key Takeaways

  • DelegatingHandler is outbound middleware — handles auth, logging, and cross-cutting concerns once
  • Register handlers as transient with AddTransient<THandler>()
  • Registration order on IHttpClientBuilder is execution order on the way out
  • Polly's resilience handler should be the last thing added — it wraps the entire chain
  • Test handlers by setting InnerHandler directly — no IHttpClientFactory needed in tests