What's up, .NET devs! 👋
Today I want to show you one of the most underrated features in HttpClient — the DelegatingHandler pipeline. It's like middleware for your HTTP calls!
What's a DelegatingHandler?
Think of DelegatingHandler as a chain of interceptors. Every HTTP request passes through each handler, and every response comes back through the same chain (in reverse).
🔗 Fun Fact: The handler pipeline is modeled after the "Chain of Responsibility" design pattern from the Gang of Four book!
Request: Client → Handler1 → Handler2 → Handler3 → Network
Response: Client ← Handler1 ← Handler2 ← Handler3 ← Network
Your First Handler: Logging
public class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(ILogger<LoggingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
_logger.LogInformation("→ {Method} {Uri}", request.Method, request.RequestUri);
var response = await base.SendAsync(request, cancellationToken);
stopwatch.Stop();
_logger.LogInformation("← {StatusCode} in {ElapsedMs}ms",
(int)response.StatusCode, stopwatch.ElapsedMilliseconds);
return response;
}
}
Register It
services.AddTransient<LoggingHandler>();
services.AddHttpClient<IMyApiClient, MyApiClient>()
.AddHttpMessageHandler<LoggingHandler>();
Correlation ID Propagation
In microservices, trace requests across services:
public class CorrelationIdHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CorrelationIdHandler(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var correlationId = _httpContextAccessor.HttpContext?
.Request.Headers["X-Correlation-ID"].FirstOrDefault()
?? Guid.NewGuid().ToString();
request.Headers.Add("X-Correlation-ID", correlationId);
return base.SendAsync(request, cancellationToken);
}
}
Auth Token Injection
Never manually add auth headers again:
public class AuthTokenHandler : DelegatingHandler
{
private readonly ITokenService _tokenService;
public AuthTokenHandler(ITokenService tokenService)
{
_tokenService = tokenService;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await _tokenService.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
Metrics Collection
public class MetricsHandler : DelegatingHandler
{
private readonly IMeterFactory _meterFactory;
private readonly Histogram<double> _requestDuration;
public MetricsHandler(IMeterFactory meterFactory)
{
var meter = meterFactory.Create("HttpClient.Metrics");
_requestDuration = meter.CreateHistogram<double>("http_request_duration_ms");
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var tags = new TagList
{
{ "method", request.Method.Method },
{ "host", request.RequestUri?.Host ?? "unknown" }
};
try
{
var response = await base.SendAsync(request, cancellationToken);
tags.Add("status_code", ((int)response.StatusCode).ToString());
return response;
}
finally
{
stopwatch.Stop();
_requestDuration.Record(stopwatch.Elapsed.TotalMilliseconds, tags);
}
}
}
Chaining Multiple Handlers
services.AddTransient<LoggingHandler>();
services.AddTransient<CorrelationIdHandler>();
services.AddTransient<AuthTokenHandler>();
services.AddTransient<MetricsHandler>();
services.AddHttpClient<IMyApiClient, MyApiClient>()
.AddHttpMessageHandler<CorrelationIdHandler>() // First in, last out
.AddHttpMessageHandler<AuthTokenHandler>()
.AddHttpMessageHandler<LoggingHandler>()
.AddHttpMessageHandler<MetricsHandler>(); // Last in, first out
🎯 Pro Tip: Order Matters!
Handlers execute in the order you add them for requests, and reverse order for responses.
- Logging should be first (sees raw request) or last (sees final request)
- Auth should be after correlation ID
- Retry policies (Polly) should wrap most other handlers
Wrapping Up
DelegatingHandler is your Swiss Army knife for HTTP concerns. Instead of polluting your business logic with logging, auth, and metrics — put it in handlers!
Happy pipelining! 🚀
Top comments (1)
DelegatingHandler really is the most underused HttpClient feature. One pitfall worth adding: retries inside a handler. If you wrap the inner SendAsync in a retry loop, everything downstream (including the logging handler if it sits inside the retry) runs again per attempt, and you get duplicated logs and duplicate side effects on non-idempotent calls.
What I settled on: logging as the outermost handler, retry below it, and an explicit list of idempotent methods/paths inside the retry handler before it decides to resend. Ordering the pipeline deliberately made debugging a lot easier than the default register-everything-in-DI-and-hope approach.