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.
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)
│
▼
NetworkAuth Header Handler — Bearer Token
The most common use case: attach a Bearer token from a token service without touching every call site.
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
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
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.
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
// 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 everythingHandlers 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:
builder.Services
.AddRefitClient<IPaymentApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri("https://payments.example.com"))
.AddHttpMessageHandler<BearerTokenHandler>()
.AddHttpMessageHandler<CorrelationIdHandler>()
.AddStandardResilienceHandler();Testing Handlers with MockHttpMessageHandler
Install:
dotnet add package RichardSzalay.MockHttpTest the BearerTokenHandler in isolation:
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
DelegatingHandleris outbound middleware — handles auth, logging, and cross-cutting concerns once- Register handlers as transient with
AddTransient<THandler>() - Registration order on
IHttpClientBuilderis 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
InnerHandlerdirectly — noIHttpClientFactoryneeded in tests
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.