DN

Personal .NET notes

Duck's .NET Notebook

.NET

HttpClient - A learning ladder for using HttpClient in .NET API

A step-by-step guide to creating, registering, injecting, and using HttpClient correctly in a .NET API, from basic cases to named clients and response handling.

This HttpClient learning ladder covers:

Step 1: What HttpClient is for

HttpClient is used when your API needs to call another HTTP service, for example:

  • another internal API
  • a third-party API
  • a remote endpoint returning JSON

Simple example:

using System.Net.Http;

using var client = new HttpClient();
using var response = await client.GetAsync("https://api.example.com/weather");
var content = await response.Content.ReadAsStringAsync();

Step 2: The simplest app setup

In ASP.NET Core, the normal starting point is to register HttpClient support in Program.cs.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient();

var app = builder.Build();
app.MapGet("/", () => "API is running");
app.Run();

What this does:

  • It adds the IHttpClientFactory to the Dependency Injection (DI) container
  • Allows you to inject IHttpClientFactory
  • Allows the framework to manage HttpClient creation more safely

Step 3: Inject IHttpClientFactory

Once you register AddHttpClient(), you can inject IHttpClientFactory into a service and use it.

Example service:

using System.Net.Http;

public sealed class WeatherService(IHttpClientFactory httpClientFactory)
{
    public async Task<string> GetWeatherAsync()
    {
        var client = httpClientFactory.CreateClient();

        using var response = await client.GetAsync("https://api.example.com/weather");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

And register the service:

builder.Services.AddHttpClient();
builder.Services.AddScoped<WeatherService>();

What this does:

  1. AddHttpClient() registers the HTTP client factory system
  2. Your service receives IHttpClientFactory through constructor injection (DI)

That means your code does not manually manage the full client lifecycle itself.

Step 4: Why you should not create HttpClient manually

  1. Creating HttpClient with new for every request (including inside using) can cause socket exhaustion (the OS runs out of available ports).
  2. Using one long-lived static HttpClient can cause stale DNS issues (it may not pick up upstream IP changes quickly).

Why IHttpClientFactory helps solve these issues

  • IHttpClientFactory pools and manages underlying HttpMessageHandler instances for you.
  • Handlers are rotated periodically (2 minutes by default), which helps refresh DNS information.
  • You still get a fresh HttpClient instance each time you call CreateClient(), without paying the connection-management cost.

Step 5: Typed client - cleaner

You can still inject IHttpClientFactory, but if one service always talks to one external API, a typed client often reads more cleanly.

Example:

public sealed class GitHubService(HttpClient httpClient)
{
    public async Task<string> GetUserAsync(string username)
    {
        using var response = await httpClient.GetAsync($"/users/{username}");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

Registration:

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

What this does:

  • the service directly receives HttpClient
  • the base address can be configured just once.

Step 6: When to use HttpClient vs IHttpClientFactory

This confuses a lot of people because both appear in examples. The practical rule is:

  • Use plain injected HttpClient inside a typed client
  • Use IHttpClientFactory when you need to create clients dynamically or choose between multiple client configurations (e.g call two different websites)

When to use which:

  • one service, one external API: Typed client is often cleaner
  • many external APIs or dynamic choice: IHttpClientFactory is often better

Step 7: Named client

A named client is useful when you want different HTTP configurations with different names.

Example registration:

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

builder.Services.AddHttpClient("weather", client =>
{
    client.BaseAddress = new Uri("https://api.weather.example");
});

Then in a service:

public sealed class ExternalApiService(IHttpClientFactory httpClientFactory)
{
    public async Task<string> GetGitHubUserAsync(string username)
    {
        var client = httpClientFactory.CreateClient("github");
        using var response = await client.GetAsync($"/users/{username}");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> GetWeatherAsync(string city)
    {
        var client = httpClientFactory.CreateClient("weather");
        using var response = await client.GetAsync($"/forecast/{city}");
        response.EnsureSuccessStatusCode();

        return await response.Content.ReadAsStringAsync();
    }
}

Why named clients help:

  • each external API can have its own setup
  • base address and headers can be separated clearly
  • you avoid mixing configurations

Step 8: Named client vs Typed client

Use a typed client when:

  • one service/class clearly maps to one external API
  • you want the cleanest constructor and service code

Use a named client when:

  • one service/class needs to call several external APIs
  • you want to select between clients by name
  • you want more central configuration without creating a separate typed service for each API

Step 9: Add default headers

Sometimes an API needs headers on every request.

Example registration:

builder.Services.AddHttpClient("github", client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
    client.DefaultRequestHeaders.Add("User-Agent", "MyDotNetApi");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

Use this for headers that should apply to every request for that client.

Step 10: Add custom headers for just one request

If a header should only exist on one request, use HttpRequestMessage.

Example:

var request = new HttpRequestMessage(HttpMethod.Get, "/users/octocat");
request.Headers.Add("X-Correlation-Id", Guid.NewGuid().ToString());

using var response = await client.SendAsync(request);

Use this when:

  • the header changes per request
  • the header is not a global default

Step 11: EnsureSuccessStatusCode()

When checking a response, you usually care about:

  • status code
  • headers
  • body content

Use EnsureSuccessStatusCode() when:

  • any non-2xx response should be treated as a failure
  • you do not need special handling for specific status codes

If the response is not in the 2xx range, EnsureSuccessStatusCode() throws an HttpRequestException.

Example:

using var response = await client.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();

var body = await response.Content.ReadAsStringAsync();

Step 12: Check HTTP response content on error

Basic example:

using var response = await client.GetAsync("/users/octocat");

if (!response.IsSuccessStatusCode)
{
    var errorBody = await response.Content.ReadAsStringAsync();
    throw new Exception($"Request failed: {(int)response.StatusCode} - {errorBody}");
}

var body = await response.Content.ReadAsStringAsync();

This gives you more control than immediately calling EnsureSuccessStatusCode().

Step 13: How to read JSON response content - ReadFromJsonAsync

If the API returns JSON, you usually want to deserialize it into a class.

Example model:

public sealed record GitHubUser(string Login, long Id);

Example usage:

using System.Net.Http.Json;

using var response = await client.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();

var user = await response.Content.ReadFromJsonAsync<GitHubUser>();

Step 14: GetFromJsonAsync

GetFromJsonAsync is a shortcut when:

  • you are making a GET request
  • you expect JSON back
  • you want to deserialize it directly into a model

Example model:

public sealed record GitHubUser(string Login, long Id);

Example usage:

using System.Net.Http.Json;

var user = await _httpClient.GetFromJsonAsync<GitHubUser>("/users/octocat");

If the response is not in the 2xx range, GetFromJsonAsync throws an HttpRequestException rather than giving you the raw response to inspect first.

This is shorter than:

using var response = await _httpClient.GetAsync("/users/octocat");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadFromJsonAsync<GitHubUser>();

Use GetFromJsonAsync when:

  • quick, simple GET requests
  • you do not need much custom response handling

Avoid it when:

  • you need to inspect status codes first
  • you need custom error handling
  • you need headers from the response

Full code example

Here is a simple typed client setup.

Registration

// Typed client: one service focused on one upstream API.
builder.Services.AddHttpClient<IGitHubUserClient, GitHubUserClient>(client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
    client.DefaultRequestHeaders.Add("User-Agent", "LearningApi");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

Service

using System.Net.Http.Json;

public sealed class GitHubUserClient(HttpClient httpClient) : IGitHubUserClient
{
    public async Task<GitHubUser?> GetUserAsync(string username, CancellationToken cancellationToken = default)
    {
        using var response = await httpClient.GetAsync($"/users/{username}", cancellationToken);
        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<GitHubUser>(cancellationToken: cancellationToken);
    }
}

Minimal API endpoint usage

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<IGitHubUserClient, GitHubUserClient>(client =>
{
    client.BaseAddress = new Uri("https://api.github.com");
    client.DefaultRequestHeaders.Add("User-Agent", "LearningApi");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
});

var app = builder.Build();

app.MapGet("/api/users/{username}", async (
    string username,
    IGitHubUserClient gitHubUserClient,
    CancellationToken cancellationToken) =>
{
    var user = await gitHubUserClient.GetUserAsync(username, cancellationToken);
    return user is null ? Results.NotFound() : Results.Ok(user);
});

app.Run();

Summary

  • Do not scatter new HttpClient() throughout your code (avoid new)
  • Register HTTP clients with DI
  • Use typed clients for simple one-service-to-one-API cases
  • Use named clients when you need multiple configurations
  • Use default headers only for values that apply to every request
  • Use request-specific headers through HttpRequestMessage
  • Dispose HttpResponseMessage when you manually handle the response
  • Check status codes.
  • Deserialize JSON into models.