C# HttpClientFactory and HTTP Boundaries

Configure named HTTP clients with IHttpClientFactory, handle status and cancellation explicitly, bound responses, and test without contacting a real service.

Lesson 41 of 43Advanced9 min read

An HTTP request crosses a boundary the application does not control. The server can be unavailable, return an unexpected status, send an invalid body or respond too slowly. A reusable client configuration helps, but it does not decide what those outcomes mean for the feature. Code still needs a timeout, cancellation, response ownership and a clear success contract.

This lesson uses .NET 8 and C# 12 with IHttpClientFactory. The complete examples use an in-memory fake message handler and contact no server. You will configure a named client, read a bounded known response and exercise a missing-resource path. This makes the behavior testable before integrating a real service endpoint.

Handler lifetime differs from client lifetime

Creating and discarding a separately configured HttpClient for every request can unnecessarily churn underlying connections. Factory-created clients share managed handler lifetimes under the factory's policy. Alternatively, an intentionally long-lived client can use a configured SocketsHttpHandler with an appropriate pooled-connection lifetime. These are deliberate designs, not permission to create clients without understanding ownership.

A factory-created client can be disposed after its operation without destroying the pooled handler immediately. Avoid retaining a typed client inside a singleton without reviewing its intended lifetime. Cookie handling also needs care because pooled handlers can share cookie containers and recycled handlers can lose cookies. When a feature depends on isolated cookies, use an appropriate dedicated design rather than assuming a named client automatically provides per-user cookie isolation.

Configuration should express the service contract

A named client can define a base address, request headers and timeout. Relative URI resolution matters: a base address without the intended trailing slash can combine differently from what a beginner expects. Test the actual resolved URI. A named client is not an authorization boundary, so avoid allowing untrusted input to replace the destination host.

Validate or constrain any user-derived path and query values. Prefer an approved service origin and encode query components rather than concatenating raw text. Do not put credentials in a URL because it can appear in logs and intermediaries. A token should come from an appropriate secret source and an intentional authentication handler, not a hard-coded tutorial string. Keep diagnostics limited to safe request identifiers and status information.

Status, cancellation and body shape are separate checks

A completed request is not necessarily a successful response. Check the status or call EnsureSuccessStatusCode when every non-success status should become a failure. Sometimes a 404 is a meaningful missing-record result rather than an exception-worthy incident. Define the expected statuses and parse the body only under the appropriate branch.

Pass a cancellation token through both sending and reading where supported. A client timeout provides another bound, but it does not tell the caller whether cancellation came from the caller or the timeout without additional context. Response size also needs a policy. Streaming with ResponseHeadersRead lets the application apply a bounded reading strategy rather than blindly buffering arbitrary content. A Content-Length hint alone cannot be trusted as the complete enforcement.

Retries require idempotency and bounds

A transient failure can justify a retry, but retrying every request blindly is dangerous. A timed-out payment request may have succeeded at the server even though the caller did not receive its response. Repeating it can duplicate the effect unless the service supports a suitable idempotency contract.

Use a bounded policy with an overall deadline, cancellation and appropriate delay rather than an infinite retry loop. Review which status codes and exceptions are retryable for that specific API. Supported resilience libraries can implement mechanics, but they cannot invent business idempotency. Avoid retry storms during an outage. For a tutorial's first client, correct status handling and ownership are more valuable than a complicated policy whose consequences are unexplained.

Keep tests and real integration responsibilities distinct

A fake handler verifies request construction and application response behavior. It does not prove that the actual server accepts the schema, that TLS configuration is correct or that the credential has the required permission. Add controlled integration checks for those responsibilities in an authorized environment.

Assert the method, resolved URI, safe headers and branch outcome in client tests. Avoid asserting unstable transport timing or secret values. For JSON bodies, deserialize into a bounded explicit contract and validate required properties. An HTTP 200 with an incompatible body is still an application failure. Keep the client boundary small enough that callers receive a meaningful result rather than needing to reinterpret transport details repeatedly.

Official references

C# HttpClientFactory and HTTP Boundaries Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Configure a named client and use a fake handler

Use this project file and complete program. The fake response has a fixed small body, so ordinary string reading is appropriate for this fixture.

The handler intercepts the request entirely. The address is reserved example text, not a required external service. Both the response and client have explicit disposal scopes.

XML
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
</Project>
C#
using System.Net;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddHttpClient("catalog", client =>
{
    client.BaseAddress = new Uri("https://catalog.example.test/api/");
    client.Timeout = TimeSpan.FromSeconds(5);
}).ConfigurePrimaryHttpMessageHandler(() => new CatalogHandler());

using var provider = services.BuildServiceProvider();
var factory = provider.GetRequiredService<IHttpClientFactory>();
using HttpClient client = factory.CreateClient("catalog");
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using HttpResponseMessage response =
    await client.GetAsync("status", cancellation.Token);
response.EnsureSuccessStatusCode();
string body = await response.Content.ReadAsStringAsync(cancellation.Token);
Console.WriteLine($"Status: {(int)response.StatusCode}");
Console.WriteLine(body);

public sealed class CatalogHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("Catalog ready")
        });
    }
}
Output
Status: 200
Catalog ready

C# HttpClientFactory and HTTP Boundaries in Real Projects

Worked exercise: treat a missing record explicitly

The task is to distinguish a 404 from a successful lookup and avoid reading a success-shaped body on the missing branch. This self-contained fake returns only the missing status. In a real API, document whether that result is normal and whether another status should propagate as an error.

C#
using System;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

using var client = new HttpClient(new MissingHandler());
using HttpResponseMessage response =
    await client.GetAsync("https://catalog.example.test/items/7");

if (response.StatusCode == HttpStatusCode.NotFound)
{
    Console.WriteLine("Item not found");
}
else
{
    response.EnsureSuccessStatusCode();
    Console.WriteLine(await response.Content.ReadAsStringAsync());
}

public sealed class MissingHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return Task.FromResult(
            new HttpResponseMessage(HttpStatusCode.NotFound));
    }
}
Output
Item not found

A test suite should add success, unexpected error status, malformed content and cancellation. Fake handlers let those paths run without unpredictable network timing.

C# HttpClientFactory and HTTP Boundaries FAQ

Should I create a new HttpClient for every request?

Not as an independently configured client that churns handlers and connections. Use the documented factory pattern or a deliberately long-lived client with suitable handler settings. Factory-created client objects can be short-lived because the factory manages handler lifetimes. Understand the distinction before treating either “always singleton” or “always new” as a universal rule.

Does a successful GetAsync mean the status is successful?

It means a response was obtained without that call failing. The server can still return a 404 or 500. Check the status according to the feature's contract, and validate the body under the success branch. Use EnsureSuccessStatusCode when non-success statuses should throw, or map expected statuses into meaningful application results.

Can I safely retry a timed-out POST?

Only when the operation and service contract support it. A timeout does not prove the server did nothing. An idempotency key or another explicit server-side mechanism may be necessary to avoid duplicate effects. Bound retries under an overall deadline and cancellation policy. Do not add a generic retry loop around every request without reviewing the business outcome.

What does a fake message handler verify?

It can inspect the request and supply controlled responses, allowing deterministic tests of method, URI, headers, status handling and parsing. It does not exercise real DNS, TLS, authentication or server behavior. Keep a separate controlled integration layer for those concerns. A good fake fixture avoids real network access and makes each failure path explicit.

C# HttpClientFactory and HTTP Boundaries Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

A 404 is a normal missing-item outcome for an API. Which implementation fits?

C# HttpClientFactory and HTTP Boundaries Quiz

2 questions to check what you learned.

  1. Why does the main example dispose HttpResponseMessage?

    • To change its status from 200 to 204.
    • To force every pooled handler to be destroyed immediately.
    • To erase the configured base address.
    • To release response-owned resources at the operation boundary.
  2. A POST times out after the server may have processed it. What must a retry policy consider?

    • The operation's idempotency and possible duplicate effects.
    • Only that the client can create a fresh request message for another attempt.
    • Only whether the exception arose before the client received response headers.
    • That timeouts prove the server made no change.