Building robust HTTP clients in .NET
HTTP clients are the backbone of modern applications, enabling communication with external APIs and services. However, not all HTTP client implementations are created equal. In this post, we'll explore how to evolve from a basic HTTP client to a sophisticated, production-ready implementation with proper authentication, error handling, and maintainability. We will use a real-world example of a Customer Management API (CMA) service client to demonstrate each evolution step, showing how each improvement adds value and addresses specific challenges. In the real world I would recommend naming the client by a descriptive name, CustomerManagementHttpClient, but to save horizontal space in this blog post, we'll simply refer to it as CmaHttpClient.
Stage 1: HttpClient, as simple as it gets.
Let's start with the bare minimum. You want data, and you're going to get it.
Implementation
public class CmaHttpClient(HttpClient httpClient)
{
public Task<List<Customer>> ListCustomers() => httpClient.GetFromJsonAsync<List<Customer>>();
}
Registration
It's generally a good idea to fetch your base address from configuration, but for the sake of simplicity, we'll hard-code it.
services.AddHttpClient<CmaClient>(client =>
{
client.BaseAddress = new Uri("https://cma.com/api");
});
Stage 2: Abstracting your client
If you intend to test code that depends on your client, or you plan to ship your client as part of a reusable module, it is beneficial to abstract it, allowing your abstraction to be mocked. If your app is tiny with low growth expectancy, and you do not plan to write tests for it, I'd say it's better to keep it simple and skip the abstraction.
Implementation
public interface ICmaClient
{
Task<List<Customer>> ListCustomers();
}
public class CmaHttpClient(HttpClient httpClient) : ICmaClient
{
public Task<List<Customer>> ListCustomers() => httpClient.GetFromJsonAsync<List<Customer>>();
}
Registration
services.AddHttpClient<ICmaClient, CmaClient>(client =>
{
client.BaseAddress = new Uri("https://api.cma.com");
});
Stage 3: Basic Authentication with API Key
In most cases, APIs you're interacting with require authentication. At this stage, we'll add a simple API key authentication mechanism. This is the simplest way to add authentication to your client, but it's not the most flexible one. In the next stage, we'll see how we can connect to services using more advanced means of authentication.
Implementation
Implementation stays the same as in the previous stage.
Configuration
Configuration has been added to serve environmental or secret values. Remember to always store your API keys in a secure location.
public record CmaApiOptions
{
public const string SectionName = "Cma:Api";
public string BaseUrl { get; set; }
public string ApiKey { get; set; }
}
// appsettings.json
{
"Cma": {
"Api": {
"BaseUrl": "https://cma.com/api/",
"ApiKey": "your-api-key-here"
}
}
}
Registration
services.AddOptions<CmaApiOptions>()
.Configure<IConfiguration>((settings, configuration) =>
{
configuration.GetSection(CmaApiOptions.SectionName).Bind(settings);
});
services.AddHttpClient<ICmaClient, CmaClient>((serviceProvider, httpClient) =>
{
var options = serviceProvider.GetRequiredService<IOptions<CmaApiOptions>>().Value;
httpClient.BaseAddress = new Uri(options.BaseUrl);
httpClient.DefaultRequestHeaders.Add("x-api-key", _options.ApiKey);
});
Stage 4: OAuth2 Authentication with Message Handler
When working with OAuth2 or JWT tokens, HTTP message handlers are your best friend. They intercept every outgoing request, attach the bearer token automatically, and handle token caching. Your client code stays simple and never needs to think about authentication.
Implementation
Client implementation stays the same as the previous stage.
Token handler
The message handler does all the authentication work. It fetches tokens, caches them, and attaches them to each request
public class CmaAuthenticationHandler(IMemoryCache cache, IOptions<CmaApiOptions> options) : DelegatingHandler
{
private static readonly SemaphoreSlim TokenLock = new(1, 1);
private const string CacheKey = "CmaAccessToken";
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var token = await GetTokenAsync(cancellationToken);
var authorizationHeader = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Authorization = authorizationHeader;
return await base.SendAsync(request, cancellationToken);
}
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
{
if (cache.TryGetValue(CacheKey, out string? token))
{
return token!;
}
// We'll use a semaphore to ensure that only one token is fetched at a time.
// This is important to avoid race conditions when multiple requests are made at the same time.
await TokenLock.WaitAsync(cancellationToken);
try
{
if (cache.TryGetValue(CacheKey, out token))
{
return token!;
}
token = await FetchTokenAsync(cancellationToken);
// Sometimes you'll get lifetime information from the token response, which you can use to set the cache expiration.
// In this example, we'll set the expiration to 50 minutes.
// You can also parse the JWT token to get the expiration time value and use that to set the cache expiration.
// Remember to always use a buffer to avoid expiration issues.
cache.Set(CacheKey, token, TimeSpan.FromMinutes(50));
return token;
}
finally
{
TokenLock.Release();
}
}
private async Task<string> FetchTokenAsync(CancellationToken cancellationToken)
{
using var client = new HttpClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
// Depending on the required grant_type, you may need to add additional parameters.
// Check the documentation of your OAuth2 provider for more information.
["grant_type"] = "client_credentials",
["client_id"] = options.Value.ClientId,
["client_secret"] = options.Value.ClientSecret
});
var response = await client.PostAsync(options.Value.AuthUrl, content, cancellationToken);
response.EnsureSuccessStatusCode();
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken);
return tokenResponse?.AccessToken ?? throw new InvalidOperationException("No access token received");
}
}
public record TokenResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; init; } = string.Empty;
}
Configuration
public record CmaApiOptions
{
public const string SectionName = "Cma:Api";
public string BaseUrl { get; init; } = string.Empty;
public string AuthUrl { get; init; } = string.Empty;
public string ClientId { get; init; } = string.Empty;
public string ClientSecret { get; init; } = string.Empty;
}
// appsettings.json
{
"Cma": {
"Api": {
"BaseUrl": "https://cma.com/api/",
"AuthUrl": "https://cma.com/oauth/token",
"ClientId": "your-client-id",
"ClientSecret": "your-client-secret"
}
}
}
Registration
Token handler is registered as a HttpMessageHandler that will run during each request
public static class CmaHttpClientExtensions
{
public static IServiceCollection AddCmaHttpClient(this IServiceCollection services)
{
services.AddOptions<CmaApiOptions>()
.Configure<IConfiguration>((settings, configuration) =>
{
configuration.GetSection(CmaApiOptions.SectionName).Bind(settings);
});
services.AddTransient<CmaAuthenticationHandler>();
services.AddHttpClient<ICmaClient, CmaHttpClient>((serviceProvider, httpClient) =>
{
var options = serviceProvider.GetRequiredService<IOptions<CmaApiOptions>>().Value;
httpClient.BaseAddress = new Uri(options.BaseUrl);
})
.AddHttpMessageHandler<CmaAuthenticationHandler>();
return services;
}
}
Stage 5: Logging
Logging is critical when working with integrations. When something breaks at 3 AM, you'll want to know exactly what went wrong without having to attach a debugger or redeploy with additional instrumentation. But logging is highly domain-specific and application-specific, so there's no one-size-fits-all solution.
What you get for free
When you use AddHttpClient, you get built-in logging at no extra cost. The framework logs HTTP requests and responses through the System.Net.Http.HttpClient category. By default, you'll see:
- Information level: Request start (method, URL) and completion (status code, elapsed time)
- Trace level: Request and response headers (useful for debugging, but verbose)
- Error level: Exceptions and failed requests
Remember that you can configure the log level in appsettings.json:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"System.Net.Http.HttpClient": "Warning"
}
}
}
What to consider beyond the defaults
The right logging approach depends on your needs. Are you building a high-throughput API where every millisecond counts? You'll want minimal logging. Working on a payment integration where audit trails are mandatory? You'll need detailed logs of every request and response. Debugging a flaky third-party API? You might temporarily crank up the verbosity to catch intermittent issues.
Here are some questions to guide your logging strategy:
What to consider logging?
- Authentication events (token fetches, refreshes, failures)
- Request/response metadata (URLs, status codes, duration)
- Errors and exceptions (with context about what operation failed)
- Business-relevant events (e.g., "Customer import completed: 150 records")
What should you NOT log?
- Secrets (API keys, tokens, passwords)
- Personally identifiable information (unless required and compliant)
- Full request/response bodies in production (they can be huge and expensive)
Stage 6: Retries and Error Handling
Networks fail. APIs go down. Timeouts happen. Proper error handling and retry logic are what separate a fragile integration from a resilient one. But much like logging, there's no one-size-fits-all solution—it depends on your domain and use case.
Should you retry?
Not all errors are worth retrying. Some are transient, others are permanent.
Retry these (transient errors):
- 5xx server errors
- 408 Request Timeout
- 429 Too Many Requests (respect
Retry-Afterheader) - Network failures (connection refused, timeouts)
Don't retry these (permanent errors):
- 4xx client errors (bad request, unauthorized, not found)
- Unless you can fix the problem (e.g., refresh an expired token)
How to retry
Use exponential backoff with jitter to avoid overwhelming struggling servers and prevent thundering herd problems. Modules like Microsoft.Extensions.Http.Resilience or Polly makes this Trivial.
services.AddHttpClient<ICmaClient, CmaHttpClient>(/* ... */)
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromSeconds(1);
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
});
That's it. You get retries, circuit breaker, and timeout policies out of the box.
Error handling in your client
Even with retries, errors will still happen. Handle them gracefully:
public class CmaHttpClient(HttpClient httpClient, ILogger<CmaHttpClient> logger) : ICmaClient
{
public async Task<Customer> FindCustomer(int id)
{
try
{
return await httpClient.GetFromJsonAsync<Customer>($"/customers/{id}");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
logger.LogWarning("Customer not found: {Id}", id);
return null;
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Failed to fetch customer");
throw; // Let the caller decide what to do
}
}
}
Finding the right balance
Start conservative: retry only on obvious transient failures, use exponential backoff with jitter, and limit retries to 3–5 attempts. Monitor your retry metrics—if you're constantly retrying, something's wrong upstream. Sometimes the best error handling is accepting that things fail and designing your application to handle it gracefully.
Conclusion
In this post, we've explored how to evolve from a simple HTTP client to a sophisticated, production-ready implementation with proper authentication, error handling, and maintainability. We've used a real-world example of a Customer Management API (CMA) service client to demonstrate each evolution step, showing how each improvement adds value and addresses specific challenges.
We would like to hear what you think about the blog post