Extending HttpClient With Delegating Handlers in ASP.NET Core

Extending HttpClient With Delegating Handlers in ASP.NET Core

Delegating handlers are like ASP.NET Core middleware. Except they work with the HttpClient. The ASP.NET Core request pipeline allows you to introduce custom behavior with middleware. You can solve many cross-cutting concerns using middleware — logging, tracing, validation, authentication, authorization, etc.

But, an important aspect here is that middleware works with incoming HTTP requests to your API. Delegating handlers work with outgoing requests.

HttpClient is my preferred way of sending HTTP requests in ASP.NET Core. It's straightforward to use and solves most of my use cases. You can use delegating handlers to extend the HttpClient with behavior before or after sending an HTTP request.

Today, I want to show you how to use a DelegatingHandler to introduce:

  • Logging

  • Resiliency

  • Authentication

Configuring an HttpClient

Here's a very simple application that:

  • Configures the GitHubService class as a typed HTTP client

  • Sets the HttpClient.BaseAddress to point to the GitHub API

  • Exposes an endpoint that retrieves a GitHub user by their username

We're going to extend the GitHubService behavior using delegating handlers.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<GitHubService>(httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com");
});

var app = builder.Build();

app.MapGet("api/users/{username}", async (
    string username,
    GitHubService gitHubService) =>
{
    var content = await gitHubService.GetByUsernameAsync(username);

    return Results.Ok(content);
});

app.Run();

The GitHubService class is a typed client implementation. Typed clients allow you to expose a strongly typed API and hide the HttpClient. The runtime takes care of providing a configured HttpClient instance through dependency injection. You also don't have to think about disposing of the HttpClient. It's resolved from an underlying IHttpClientFactory that manages the HttpClient lifetime.

public class GitHubService(HttpClient client)
{
    public async Task<GitHubUser?> GetByUsernameAsync(string username)
    {
        var url = $"users/{username}";

        return await client.GetFromJsonAsync<GitHubUser>(url);
    }
}

Logging HTTP Requests Using Delegating Handlers

Let's start with a simple example. We will add logging before and after sending an HTTP request. For this, we will to create a custom delegating handler - LoggingDelegatingHandler.

The custom delegating handler implements the DelegatingHandler base class. Then, you can override the SendAsync method to introduce additional behavior.

public class LoggingDelegatingHandler(ILogger<LoggingDelegatingHandler> logger)
    : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        try
        {
            logger.LogInformation("Before HTTP request");

            var result = await base.SendAsync(request, cancellationToken);

            result.EnsureSuccessStatusCode();

            logger.LogInformation("After HTTP request");

            return result;
        }
        catch (Exception e)
        {
            logger.LogError(e, "HTTP request failed");

            throw;
        }
    }
}

You also need to register the LoggingDelegatingHandler with dependency injection. Delegating handlers must be registered as transient services.

The AddHttpMessageHandler method adds the LoggingDelegatingHandler as a delegating handler for the GitHubService. Any HTTP request sent using the GitHubService will first go through the LoggingDelegatingHandler.

builder.Services.AddTransient<LoggingDelegatingHandler>();

builder.Services.AddHttpClient<GitHubService>(httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com");
})
.AddHttpMessageHandler<LoggingDelegatingHandler>();

Let's see what else we can do.

Adding Resiliency With Delegating Handlers

Building resilient applications is an important requirement for cloud development.

The RetryDelegatingHandler class uses Polly to create an AsyncRetryPolicy. The retry policy wraps the HTTP request and retries it in case of a transient failure.

public class RetryDelegatingHandler : DelegatingHandler
{
    private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy =
        Policy<HttpResponseMessage>
            .Handle<HttpRequestException>()
            .RetryAsync(2);

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var policyResult = await _retryPolicy.ExecuteAndCaptureAsync(
            () => base.SendAsync(request, cancellationToken));

        if (policyResult.Outcome == OutcomeType.Failure)
        {
            throw new HttpRequestException(
                "Something went wrong",
                policyResult.FinalException);
        }

        return policyResult.Result;
    }
}

You also need to register the RetryDelegatingHandler with dependency injection. Also, remember to configure it as a message handler. In this example, I'm chaining two delegating handlers together, and they will run one after another.

builder.Services.AddTransient<RetryDelegatingHandler>();

builder.Services.AddHttpClient<GitHubService>(httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com");
})
.AddHttpMessageHandler<LoggingDelegatingHandler>()
.AddHttpMessageHandler<RetryDelegatingHandler>();

Solving Authentication With Delegating Handlers

Authentication is a cross-cutting concern you will have to solve in any microservices application. A common use case for delegating handlers is adding the Authorization header before sending an HTTP request.

For example, the GitHub API requires an access token to be present for authenticating incoming requests. The AuthenticationDelegatingHandler class adds the Authorization header value from the GitHubOptions. Another requirement is specifying the User-Agent header, which is set from the app configuration.

public class AuthenticationDelegatingHandler(IOptions<GitHubOptions> options)
    : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        request.Headers.Add("Authorization", options.Value.AccessToken);
        request.Headers.Add("User-Agent", options.Value.UserAgent);

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

Don't forget to configure the AuthenticationDelegatingHandler with the GitHubService:

builder.Services.AddTransient<AuthenticationDelegatingHandler>();

builder.Services.AddHttpClient<GitHubService>(httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com");
})
.AddHttpMessageHandler<LoggingDelegatingHandler>()
.AddHttpMessageHandler<RetryDelegatingHandler>()
.AddHttpMessageHandler<AuthenticationDelegatingHandler>();

Here's a more involved authentication example using the KeyCloakAuthorizationDelegatingHandler. This is a delegating handler that acquires the access token from Keycloak. Keycloak is an open-source identity and access management service.

I used Keycloak as the identity provider in my Pragmatic Clean Architecture course.

The delegating handler in this example uses an OAuth 2.0 client credentials grant flow to obtain an access token. This grant is used when applications request an access token to access their own resources, not on behalf of a user.

public class KeyCloakAuthorizationDelegatingHandler(
    IOptions<KeycloakOptions> keycloakOptions)
    : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var authToken = await GetAccessTokenAsync();

        request.Headers.Authorization = new AuthenticationHeaderValue(
            JwtBearerDefaults.AuthenticationScheme,
            authToken.AccessToken);

        var httpResponseMessage = await base.SendAsync(
            request,
            cancellationToken);

        httpResponseMessage.EnsureSuccessStatusCode();

        return httpResponseMessage;
    }

    private async Task<AuthToken> GetAccessTokenAsync()
    {
        var params = new KeyValuePair<string, string>[]
        {
            new("client_id", _keycloakOptions.Value.AdminClientId),
            new("client_secret", _keycloakOptions.Value.AdminClientSecret),
            new("scope", "openid email"),
            new("grant_type", "client_credentials")
        };

        var content = new FormUrlEncodedContent(params);

        var authRequest = new HttpRequestMessage(
            HttpMethod.Post,
            new Uri(_keycloakOptions.TokenUrl))
        {
            Content = content
        };

        var response = await base.SendAsync(authRequest, cancellationToken);

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<AuthToken>() ??
               throw new ApplicationException();
    }
}

Takeaway

Delegating handlers give you a powerful mechanism to extend the behavior when sending requests with an HttpClient. You can use delegating handlers to solve cross-cutting concerns, similar to how you would use middleware.

Here are a few ideas on how you could use delegating handlers:

  • Logging before and after sending HTTP requests

  • Introducing resilience policies (retry, fallback)

  • Validating the HTTP request content

  • Authenticating with an external API

I'm sure you can come up with a few use cases yourself.

I made a video showing how to implement delegating handlers, and you can watch it here.

Thanks for reading, and stay awesome!


P.S. Whenever you’re ready, there are 2 ways I can help you:

  1. Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 1,900+ students here.

  2. Patreon Community: Think like a senior software engineer with access to the source code I use in my YouTube videos and exclusive discounts for my courses. Join 980+ engineers here.