Back to blog
Backend Systemsintermediate

DelegatingHandler — Middleware for Your HTTP Clients

DelegatingHandler is ASP.NET Core middleware for outgoing HTTP calls. Add auth headers, log requests, forward correlation IDs, and test with MockHttpMessageHandler.

LearnixoApril 14, 20265 min read
.NETC#HttpClientDelegatingHandlerMiddlewareASP.NET CoreTesting
Share:𝕏

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

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.