DEV Community

Cover image for DelegatingHandler Magic: Build a Pipeline for Your HTTP Calls
Nick
Nick

Posted on AI-assisted

DelegatingHandler Magic: Build a Pipeline for Your HTTP Calls

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Register It

services.AddTransient<LoggingHandler>();

services.AddHttpClient<IMyApiClient, MyApiClient>()
    .AddHttpMessageHandler<LoggingHandler>();
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🎯 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)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

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.