Skip to content
Rehan Akbar
Rehan Akbar
All articles
October 13, 2026·29 min
  • #dotnet
  • #csharp
  • #resilience
  • #microservices
  • #distributed-systems
  • #polly
  • #performance
  • #software-architecture

Building Resilient HTTP Integrations in .NET 10: Retries, Timeouts, Circuit Breakers, and Idempotency

A production engineering guide to building resilient service-to-service HTTP integrations in .NET 10. Master Microsoft.Extensions.Http.Resilience, Polly v8 pipeline ordering, retry amplification traps, ambiguous write failures, EF Core idempotency keys, and connection pool dynamics.

·
29 min read
·
··· reads
On this page

A production HTTP integration is not reliable because it has a retry policy. It is reliable when failure is bounded, observable, and safe to recover from.

Every backend engineer has written this pattern. It passes code review. It passes unit tests. It passes local integration tests:

csharp
// The naive retry anti-pattern: blinds itself to latency, concurrency, and mutation safety
try
{
    return await httpClient.PostAsJsonAsync("/api/payments", command, cancellationToken);
}
catch (HttpRequestException)
{
    return await httpClient.PostAsJsonAsync("/api/payments", command, cancellationToken);
}

Then, under peak production load, your downstream payment gateway suffers a brief database lock stall. Latency climbs from 80ms to 4.5 seconds.

Instead of shielding the dependency, your application instances multiply their traffic by firing immediate retries. The downstream service collapses into a complete outage. Worse, your callers receive socket exceptions on operations that already committed on the remote database—silently double-charging customers and corrupting financial records.

The Axiom of Remote Unreliability

An in-memory method invocation either completes or throws within a single deterministic process boundary. An outbound HTTP call crosses eight independent failure domains: the local thread pool, OS socket allocations, connection pooling, DNS resolution, network switches and proxies, load balancers, the downstream service runtime, and the downstream persistence boundary. A network error on the return leg is not a failure of the operation—it is an absence of confirmation.

Downstream Outage Blast Radius (Payment Gateway Database Stall Benchmark)Zero Thundering Herds
BeforeUnbounded Retries (4,200 req/s load amplification, 100% thread pool exhaustion)
OptimizedBounded Pipeline + Circuit Breaker (Fail-fast in 1.2s, 0 duplicate charges, 98% capacity preserved)

In a production-scale payment and inventory integration test, replacing naive client-side retry loops with Microsoft.Extensions.Http.Resilience (attempt budget, cumulative timeout, jittered exponential backoff, bulkhead concurrency limits, and database idempotency keys) eliminated cascading node evictions and preserved service availability during a 45-second downstream dependency stall.

Production Bounded Pipeline
  • ✓Strict separation between Attempt Timeout and Cumulative Total Request Budget
  • ✓Jittered exponential backoff prevents synchronized thundering herd retry waves
  • ✓Circuit breaker fails fast once failure ratio crosses threshold, isolating unhealthy downstream
  • ✓Unsafe HTTP methods (POST/PATCH) retry only when guarded by durable Idempotency Keys
  • ✓Outbound bulkhead rate limiters bound concurrency and protect thread pool availability
Naive Try/Catch Retry Anti-Pattern
  • ✗Unbounded retry loops multiply traffic exactly when downstream dependencies are failing
  • ✗Fixed delays cause synchronized thundering herds that immediately re-crash recovering pods
  • ✗Repeated requests continue pounding unavailable services, wasting socket handles and memory
  • ✗Blind retries on state-changing endpoints cause duplicate billing and ghost data corruption
  • ✗Unconstrained outbound concurrency exhausts client connection pools and starves local threads

1. The Outbound Failure Taxonomy

Before adding resilience packages or configuring policies, you must classify what can fail. In distributed systems, failure is not a boolean.

A service-to-service HTTP call can fail across several distinct semantic categories:

Scroll horizontally to see all columns.

Failure ModeStatus Code / ExceptionTransient LikelihoodDefault Safe ActionMutation Safety
Connection Refused / ResetSocketException, HttpRequestExceptionModerate (often host restarting)Retry with jittered backoffSafe for idempotent operations; requires key for writes
Attempt TimeoutTimeoutRejectedException, TaskCanceledExceptionHighRetry if cumulative budget permitsUnsafe for blind writes (remote side may have committed)
Request TimeoutHTTP 408HighRetry with backoffSafe for idempotent operations
Rate LimitingHTTP 429 Too Many RequestsHigh (throttled)Respect Retry-After headerSafe to retry after specified duration
Gateway / Proxy ErrorsHTTP 502, 503, 504High (temporary unavailability)Retry with backoff & circuit breakerSafe for idempotent operations; requires key for writes
Internal Server ErrorHTTP 500Context-dependentDo NOT retry blindlyOften deterministic (e.g., NullReference, schema bug)
Client Input ValidationHTTP 400, 422Zero (deterministic)Never retryImmediate client failure
Authentication / AuthzHTTP 401, 403Zero (without token refresh)Never retry inlineRoute to token refresh workflow
Resource Not FoundHTTP 404Zero (unless eventual consistency)Never retryReturn null / NotFound
The HTTP 500 Trap

Treating HTTP 500 Internal Server Error as automatically transient is a widespread anti-pattern. If a downstream service throws an unhandled NullReferenceException or a database foreign key constraint violation on a specific payload, retrying that identical payload 3 times generates pure CPU and network waste. Only retry HTTP 500 if the downstream service contract explicitly documents transient internal error conditions.


2. The Modern .NET 10 Resilience Architecture

In earlier versions of .NET, developers assembled static policies using standalone Polly v7 packages (Policy.Handle<...>().WaitAndRetryAsync(...)). While powerful, this approach lacked native integration with IHttpClientFactory, lacked unified metrics, and led to ad-hoc, poorly ordered policy chains.

In modern .NET 10, resilience is fully standardized via:

bash
dotnet add package Microsoft.Extensions.Http.Resilience

Built upon the redesigned Polly v8 engine, Microsoft.Extensions.Http.Resilience introduces non-allocating, telemetry-native Resilience Pipelines directly integrated into the HttpClient messaging handler stack.

Registering the Standard Resilience Handler

csharp
using Microsoft.Extensions.Http.Resilience;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services
    .AddHttpClient<IInventoryClient, InventoryClient>(client =>
    {
        client.BaseAddress = new Uri("https://inventory.internal.service");
    })
    .AddStandardResilienceHandler();

The Standard Pipeline Architecture & Strategy Order

AddStandardResilienceHandler() is not a random collection of features; it executes an opinionated, mathematically ordered pipeline. When an outbound request is dispatched through the client, it traverses six distinct layers from outermost to innermost:

Architecture Diagram·Mermaid
Rendering architecture diagram...

The architectural rationale for this strict sequencing is critical:

  1. Rate Limiter (Bulkhead) sits outermost: Before spending any timeout budgets or acquiring execution threads, the request must secure a concurrency permit. If the outbound bulkhead is saturated, the request fails fast immediately.
  2. Total Request Timeout wraps the Retry loop: This guarantees that the cumulative execution time—including all initial attempts, transient retry attempts, and backoff delays—never exceeds a hard wall-clock budget.
  3. Retry wraps the Circuit Breaker: When an attempt fails, the retry strategy evaluates whether to initiate a new attempt. Each new attempt must re-evaluate the circuit breaker before reaching the network.
  4. Circuit Breaker wraps the Attempt Timeout: If recent failure ratios indicate the downstream service is dead, the circuit breaker opens and short-circuits the call immediately, preventing the allocation of an attempt timer.
  5. Attempt Timeout wraps the Transport: A strict ceiling enforced on a single physical network round-trip.

Documented Framework Defaults

Microsoft ships the standard handler with the following baseline defaults:

Scroll horizontally to see all columns.

StrategyDefault SettingArchitectural Rationale
Total Request Timeout30 secondsHard ceiling for the entire multi-attempt lifecycle
Retry Strategy3 retries, Exponential backoff, 2s base delay, Jitter enabledRecovers from short momentary blips
Circuit Breaker10% failure ratio, MinimumThroughput: 100, SamplingDuration: 30s, BreakDuration: 5sIsolates dependency under sustained distress
Attempt Timeout10 secondsCaps individual socket read/write operations
Rate Limiter1,000 permits, Zero queuePrevents local thread pool saturation
Framework Defaults are Not Production Architectures

These defaults are designed to prevent catastrophic runtime exceptions in generic applications. In a low-latency microservice architecture, a 30-second total request timeout and a 10-second attempt timeout are absurdly high. If your user-facing API has a 2-second SLA, waiting 30 seconds for an inventory call will cause client timeouts, cascading queue buildups, and thread pool starvation. You must tune these values to your specific service level objectives.


3. The Mechanics of Retry Amplification

Retries are frequently described as a self-healing mechanism. In distributed systems, this is a dangerous half-truth.

Every retry is additional load injected into a struggling system.

Consider an architecture where upstream service A dispatches 1,000 requests/second to downstream service B. Service B experiences a momentary CPU spike or database connection pool saturation, causing 50% of requests to fail:

plaintext
Original Inbound Load:            1,000 req/s
Failed Requests:                    500 req/s
Retries Generated (Attempt 2):     +500 req/s
Combined Load on Service B:       1,500 req/s

If Service B was already struggling at 1,000 req/s, hitting it with 1,500 req/s guarantees its total collapse. The failure rate climbs to 80%, triggering a second wave of retries:

plaintext
Third Attempt Wave:                +800 req/s
Combined Load on Service B:       2,300 req/s
Architecture Diagram·Mermaid
Rendering architecture diagram...

This positive feedback loop is known as a retry storm or cascading stampede.

Why Exponential Backoff is Mandatory

Immediate retries (attempt 1 -> fail -> immediate attempt 2) compress retry volume into milliseconds, starving the downstream dependency of the idle CPU cycles it needs to drain queues, run garbage collection, or complete database failovers.

Exponential backoff spaces attempts across expanding intervals:

text
Delay(attempt) = BaseDelay * 2^(attempt - 1)

With a 200ms base delay, subsequent attempts wait 200ms, 400ms, 800ms, providing the downstream system breathing room to recover.

The Thundering Herd & The Need for Jitter

Exponential backoff alone is insufficient in clustered environments.

Imagine 200 instances of your Web API all fail a call to an identity provider at timestamp T0 due to a network blip. If all 200 instances use deterministic exponential backoff (2s, 4s, 8s), their retries will synchronize into periodic shockwaves:

text
T0 + 0s:  200 requests fail simultaneously
T0 + 2s:  200 retry requests strike the provider simultaneously
T0 + 6s:  200 retry requests strike the provider simultaneously

Instead of smooth recovery, the provider experiences periodic hammer blows that repeatedly re-trip its circuit breakers.

Jitter breaks synchronization by injecting non-deterministic entropy into the backoff calculation:

text
Delay_jittered = Random(0, Delay_exponential)

With jitter enabled, the 200 instances scatter their retries evenly across the time window, transforming destructive spikes into a smooth, manageable background trickle.


4. The Dual-Budget Timeout Model

In naive implementations, developers configure a single timeout on HttpClient.Timeout. This creates architectural ambiguity: does the timeout apply to a single network attempt, or does it cover the entire operation including retries?

In modern .NET 10 resilience pipelines, timeouts are modeled as two distinct, coordinated boundaries:

  1. Attempt Timeout: The maximum duration permitted for a single physical HTTP attempt over the wire.
  2. Total Request Timeout: The absolute cumulative wall-clock budget permitted for the logical operation, including all attempts, backoff delays, and local handler overhead.
plaintext
|<--------------------------------- TOTAL REQUEST BUDGET (3.0s) --------------------------------->|
 
|--- Attempt 1 (800ms) ---|-- Backoff (200ms) --|--- Attempt 2 (800ms) ---|-- Backoff (400ms) --|--- Attempt 3 (800ms) ---|

If Attempt 1 takes 800ms and fails, and Attempt 2 takes 800ms and fails, Attempt 3 has only 800ms remaining before the Total Request Timeout of 3.0 seconds terminates the pipeline, regardless of how many retry attempts were initially configured.

Deriving Dependency Budgets from Caller SLAs

Dependency budgets must be derived systematically from your caller's service level objective (SLO):

Architecture Diagram·Mermaid
Rendering architecture diagram...

Never configure retry counts and attempt timeouts that, when multiplied, exceed your total request budget. Retries must fit inside the budget; they cannot conjure time out of thin air.


5. Circuit Breakers: Failure Isolation, Not Service Repair

A circuit breaker tracks execution outcomes over a rolling time window and temporarily halts outbound traffic when failures exceed a configured threshold.

The Three-State Machine

Architecture Diagram·Mermaid
Rendering architecture diagram...
  • Closed (Normal Operation): Requests pass freely to the downstream dependency. Metrics are recorded into a rolling ring buffer.
  • Open (Failing Fast): When the failure ratio crosses the configured threshold (e.g., 25% failures over 30 seconds with at least 20 calls), the breaker trips. All subsequent calls throw BrokenCircuitException immediately in under 1 millisecond. No network sockets are allocated.
  • Half-Open (Canary Probing): When BreakDuration elapses, the breaker permits a limited number of trial requests to probe whether the downstream service has genuinely recovered. If the probe succeeds, the breaker resets to Closed; if it fails, it trips back to Open for another break period.

Circuit Breakers Are Not Health Checks

A circuit breaker is local client state, not an authoritative platform health monitor.

A breaker may open for your service while remaining closed for other clients because:

  • Your service is sending a malformed payload that triggers downstream exceptions.
  • Your specific route or API key is being throttled by an upstream gateway.
  • A network partition exists specifically between your availability zone and the downstream cluster.
  • Your instance has not handled enough requests to satisfy MinimumThroughput.

Use health checks (e.g., /healthz probes) for infrastructure orchestration and container restarts. Use circuit breakers inside HttpClient to prevent local thread pool starvation and avoid kicking a downstream service while it is down.


6. HTTP Semantics vs. Business Semantics: The POST Trap

This is the single most critical reliability concern when configuring retries.

Under RFC 9110:

  • Safe Methods (GET, HEAD, OPTIONS, TRACE): Read-only semantics. They do not alter server state and are inherently safe to repeat.
  • Idempotent Methods (PUT, DELETE): State-changing, but executing identical requests multiple times results in the identical final resource state as executing it once.
  • Non-Idempotent Methods (POST, PATCH): State-changing and non-idempotent by default. Repeating a request may create duplicate side effects (e.g., duplicate orders, duplicate billing).

The Critical .NET 10 Gotcha

By default, Microsoft's AddStandardResilienceHandler() retries ALL HTTP methods—including POST and PATCH!

If you register a standard resilience handler on a payment or order processing client without modifying its options, you are actively configuring your application to automatically retry failed POST requests:

csharp
// DANGER: By default, this retries POST, PATCH, PUT, and DELETE!
builder.Services
    .AddHttpClient<IPaymentClient, PaymentClient>()
    .AddStandardResilienceHandler();

To prevent automatic retries on state-changing operations, you must explicitly disable unsafe methods:

csharp
builder.Services
    .AddHttpClient<IPaymentClient, PaymentClient>(client =>
    {
        client.BaseAddress = new Uri("https://payments.internal.service");
    })
    .AddStandardResilienceHandler(options =>
    {
        // Enforce RFC 9110 safety: Disables automatic retry for POST, PATCH, PUT, DELETE
        options.Retry.DisableForUnsafeHttpMethods();
    });

Alternatively, if you know PUT and DELETE endpoints in your service are strictly idempotent, disable only POST and PATCH:

csharp
builder.Services
    .AddHttpClient<IUserProfileClient, UserProfileClient>()
    .AddStandardResilienceHandler(options =>
    {
        options.Retry.DisableFor(HttpMethod.Post, HttpMethod.Patch);
    });

7. The Ambiguous-Success Problem

Why is retrying a POST request without coordination so dangerous?

Consider what happens over a physical TCP network:

Architecture Diagram·Mermaid
Rendering architecture diagram...

The client dispatched the charge request. The server processed the payment, deducted inventory, and committed the database transaction. But before the HTTP 201 Created response packet traversed the return gateway, the TCP connection suffered a socket reset.

The client observes a network timeout. But the business operation succeeded.

If the client retries blindly, it creates a second transaction. This is not a failure of network resilience; it is a fundamental flaw in the business protocol.


8. Production-Grade Server-Side Idempotency with EF Core

To make state-changing operations (POST) safe to retry, the client must attach a unique Idempotency-Key header representing the logical business operation:

http
POST /api/v1/payments HTTP/1.1
Host: payments.internal.service
Idempotency-Key: 7b8d0c2e-4b68-45a9-b003-882413a17e0b
Content-Type: application/json
 
{
  "orderId": "ORD-2026-9041",
  "amount": 149.99,
  "currency": "USD"
}

The server persists this key atomically alongside the business state mutation. If a retry arrives with the identical key, the server skips execution and replays the cached original response.

The Check-Then-Insert Race Condition

Never implement idempotency using a naive if (!await db.Records.AnyAsync(key)) { await Insert(); } check! Under concurrent network retries or high-throughput distributed callers, two identical requests will both pass the AnyAsync check simultaneously before either commits. The only durable concurrency boundary is a unique database constraint enforced by the persistence engine.

Database Schema with Unique Constraints

csharp
public sealed class IdempotencyRecord
{
    public required string Key { get; init; }
    public required string Operation { get; init; }
    public required string RequestHash { get; init; }
    public int StatusCode { get; set; }
    public string? ResponseBody { get; set; }
    public DateTimeOffset CreatedAtUtc { get; init; }
}

Entity Framework Core Configuration

csharp
public sealed class PaymentsDbContext(DbContextOptions<PaymentsDbContext> options)
    : DbContext(options)
{
    public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
    public DbSet<Payment> Payments => Set<Payment>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<IdempotencyRecord>(entity =>
        {
            // Composite primary key acts as a hard database-level unique constraint
            entity.HasKey(x => new { x.Key, x.Operation });
 
            entity.Property(x => x.Key)
                .HasMaxLength(128)
                .IsUnicode(false);
 
            entity.Property(x => x.Operation)
                .HasMaxLength(100)
                .IsUnicode(false);
 
            entity.Property(x => x.RequestHash)
                .HasMaxLength(64)
                .IsFixedLength()
                .IsUnicode(false);
 
            entity.HasIndex(x => x.CreatedAtUtc);
        });
    }
}

Atomic Service Implementation Handling Concurrency

csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Npgsql; // Or Microsoft.Data.SqlClient for SQL Server
 
public sealed class PaymentService(PaymentsDbContext db, ILogger<PaymentService> logger)
{
    public async Task<PaymentResponse> ProcessPaymentAsync(
        string idempotencyKey,
        CreatePaymentCommand command,
        CancellationToken cancellationToken)
    {
        const string operation = "ProcessPayment";
        var payloadJson = JsonSerializer.Serialize(command);
        var requestHash = ComputeSha256(payloadJson);
 
        // 1. Fast Path: Check if this operation has already completed
        var existing = await db.IdempotencyRecords
            .AsNoTracking()
            .SingleOrDefaultAsync(
                x => x.Key == idempotencyKey && x.Operation == operation,
                cancellationToken);
 
        if (existing is not null)
        {
            // Validate payload fingerprint to prevent key reuse with altered data
            if (!string.Equals(existing.RequestHash, requestHash, StringComparison.Ordinal))
            {
                logger.LogWarning("Idempotency key {Key} reused with mismatched payload", idempotencyKey);
                throw new IdempotencyConflictException("Idempotency key payload mismatch.");
            }
 
            logger.LogInformation("Returning cached idempotent response for key {Key}", idempotencyKey);
            return new PaymentResponse(existing.StatusCode, existing.ResponseBody!);
        }
 
        // 2. Slow Path: Execute mutation inside an isolated atomic transaction
        await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
 
        try
        {
            var payment = Payment.Create(command.OrderId, command.Amount, command.Currency);
            db.Payments.Add(payment);
 
            var responseBody = JsonSerializer.Serialize(new
            {
                payment.Id,
                payment.OrderId,
                payment.Amount,
                Status = "Processed"
            });
 
            var record = new IdempotencyRecord
            {
                Key = idempotencyKey,
                Operation = operation,
                RequestHash = requestHash,
                StatusCode = StatusCodes.Status201Created,
                ResponseBody = responseBody,
                CreatedAtUtc = DateTimeOffset.UtcNow
            };
 
            db.IdempotencyRecords.Add(record);
 
            await db.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);
 
            return new PaymentResponse(record.StatusCode, record.ResponseBody);
        }
        catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
        {
            // 3. Concurrency Path: Another thread committed the exact same key concurrently
            await transaction.RollbackAsync(cancellationToken);
            logger.LogInformation("Concurrent duplicate detected for key {Key}. Replaying winner.", idempotencyKey);
 
            var winner = await db.IdempotencyRecords
                .AsNoTracking()
                .SingleAsync(
                    x => x.Key == idempotencyKey && x.Operation == operation,
                    cancellationToken);
 
            return new PaymentResponse(winner.StatusCode, winner.ResponseBody!);
        }
    }
 
    private static bool IsUniqueConstraintViolation(DbUpdateException ex)
    {
        // PostgreSQL unique violation error code = 23505; SQL Server = 2627 or 2601
        return ex.InnerException is PostgresException { SqlState: "23505" };
    }
 
    private static string ComputeSha256(string rawData)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawData));
        return Convert.ToHexString(bytes);
    }
}
External Payment Gateways and Cross-System Atomicity

A local database transaction cannot atomically commit an external third-party HTTP call (such as Stripe or Adyen). If your endpoint charges a third-party gateway, you must pass your Idempotency-Key directly into the third-party gateway's HTTP headers. If your local database crashes after the external charge succeeds, the subsequent retry passes the identical key to the gateway, which safely returns the existing charge without double-billing.


9. Cooperative Backoff: Respecting Retry-After

When a downstream service returns HTTP 429 Too Many Requests or HTTP 503 Service Unavailable, it often knows exactly when its capacity will recover. Under RFC 6585 and RFC 9110, it communicates this via the Retry-After header:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/json

Or using an HTTP-date timestamp:

http
HTTP/1.1 503 Service Unavailable
Retry-After: Tue, 13 Oct 2026 09:30:00 GMT

If your client ignores Retry-After and executes its own arbitrary 200ms exponential retry, it violates downstream flow control and gets immediately re-throttled.

In Microsoft.Extensions.Http.Resilience, you enable cooperative backoff with a single property:

csharp
builder.Services
    .AddHttpClient<ICatalogClient, CatalogClient>()
    .AddStandardResilienceHandler(options =>
    {
        // Automatically pauses retry execution until the duration in Retry-After elapses
        options.Retry.ShouldRetryAfterHeader = true;
    });

10. Protecting Downstream Capacity with Concurrency Bulkheads

A downstream service can have 100% operational uptime and still fail under excessive concurrent demand.

Suppose your API Gateway can handle 3,000 active concurrent requests, but downstream Service B is provisioned to process a maximum of 250 concurrent requests. Forwarding all incoming requests unconstrained creates an immediate queuing bottleneck:

Architecture Diagram·Mermaid
Rendering architecture diagram...

When concurrency exceeds capacity:

  1. Downstream request queues fill up.
  2. Latency skyrockets from 50ms to 4,000ms.
  3. Client attempt timeouts fire.
  4. Clients initiate retries, injecting more concurrency into the saturated queue.
  5. The downstream process dies of memory exhaustion or thread starvation.

The rate limiter in AddStandardResilienceHandler() acts as an outbound bulkhead:

csharp
builder.Services
    .AddHttpClient<IInventoryClient, InventoryClient>()
    .AddStandardResilienceHandler(options =>
    {
        // Limit active in-flight requests to 200 with zero queuing
        options.RateLimiter.PermitLimit = 200;
        options.RateLimiter.QueueLimit = 0;
    });

Failing fast locally in under 1 millisecond preserves your thread pool and prevents queuing delays from cascading across the entire application cluster.


11. HttpClient Lifecycle & The Captive Dependency Trap

Resilience is not merely policy configuration; it is deeply tied to socket lifecycle management. In .NET, developers historically encountered two opposing traps:

  1. New HttpClient per request: Calling using var client = new HttpClient() exhausts OS socket handles in the TIME_WAIT state, leading to socket exhaustion under high load.
  2. Static singleton HttpClient forever: Keeping a static instance forever prevents the runtime from respecting DNS changes when container IPs change in Kubernetes or AWS ECS.

The Modern Resolution: IHttpClientFactory vs. SocketsHttpHandler

csharp
// Pattern 1: Typed client managed via IHttpClientFactory (Recommended for ASP.NET Core)
builder.Services.AddHttpClient<IShippingClient, ShippingClient>(client =>
{
    client.BaseAddress = new Uri("https://shipping.internal.service");
});

IHttpClientFactory pools the underlying HttpMessageHandler instances, rotating them automatically every 2 minutes to ensure DNS records are refreshed while reusing sockets to prevent port starvation.

csharp
// Pattern 2: Standalone SocketsHttpHandler with PooledConnectionLifetime (For Daemons/Console Apps)
var handler = new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2), // Forces socket rotation for DNS
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
    MaxConnectionsPerServer = 200
};
 
var client = new HttpClient(handler);
The Captive Typed Client Pitfall

Typed clients registered via AddHttpClient<TClient> are registered as Transient in the dependency injection container. If you inject a typed HttpClient into a Singleton service, that typed client is captured permanently in memory. The factory can no longer rotate its underlying handler, and your application will silently stop honoring DNS record updates!


12. Distributed Multi-Layer Retry Cascades

In distributed microservices, retries are frequently configured independently at every architectural tier:

Architecture Diagram·Mermaid
Rendering architecture diagram...

Calculate the potential physical requests hitting the database for a single user click:

text
2 (Browser) × 2 (Gateway) × 3 (Aggregator) × 2 (Mesh) × 2 (SDK) = 48 physical attempts!

A single transient slowdown at the persistence layer gets multiplied 48 times by the time it cascades through the architecture.

The Single-Owner Architecture Rule

In any distributed service path, designate exactly one architectural layer responsible for retrying a given failure mode. If your API Gateway or Service Mesh handles transient connection retries, disable automatic retries in your C# HttpClient. If your C# typed client implements application-level retries, configure your ingress gateway to pass failures through without retrying.


13. Dedicated Read vs. Write Client Architectures

A widespread architectural mistake is creating a single monolithic client class (e.g., PaymentGatewayClient) that handles both queries and mutations under a single generic resilience policy.

Because read operations and write operations have fundamentally opposing safety profiles, production architectures should separate them into dedicated typed clients:

Architecture Diagram·Mermaid
Rendering architecture diagram...

Read Client Registration

csharp
builder.Services
    .AddHttpClient<IProductCatalogReadClient, ProductCatalogReadClient>(client =>
    {
        client.BaseAddress = new Uri("https://catalog.internal.service");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(2);
        options.AttemptTimeout.Timeout = TimeSpan.FromMilliseconds(600);
 
        options.Retry.MaxRetryAttempts = 2;
        options.Retry.Delay = TimeSpan.FromMilliseconds(100);
        options.Retry.BackoffType = DelayBackoffType.Exponential;
        options.Retry.UseJitter = true;
    });

Write Client Registration

csharp
builder.Services
    .AddHttpClient<IPaymentWriteClient, PaymentWriteClient>(client =>
    {
        client.BaseAddress = new Uri("https://payments.internal.service");
    })
    .AddStandardResilienceHandler(options =>
    {
        options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
        options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
 
        // Never retry unsafe operations automatically
        options.Retry.DisableForUnsafeHttpMethods();
 
        // Tune circuit breaker for critical payment pipeline
        options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
        options.CircuitBreaker.FailureRatio = 0.20;
        options.CircuitBreaker.MinimumThroughput = 15;
        options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(10);
    });

14. End-to-End Distributed Observability & Telemetry

If you cannot observe your resilience pipeline in real time, you cannot know if your system is healing or silently degrading.

Consider this log line:

text
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request finished HTTP/1.1 POST /api/checkout - 200 OK in 8420.14ms

To an uninstrumented monitoring dashboard, this request succeeded. But why did it take 8.4 seconds?

  • Did Attempt 1 time out after 4 seconds?
  • Did Attempt 2 encounter a 503 and wait on backoff for 2 seconds?
  • Did Attempt 3 finally succeed in 2.4 seconds?

Without per-attempt telemetry, you are blind to latent infrastructure degradation until the entire system collapses.

Emitting Custom Diagnostics with ActivitySource

csharp
using System.Diagnostics;
 
public static class ResilienceDiagnostics
{
    public static readonly ActivitySource Source = new("Rehan.ResilientHttp", "1.0.0");
}
 
public sealed class InventoryFacade(IInventoryClient client)
{
    public async Task<InventoryItem?> GetInventoryAsync(Guid productId, CancellationToken ct)
    {
        using var activity = ResilienceDiagnostics.Source.StartActivity(
            "inventory.lookup",
            ActivityKind.Client);
 
        activity?.SetTag("inventory.product_id", productId);
 
        try
        {
            var result = await client.GetAsync(productId, ct);
            activity?.SetStatus(ActivityStatusCode.Ok);
            return result;
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.RecordException(ex);
            throw;
        }
    }
}

Capturing OpenTelemetry Resilience Metrics

Microsoft.Extensions.Http.Resilience automatically emits OpenTelemetry meters under Microsoft.Extensions.Http.Resilience. In your host startup, subscribe to these meters:

csharp
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics
            .AddMeter("Microsoft.Extensions.Http.Resilience")
            .AddMeter("Rehan.ResilientHttp")
            .AddHttpClientInstrumentation()
            .AddPrometheusExporter(); // Or OTLP Exporter
    });

Key metrics to monitor on your production Grafana dashboards:

  • http.client.request.resilience.attempt.duration: Duration distribution of individual physical attempts.
  • http.client.request.resilience.retry.attempts: Total count of retry attempts executed.
  • http.client.request.resilience.circuit_breaker.state: Current state (0 = Closed, 1 = Open, 2 = Half-Open).
  • http.client.request.resilience.rate_limiter.rejections: Count of requests rejected by outbound bulkheads.

15. Deterministic Resilience Testing with Sequence Handlers

Validating resilience policies with unit tests that mock HttpClient to return a single static response is completely useless. You must test behavioral state sequences over time:

  • Verify that a 503 -> 503 -> 200 sequence returns success while recording exactly 3 attempts.
  • Verify that exceeding the TotalRequestTimeout cancels the pipeline cleanly.
  • Verify that consecutive 500 errors trip the circuit breaker into the Open state.

Production Test Sequence Handler

csharp
public sealed class SequenceHttpMessageHandler(
    params Func<HttpRequestMessage, HttpResponseMessage>[] responseFactories)
    : HttpMessageHandler
{
    private int _invocationIndex;
 
    public int TotalInvocations => Volatile.Read(ref _invocationIndex);
 
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var currentIndex = Interlocked.Increment(ref _invocationIndex) - 1;
        var index = Math.Min(currentIndex, responseFactories.Length - 1);
 
        var response = responseFactories[index](request);
        return Task.FromResult(response);
    }
}

Complete XUnit Test Suite

csharp
using System.Net;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Xunit;
 
public sealed class ResiliencePipelineTests
{
    [Fact]
    public async Task Pipeline_Should_Retry_And_Recover_On_Transient_503()
    {
        // Arrange
        var testHandler = new SequenceHttpMessageHandler(
            _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
            _ => new HttpResponseMessage(HttpStatusCode.ServiceUnavailable),
            _ => new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new StringContent("{\"status\":\"success\"}")
            }
        );
 
        var services = new ServiceCollection();
        services
            .AddHttpClient("TestClient", client => client.BaseAddress = new Uri("https://test.internal"))
            .AddStandardResilienceHandler(options =>
            {
                options.Retry.MaxRetryAttempts = 3;
                options.Retry.Delay = TimeSpan.FromMilliseconds(10); // Fast delay for unit tests
            })
            .ConfigurePrimaryHttpMessageHandler(() => testHandler);
 
        var sp = services.BuildServiceProvider();
        var factory = sp.GetRequiredService<IHttpClientFactory>();
        var client = factory.CreateClient("TestClient");
 
        // Act
        var response = await client.GetAsync("/api/resource");
 
        // Assert
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal(3, testHandler.TotalInvocations); // Proves exactly 2 retries were executed
    }
}

16. Solution Structure & Clean Architecture Integration

In an enterprise solution following Clean Architecture, HTTP resilience is an infrastructure adapter concern. Domain entities and Application use-case handlers must remain completely agnostic of Polly, timeouts, or socket handlers:

Solution Explorer — Resilient HTTP Integration Structure
.NET 10 Solution
src
PaymentGateway.DomainDomain Layer
Entities
Payment.cs
IdempotencyRecord.cs
ValueObjects
IdempotencyKey.cs
PaymentGateway.ApplicationApplication Layer
Common
IPaymentClient.cs
IIdempotencyStore.cs
Commands
ProcessPaymentCommand.cs
PaymentGateway.InfrastructureInfrastructure Layer
HttpClients
PaymentWriteClient.cs
PaymentReadClient.cs
Resilience
ResilienceOptions.cs
ResiliencePipelineExtensions.cs
Persistence
PaymentsDbContext.cs
IdempotencyRecordConfiguration.cs
PaymentGateway.ApiComposition Root
Program.cs
appsettings.json
tests
PaymentGateway.IntegrationTestsTest Harness
ResiliencePipelineTests.cs
SequenceHttpMessageHandler.cs

17. Production Architecture Decision Checklist

Before enabling any resilience pipeline in production, verify every item on this checklist:

Operation Semantics

  • Is the operation read-only (GET/HEAD) or state-changing (POST/PUT/DELETE)?
  • If state-changing, is automatic retry disabled via DisableForUnsafeHttpMethods()?
  • If state-changing and retried, is it protected by a server-side Idempotency-Key?
  • Does the server enforce uniqueness with a database unique index, avoiding check-then-insert race conditions?
  • Does the server reject requests where an existing idempotency key is reused with a different payload hash?

Budget & Timing

  • Is the cumulative Total Request Timeout strictly smaller than the caller's end-to-end SLA?
  • Can all configured retry attempts and backoff delays fit inside the Total Request Timeout?
  • Is jitter enabled on all backoff policies to prevent synchronized thundering herds?
  • Is ShouldRetryAfterHeader enabled to honor downstream 429 and 503 delay requests?

Capacity & Isolation

  • Is outbound concurrency bounded by a Rate Limiter bulkhead to protect local thread pools?
  • Is the Circuit Breaker configured with a realistic MinimumThroughput that matches actual production traffic?
  • Is exactly one architectural layer responsible for retries across the distributed call path?
  • Are read operations and write operations isolated into separate typed HttpClient registrations?

Sockets & Observability

  • Are typed clients managed by IHttpClientFactory without being captured inside Singleton services?
  • If using standalone SocketsHttpHandler, is PooledConnectionLifetime set to periodically refresh DNS?
  • Are OpenTelemetry metrics enabled for Microsoft.Extensions.Http.Resilience?
  • Can your monitoring dashboard distinguish individual physical attempts from logical requests?

18. Conclusion: Controlled Failure vs. Infinite Recovery

The core philosophy of distributed systems engineering can be distilled into a single principle:

Resilience is not the ability to keep trying until an operation succeeds. Resilience is the discipline of bounding the cost of failure.

When you configure an unbounded retry loop, you choose to believe that networks are reliable and servers have infinite capacity. In production, networks drop packets, databases deadlock, proxies crash, and cloud containers get evicted.

By structuring your HTTP integrations with:

  • Strict Attempt and Cumulative Timeouts to bound time,
  • Jittered Exponential Backoff to bound load spikes,
  • Circuit Breakers to bound failure propagation,
  • Bulkhead Rate Limiters to bound concurrency,
  • Durable Idempotency Keys to bound business duplication, and
  • OpenTelemetry Instrumentation to bound uncertainty,

you transform brittle, cascading microservice outages into isolated, observable, and safely recoverable state transitions. That is the difference between adding a retry loop and engineering a resilient distributed system.


References & Authoritative Reading

  1. Microsoft Learn: Build Resilient HTTP Apps with Key Patterns
    Comprehensive documentation on Microsoft.Extensions.Http.Resilience, strategy execution ordering, framework defaults, and custom pipeline configuration.
    https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience

  2. Microsoft Learn: Guidelines for Using HttpClient
    Authoritative guidance on connection pooling, DNS rotation, PooledConnectionLifetime, socket exhaustion, and IHttpClientFactory mechanics.
    https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines

  3. RFC 9110: HTTP Semantics
    Standards specifications for safe methods, idempotent methods, automatic retry constraints, and Retry-After header processing.
    https://www.rfc-editor.org/rfc/rfc9110.html

  4. RFC 6585: Additional HTTP Status Codes
    Specification for HTTP 429 Too Many Requests and its integration with flow control headers.
    https://www.rfc-editor.org/rfc/rfc6585.html

  5. Microsoft Learn: Troubleshoot IHttpClientFactory Issues
    Detailed diagnostic guide for captive typed client dependencies, stale DNS records, and socket allocation bottlenecks.
    https://learn.microsoft.com/en-us/dotnet/core/extensions/httpclient-factory-troubleshooting

  6. Polly Documentation: Unified Resilience Pipelines v8
    Architecture specifications for Polly v8 zero-allocation pipelines, rate limiters, and telemetry meters.
    https://www.pollydocs.org/

Rehan Akbar

Written by Rehan Akbar

Software Engineer · Singapore · .NET, Cloud & Applied AI

I write about the engineering trade-offs behind reliable production systems—Clean Architecture, distributed resilience, observability, and practical AI engineering. Open for architecture advisory and technical collaborations.

Discussion & Comments

Share your thoughts, ask technical questions, or discuss architecture edge cases.