Skip to main content

HttpClient in .NET 10: IHttpClientFactory, Timeouts, Retries

Last updated: October 8, 2026 · Tested with .NET 10 (SDK 10.0.401, runtime 10.0.12), Microsoft.Extensions.Http.Resilience 10.10.0 (Polly.Core 8.4.2), Windows 11

Short answer: don't create a new HttpClient() per request. Get clients from IHttpClientFactory (AddHttpClient, named or typed), or keep one static HttpClient on a long-lived SocketsHttpHandler with PooledConnectionLifetime set so DNS changes are picked up. For retries and timeouts add AddStandardResilienceHandler() from Microsoft.Extensions.Http.Resilience and let it own the timeouts. Below is what I measured against a local Kestrel test server: 2,000 sockets left in TIME_WAIT by the per-request pattern, the exact timeout exceptions, real retry logs, a measured Retry-After delay, and one surprise: the standard handler silently replaces your HttpClient.Timeout.

The Test Setup

All examples run as console apps against a minimal API on http://127.0.0.1:5199 in the same solution. It has endpoints that answer fast (/fast), sleep (/slow?ms=5000), return 503 a given number of times (/flaky/{id}?failures=2), return 429 with a Retry-After header once (/throttle/{id}), return a problem details body (/problem) and report the client's TCP source port (/port). The server writes a timestamped line for each interesting request, so I could compare client and server timing. Everything is loopback, so network latency is close to zero.

new HttpClient() per Request: 2,000 Sockets in TIME_WAIT

This is the pattern that still shows up in code reviews. It looks correct because HttpClient is IDisposable:

using System.Net;
using System.Net.NetworkInformation;

for (int i = 0; i < 2000; i++)
{
    using var client = new HttpClient();   // the anti-pattern
    await client.GetStringAsync("http://127.0.0.1:5199/fast");
}

Console.WriteLine($"TIME_WAIT to :5199 -> {CountTimeWait(5199)}");

static int CountTimeWait(int port) => IPGlobalProperties.GetIPGlobalProperties()
    .GetActiveTcpConnections()
    .Count(c => c.State == TcpState.TimeWait && c.RemoteEndPoint.Port == port);

Each iteration opens a new TCP connection, and disposing the client closes it. The side that closes a TCP connection keeps it in TIME_WAIT for a while, and every one of those holds a local port. Output, plus a second count from PowerShell right after the run:

TIME_WAIT to :5199 -> 2000

PS> (Get-NetTCPConnection -RemotePort 5199 -State TimeWait -ErrorAction SilentlyContinue | Measure-Object).Count
2000

One connection per request, all 2,000 still parked. On my Windows 11 machine they took about two minutes to disappear (111–112 seconds after the run, polling netstat -ano every 5 seconds). netsh int ipv4 show dynamicport tcp reports 16,384 dynamic ports starting at 49152, so a service doing a few hundred outgoing calls per second to the same host runs out and starts failing with socket errors. That is socket exhaustion, and load tests find it long before a profiler does.

The same 2,000 requests through one shared HttpClient left 0 connections in TIME_WAIT and 1 ESTABLISHED connection that was reused for every request. Through IHttpClientFactory it is also 0, even though this loop creates and disposes a client every time:

using System.Net.NetworkInformation;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddHttpClient();
using var sp = services.BuildServiceProvider();
var factory = sp.GetRequiredService<IHttpClientFactory>();

for (int i = 0; i < 2000; i++)
{
    using var client = factory.CreateClient();   // cheap: reuses the pooled handler
    await client.GetStringAsync("http://127.0.0.1:5199/fast");
}

Console.WriteLine($"TIME_WAIT to :5199 -> {IPGlobalProperties.GetIPGlobalProperties()
    .GetActiveTcpConnections()
    .Count(c => c.State == TcpState.TimeWait && c.RemoteEndPoint.Port == 5199)}");
2,000 GET requests to the local serverTIME_WAIT afterwards
using var client = new HttpClient() per request2000
One shared HttpClient0 (1 connection reused)
IHttpClientFactory.CreateClient() per request0

The factory's clients are cheap wrappers; the expensive part, the handler with its connection pool, is pooled and shared. Disposing a factory client does not close the connection.

It is also slower. I timed 500 sequential requests per variant, five rounds per run, two runs, Release build, loopback only: 483–1,037 µs per request with a new client each time versus 70–175 µs with a shared client. The high values are the first round of each run; after that it settled around 500 µs versus 70 µs. Over a real network the connection setup (and TLS handshake, which loopback HTTP doesn't have) costs more, not less.

A Static HttpClient and DNS: PooledConnectionLifetime

The old fix, a single static HttpClient, has its own problem: connections stay open as long as they are used, so if the target host's DNS record changes (blue/green deployment, failover), the client keeps talking to the old IP address. The fix is to give connections a maximum age:

public static class Http
{
    // Long-lived handler: connections are recycled every 2 minutes,
    // so a DNS change is picked up when the next connection is opened.
    private static readonly SocketsHttpHandler Handler = new()
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(2),
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
        MaxConnectionsPerServer = 50
    };

    public static readonly HttpClient Client = new(Handler, disposeHandler: false)
    {
        BaseAddress = new Uri("http://127.0.0.1:5199"),
        Timeout = TimeSpan.FromSeconds(30)
    };
}

public static class Program
{
    public static async Task Main() =>
        Console.WriteLine(await Http.Client.GetStringAsync("/fast"));
}

With PooledConnectionLifetime set, a connection is not reused after two minutes; the next request opens a new one and resolves the name again. I did not simulate a DNS change for this post, so I can only show the configuration. The behavior is described in Microsoft's HttpClient guidelines. Pass disposeHandler: false if you create more than one client on the same handler.

This static approach is fine for libraries and console tools without dependency injection. In ASP.NET Core apps, use the factory.

IHttpClientFactory: Named and Typed Clients

AddHttpClient is in Microsoft.Extensions.Http, which ASP.NET Core already references. Both styles in one file:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;

var services = new ServiceCollection();

// Named client
services.AddHttpClient("backend", c =>
{
    c.BaseAddress = new Uri("http://127.0.0.1:5199");
    c.Timeout = TimeSpan.FromSeconds(10);
});

// Typed client
services.AddHttpClient<CatalogClient>(c => c.BaseAddress = new Uri("http://127.0.0.1:5199"));

using var sp = services.BuildServiceProvider();

var named = sp.GetRequiredService<IHttpClientFactory>().CreateClient("backend");
Console.WriteLine($"named: {await named.GetStringAsync("/fast")}");

var typed = sp.GetRequiredService<CatalogClient>();
Console.WriteLine($"typed: {await typed.PingAsync()}");

var options = sp.GetRequiredService<IOptionsMonitor<HttpClientFactoryOptions>>();
Console.WriteLine($"HandlerLifetime: {options.Get("backend").HandlerLifetime}");

public sealed class CatalogClient(HttpClient http)
{
    public Task<string> PingAsync() => http.GetStringAsync("/fast");
}

Output: named: ok, typed: ok and HandlerLifetime: 00:02:00. Two minutes is the default lifetime of a pooled handler. After that, newly created clients get a fresh handler (and new connections), and the old handler is disposed once nothing uses it. Typed clients are my default: the base address and timeout live in one place, and the class gets a normal constructor dependency. If you are new to service lifetimes, the dependency injection guide explains transient, scoped and singleton, which matters in the next section.

The Captive Typed Client in a Singleton

Typed clients are registered as transient. Inject one into a singleton and the singleton keeps that one HttpClient, and its handler, for the life of the app. The factory can't rotate it. I made the handler lifetime one second and asked the server which TCP source port each call came from:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddHttpClient<PortClient>(c => c.BaseAddress = new Uri("http://127.0.0.1:5199"))
        .ConfigurePrimaryHttpMessageHandler((handler, _) =>
            Console.WriteLine($"new {handler.GetType().Name}, PooledConnectionLifetime = " +
                              $"{((SocketsHttpHandler)handler).PooledConnectionLifetime}"))
        .SetHandlerLifetime(TimeSpan.FromSeconds(1));   // short, to make the effect visible
services.AddSingleton<PriceService>();                  // singleton captures the typed client

using var sp = services.BuildServiceProvider(new ServiceProviderOptions
{
    ValidateScopes = true,
    ValidateOnBuild = true
});

var singleton = sp.GetRequiredService<PriceService>();
for (int i = 1; i <= 3; i++)
{
    var fresh = sp.GetRequiredService<PortClient>();
    Console.WriteLine($"call {i}: singleton port {await singleton.Client.GetPortAsync()}, " +
                      $"fresh typed client port {await fresh.GetPortAsync()}");
    await Task.Delay(TimeSpan.FromSeconds(3));
}

public sealed class PortClient(HttpClient http)
{
    // The server returns the client's TCP source port, i.e. which connection was used.
    public Task<string> GetPortAsync() => http.GetStringAsync("/port");
}

public sealed class PriceService(PortClient client)
{
    public PortClient Client { get; } = client;
}
new SocketsHttpHandler, PooledConnectionLifetime = 00:00:01
call 1: singleton port 60638, fresh typed client port 60638
new SocketsHttpHandler, PooledConnectionLifetime = 00:00:01
call 2: singleton port 60640, fresh typed client port 60641
new SocketsHttpHandler, PooledConnectionLifetime = 00:00:01
call 3: singleton port 60642, fresh typed client port 60643

Three things I observed:

  • No warning anywhere. The build had 0 warnings, and ValidateScopes plus ValidateOnBuild did not complain. Scope validation only catches scoped services in singletons; a transient typed client is allowed.
  • The fresh typed client got a new handler on each call after the first, as expected with a 1-second lifetime. The singleton never did: only three handlers were created, and the singleton's client kept the first one.
  • The singleton still used a new connection each time (ports 60638, 60640, 60642). On .NET 10 the factory's default primary handler is a SocketsHttpHandler whose PooledConnectionLifetime is set to the handler lifetime (it printed 00:00:01 here, and 00:02:00 with the defaults in a separate run). So the captured client still recycles connections and picks up DNS changes.

The old "stale DNS forever" consequence is therefore gone in this setup, but the captured handler chain is still never replaced or disposed, and any delegating handlers in it keep their state. In a singleton, inject IHttpClientFactory and call CreateClient when you need one, or register the typed client with a long-lived SocketsHttpHandler on purpose.

The 100-Second Timeout: Exact Exception

HttpClient.Timeout defaults to 100 seconds. The error everyone searches for is "The request was canceled due to the configured HttpClient.Timeout of 100 seconds elapsing". To get it without waiting 100 seconds I set 2 seconds and called the slow endpoint:

using System.Diagnostics;

var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
var sw = Stopwatch.StartNew();
try
{
    await client.GetStringAsync("http://127.0.0.1:5199/slow?ms=5000");
}
catch (TaskCanceledException ex)
{
    Console.WriteLine($"After {sw.Elapsed.TotalSeconds:F1} s");
    Console.WriteLine($"{ex.GetType().FullName}: {ex.Message}");
    Console.WriteLine($"Inner: {ex.InnerException?.GetType().FullName}: {ex.InnerException?.Message}");
}
After 2.1 s
System.Threading.Tasks.TaskCanceledException: The request was canceled due to the configured HttpClient.Timeout of 2 seconds elapsing.
Inner: System.TimeoutException: The operation was canceled.

The exception is a TaskCanceledException, not a TimeoutException; since .NET 5 the inner exception is a TimeoutException. The server log showed the request aborted by the client about two seconds after it started. With the default you get the same message with "100 seconds".

Timeout or Caller Cancellation?

A canceled request (the user closed the page, the app is shutting down) also throws TaskCanceledException. Use the inner exception to tell them apart:

var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };

await Call(TimeSpan.FromSeconds(1));   // caller gives up first
await Call(TimeSpan.FromSeconds(10));  // HttpClient.Timeout fires first

async Task Call(TimeSpan callerLimit)
{
    using var cts = new CancellationTokenSource(callerLimit);
    try
    {
        await client.GetStringAsync("http://127.0.0.1:5199/slow?ms=5000", cts.Token);
    }
    catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
    {
        Console.WriteLine("HttpClient.Timeout elapsed -> log it, maybe retry");
    }
    catch (TaskCanceledException ex) when (cts.Token.IsCancellationRequested)
    {
        Console.WriteLine($"Caller canceled -> stop quietly (inner: {ex.InnerException?.GetType().Name ?? "null"})");
    }
}

The first call printed Caller canceled -> stop quietly (inner: TaskCanceledException), the second HttpClient.Timeout elapsed -> log it, maybe retry. So on .NET 10, ex.InnerException is TimeoutException is a reliable test for the client timeout.

Per-Request Timeouts with CancellationTokenSource

HttpClient.Timeout applies to every request of that client. For one slow endpoint, keep the client timeout infinite and put the limit on the request:

var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };

Console.WriteLine(await GetWithTimeout("/slow?ms=500", TimeSpan.FromSeconds(2)));
try
{
    await GetWithTimeout("/slow?ms=5000", TimeSpan.FromSeconds(2));
}
catch (TimeoutException ex)
{
    Console.WriteLine(ex.Message);
}

async Task<string> GetWithTimeout(string path, TimeSpan timeout, CancellationToken ct = default)
{
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
    cts.CancelAfter(timeout);
    try
    {
        return await client.GetStringAsync("http://127.0.0.1:5199" + path, cts.Token);
    }
    catch (OperationCanceledException) when (!ct.IsCancellationRequested)
    {
        throw new TimeoutException($"GET {path} took longer than {timeout.TotalSeconds} s");
    }
}

Output: slept 500 ms, then GET /slow?ms=5000 took longer than 2 s. The linked source keeps the caller's token working, and the when filter only turns our own cancellation into a TimeoutException.

Retries with Microsoft.Extensions.Http.Resilience

Install it with dotnet add package Microsoft.Extensions.Http.Resilience; restore picked version 10.10.0, which brings Polly.Core 8.4.2. AddStandardResilienceHandler() adds a rate limiter, a total timeout, retries, a circuit breaker and a per-attempt timeout. I printed the defaults from new HttpStandardResilienceOptions():

StrategyDefault
Retry3 retries, exponential backoff, base delay 2 s, jitter on, honors Retry-After
Attempt timeout10 s
Total request timeout30 s
Circuit breakerfailure ratio 0.1, minimum throughput 100, sampling 30 s, break 5 s
Rate limiter1000 concurrent, queue 0

According to the docs, retries cover 5xx, 408, 429, HttpRequestException and attempt timeouts; in my tests 503, 429 and attempt timeouts were retried. Here is a typed client with the standard handler against an endpoint that fails twice:

using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

var builder = Host.CreateApplicationBuilder();
builder.Logging.ClearProviders();
builder.Logging.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss.fff "; });
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);

builder.Services.AddHttpClient<OrdersClient>(c => c.BaseAddress = new Uri("http://127.0.0.1:5199"))
                .AddStandardResilienceHandler();

using var host = builder.Build();
var orders = host.Services.GetRequiredService<OrdersClient>();

var sw = Stopwatch.StartNew();
var response = await orders.Http.GetAsync("/flaky/a?failures=2");
Console.WriteLine($"{(int)response.StatusCode} after {sw.Elapsed.TotalSeconds:F1} s");

public sealed class OrdersClient(HttpClient http)
{
    public HttpClient Http { get; } = http;
}
09:37:09.465 warn: Polly[3] Execution attempt. Source: 'OrdersClient-standard//Standard-Retry', Operation Key: '', Result: '503', Handled: 'True', Attempt: '0', Execution Time: 127.5588ms
09:37:09.482 warn: Polly[0] Resilience event occurred. EventName: 'OnRetry', Source: 'OrdersClient-standard//Standard-Retry', Operation Key: '', Result: '503'
09:37:11.699 warn: Polly[3] Execution attempt. Source: 'OrdersClient-standard//Standard-Retry', Operation Key: '', Result: '503', Handled: 'True', Attempt: '1', Execution Time: 3.8953ms
09:37:11.699 warn: Polly[0] Resilience event occurred. EventName: 'OnRetry', Source: 'OrdersClient-standard//Standard-Retry', Operation Key: '', Result: '503'
09:37:13.856 info: Polly[3] Execution attempt. Source: 'OrdersClient-standard//Standard-Retry', Operation Key: '', Result: '200', Handled: 'False', Attempt: '2', Execution Time: 2.6514ms
200 after 4.7 s

The server received the three requests at 09:37:09.425, 09:37:11.698 and 09:37:13.854: two waits of about 2.2 seconds, then success. The caller only saw the 200. The retry log lines come from Polly at Warning level, so they show up in your normal logs without extra setup. Watch for them in production; a lot of OnRetry events means a dependency is unhealthy even when every request eventually succeeds.

When all retries fail, the handler does not throw; you get the last response back (the POST test below received its final 503), and your own status code check decides what happens next.

Retry-After on 429 Is Honored

using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddHttpClient("api", c => c.BaseAddress = new Uri("http://127.0.0.1:5199"))
        .AddStandardResilienceHandler();
using var sp = services.BuildServiceProvider();
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("api");

foreach (var seconds in new[] { 1, 4 })
{
    var sw = Stopwatch.StartNew();
    var response = await client.GetAsync($"/throttle/r{seconds}?retryAfter={seconds}");
    Console.WriteLine($"Retry-After: {seconds} -> {(int)response.StatusCode} after {sw.Elapsed.TotalSeconds:F2} s");
}

Output: Retry-After: 1 -> 200 after 1.25 s and Retry-After: 4 -> 200 after 4.01 s. Server side, the retry came 1.03 s and 4.01 s after the 429. Without the header the first retry would wait about 2 seconds, so the 4-second case shows that the header wins over the default backoff. This is exactly what you want against an API with ASP.NET Core rate limiting, which can be set up to send Retry-After with its 429 responses.

HttpClient.Timeout vs. the Resilience Timeouts

The resilience pipeline runs inside HttpClient, so HttpClient.Timeout wraps all retries. A 5-second client timeout would cut off a pipeline with a 10-second attempt timeout and a 30-second total budget. I expected to show that. What I got was different:

using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();

// A: Timeout set in AddHttpClient, then the standard handler
services.AddHttpClient("A", c =>
{
    c.BaseAddress = new Uri("http://127.0.0.1:5199");
    c.Timeout = TimeSpan.FromSeconds(5);
})
.AddStandardResilienceHandler();

// B: Timeout set after the standard handler was added
var b = services.AddHttpClient("B", c => c.BaseAddress = new Uri("http://127.0.0.1:5199"));
b.AddStandardResilienceHandler();
b.ConfigureHttpClient(c => c.Timeout = TimeSpan.FromSeconds(5));

using var sp = services.BuildServiceProvider();
var factory = sp.GetRequiredService<IHttpClientFactory>();

foreach (var name in new[] { "A", "B" })
{
    var client = factory.CreateClient(name);
    Console.WriteLine($"{name}: HttpClient.Timeout = {client.Timeout}");
    var sw = Stopwatch.StartNew();
    try
    {
        await client.GetAsync("/slow?ms=60000");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"{name}: {sw.Elapsed.TotalSeconds:F1} s -> {ex.GetType().FullName}");
        Console.WriteLine($"   {ex.Message}");
    }
}
A: HttpClient.Timeout = -00:00:00.0010000
A: 30.0 s -> Polly.Timeout.TimeoutRejectedException
   The operation didn't complete within the allowed timeout of '00:00:30'.
B: HttpClient.Timeout = 00:00:05
B: 5.0 s -> System.Threading.Tasks.TaskCanceledException
   The request was canceled due to the configured HttpClient.Timeout of 5 seconds elapsing.

In case A the standard handler replaced my 5-second timeout with Timeout.InfiniteTimeSpan (shown as -00:00:00.0010000). The server saw three attempts: two cut at 10 seconds by the attempt timeout, the third cut when the 30-second total ran out. The caller got TimeoutRejectedException from Polly, not TaskCanceledException, so catch (TaskCanceledException) blocks written for the old timeout no longer match.

In case B, where ConfigureHttpClient runs after AddStandardResilienceHandler, my timeout survived. The request died at 5 seconds with the classic message and was not retried: the server saw a single attempt.

My rule: with a resilience handler, set HttpClient.Timeout to Timeout.InfiniteTimeSpan yourself (or leave it to the standard handler) and configure AttemptTimeout and TotalRequestTimeout. Then catch TimeoutRejectedException for timeouts and OperationCanceledException for caller cancellation.

A Custom Pipeline with AddResilienceHandler

When the standard defaults don't fit, build the pipeline yourself. Order matters: strategies added first are outermost, so the first timeout is the total and the last one applies per attempt:

using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Polly;

var services = new ServiceCollection();
services.AddHttpClient("payments", c => c.BaseAddress = new Uri("http://127.0.0.1:5199"))
    .AddResilienceHandler("payments-pipeline", pipeline =>
    {
        pipeline.AddTimeout(TimeSpan.FromSeconds(10));           // total
        pipeline.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 4,
            Delay = TimeSpan.FromMilliseconds(200),
            BackoffType = DelayBackoffType.Exponential,
            UseJitter = true,
            OnRetry = args =>
            {
                Console.WriteLine($"retry {args.AttemptNumber + 1} after {args.RetryDelay.TotalMilliseconds:F0} ms " +
                                  $"({(int?)args.Outcome.Result?.StatusCode})");
                return default;
            }
        });
        pipeline.AddTimeout(TimeSpan.FromSeconds(2));            // per attempt
    });

using var sp = services.BuildServiceProvider();
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("payments");

var sw = Stopwatch.StartNew();
var response = await client.GetAsync("/flaky/c?failures=3");
Console.WriteLine($"{(int)response.StatusCode} after {sw.Elapsed.TotalSeconds:F1} s");

Output: retry 1 after 242 ms (503), retry 2 after 143 ms (503), retry 3 after 595 ms (503), then 200 after 1.2 s. The delays are exponential with jitter, which is why they aren't 200, 400, 800. The jitter spreads retries from many clients so they don't hit a recovering service at the same moment.

Retrying a POST Can Create Duplicate Orders

The standard handler retries every HTTP method by default. Against an endpoint that always returns 503:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;

var services = new ServiceCollection();
services.AddHttpClient("default", c => c.BaseAddress = new Uri("http://127.0.0.1:5199"))
        .AddStandardResilienceHandler();
services.AddHttpClient("safe", c => c.BaseAddress = new Uri("http://127.0.0.1:5199"))
        .AddStandardResilienceHandler(o => o.Retry.DisableForUnsafeHttpMethods());

using var sp = services.BuildServiceProvider();
var factory = sp.GetRequiredService<IHttpClientFactory>();

foreach (var name in new[] { "default", "safe" })
{
    var response = await factory.CreateClient(name).PostAsync($"/orders/{name}", content: null);
    var hits = await factory.CreateClient(name).GetStringAsync($"/hits/orders-{name}");
    Console.WriteLine($"{name}: {(int)response.StatusCode}, server received the POST {hits} time(s)");
}

Output: default: 503, server received the POST 4 time(s) and safe: 503, server received the POST 1 time(s). If the server had created the order and then failed (or timed out while answering), the default client would have created four orders. Use DisableForUnsafeHttpMethods() for POST, PATCH and DELETE, or make the endpoint idempotent with an idempotency key so a retry is harmless.

EnsureSuccessStatusCode and Problem Details

EnsureSuccessStatusCode() throws an HttpRequestException whose message contains only the status code. The response body, usually the useful part, is lost:

using System.Net.Http.Json;

var client = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5199") };

// 1) EnsureSuccessStatusCode throws away the body
try
{
    var r = await client.GetAsync("/problem");
    r.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"{ex.GetType().Name}: {ex.Message} (StatusCode = {ex.StatusCode})");
}

// 2) Read the problem details first
var response = await client.GetAsync("/problem");
if (!response.IsSuccessStatusCode)
{
    var problem = response.Content.Headers.ContentType?.MediaType == "application/problem+json"
        ? await response.Content.ReadFromJsonAsync<Problem>()
        : null;
    Console.WriteLine($"{(int)response.StatusCode} {problem?.Title}: {problem?.Detail}");
}

public sealed record Problem(string? Type, string? Title, int? Status, string? Detail);
HttpRequestException: Response status code does not indicate success: 400 (Bad Request). (StatusCode = BadRequest)
400 Invalid order: Quantity must be between 1 and 100.

Since .NET 5 the exception has a StatusCode property, so you can filter on it (catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)). For APIs that return application/problem+json, read the body first and throw your own exception with the title and detail in it. Your logs will thank you.

Checklist

  • No new HttpClient() per request. Use AddHttpClient (typed clients) or one static client on a SocketsHttpHandler with PooledConnectionLifetime.
  • Don't capture typed clients in singletons; inject IHttpClientFactory there.
  • Catch timeouts as TaskCanceledException with an inner TimeoutException (plain client) or TimeoutRejectedException (resilience handler).
  • With a resilience handler, let AttemptTimeout and TotalRequestTimeout own the timeouts.
  • Disable retries for unsafe methods unless the endpoint is idempotent.
  • Log the problem details body before you throw.

To check a dependency's status on the other side, see ASP.NET Core health checks.

FAQ

Should I dispose HttpClient? Clients from IHttpClientFactory can be disposed; it doesn't close the pooled connections (0 TIME_WAIT in my test). A new HttpClient() owns its handler, so disposing it closes the connections, which is why you shouldn't create one per request.

Is the default HttpClient timeout 100 seconds in .NET 10? Yes for a plain client. A client with AddStandardResilienceHandler() had an infinite HttpClient.Timeout in my test, and the pipeline's 30-second total timeout applies instead.

Does the standard resilience handler retry POST requests? Yes. My POST was sent 4 times (1 + 3 retries) until I called DisableForUnsafeHttpMethods().

This article is part of the ASP.NET Core tutorials guide (Web API, middleware, security, performance and deployment).

References: Microsoft: HttpClient guidelines · Microsoft: IHttpClientFactory with .NET · Microsoft: Build resilient HTTP apps

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...