- #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.
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:
// 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.
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.
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.
- ✓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
- ✗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 Mode | Status Code / Exception | Transient Likelihood | Default Safe Action | Mutation Safety |
|---|---|---|---|---|
| Connection Refused / Reset | SocketException, HttpRequestException | Moderate (often host restarting) | Retry with jittered backoff | Safe for idempotent operations; requires key for writes |
| Attempt Timeout | TimeoutRejectedException, TaskCanceledException | High | Retry if cumulative budget permits | Unsafe for blind writes (remote side may have committed) |
| Request Timeout | HTTP 408 | High | Retry with backoff | Safe for idempotent operations |
| Rate Limiting | HTTP 429 Too Many Requests | High (throttled) | Respect Retry-After header | Safe to retry after specified duration |
| Gateway / Proxy Errors | HTTP 502, 503, 504 | High (temporary unavailability) | Retry with backoff & circuit breaker | Safe for idempotent operations; requires key for writes |
| Internal Server Error | HTTP 500 | Context-dependent | Do NOT retry blindly | Often deterministic (e.g., NullReference, schema bug) |
| Client Input Validation | HTTP 400, 422 | Zero (deterministic) | Never retry | Immediate client failure |
| Authentication / Authz | HTTP 401, 403 | Zero (without token refresh) | Never retry inline | Route to token refresh workflow |
| Resource Not Found | HTTP 404 | Zero (unless eventual consistency) | Never retry | Return null / NotFound |
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:
dotnet add package Microsoft.Extensions.Http.ResilienceBuilt 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
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:
The architectural rationale for this strict sequencing is critical:
- 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.
- 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.
- 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.
- 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.
- 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.
| Strategy | Default Setting | Architectural Rationale |
|---|---|---|
| Total Request Timeout | 30 seconds | Hard ceiling for the entire multi-attempt lifecycle |
| Retry Strategy | 3 retries, Exponential backoff, 2s base delay, Jitter enabled | Recovers from short momentary blips |
| Circuit Breaker | 10% failure ratio, MinimumThroughput: 100, SamplingDuration: 30s, BreakDuration: 5s | Isolates dependency under sustained distress |
| Attempt Timeout | 10 seconds | Caps individual socket read/write operations |
| Rate Limiter | 1,000 permits, Zero queue | Prevents local thread pool saturation |
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:
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/sIf 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:
Third Attempt Wave: +800 req/s
Combined Load on Service B: 2,300 req/sThis 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:
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:
T0 + 0s: 200 requests fail simultaneously
T0 + 2s: 200 retry requests strike the provider simultaneously
T0 + 6s: 200 retry requests strike the provider simultaneouslyInstead 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:
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:
- Attempt Timeout: The maximum duration permitted for a single physical HTTP attempt over the wire.
- Total Request Timeout: The absolute cumulative wall-clock budget permitted for the logical operation, including all attempts, backoff delays, and local handler overhead.
|<--------------------------------- 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):
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
- 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
BrokenCircuitExceptionimmediately in under 1 millisecond. No network sockets are allocated. - Half-Open (Canary Probing): When
BreakDurationelapses, 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:
// 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:
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:
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:
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:
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.
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
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
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
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);
}
}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/1.1 429 Too Many Requests
Retry-After: 30
Content-Type: application/jsonOr using an HTTP-date timestamp:
HTTP/1.1 503 Service Unavailable
Retry-After: Tue, 13 Oct 2026 09:30:00 GMTIf 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:
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:
When concurrency exceeds capacity:
- Downstream request queues fill up.
- Latency skyrockets from 50ms to 4,000ms.
- Client attempt timeouts fire.
- Clients initiate retries, injecting more concurrency into the saturated queue.
- The downstream process dies of memory exhaustion or thread starvation.
The rate limiter in AddStandardResilienceHandler() acts as an outbound bulkhead:
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:
- New
HttpClientper request: Callingusing var client = new HttpClient()exhausts OS socket handles in theTIME_WAITstate, leading to socket exhaustion under high load. - Static singleton
HttpClientforever: 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
// 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.
// 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);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:
Calculate the potential physical requests hitting the database for a single user click:
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.
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:
Read Client Registration
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
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:
info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
Request finished HTTP/1.1 POST /api/checkout - 200 OK in 8420.14msTo 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
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:
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 -> 200sequence returns success while recording exactly 3 attempts. - Verify that exceeding the
TotalRequestTimeoutcancels the pipeline cleanly. - Verify that consecutive
500errors trip the circuit breaker into theOpenstate.
Production Test Sequence Handler
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
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:
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
ShouldRetryAfterHeaderenabled to honor downstream429and503delay 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
MinimumThroughputthat 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
HttpClientregistrations?
Sockets & Observability
- Are typed clients managed by
IHttpClientFactorywithout being captured inside Singleton services? - If using standalone
SocketsHttpHandler, isPooledConnectionLifetimeset 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
-
Microsoft Learn: Build Resilient HTTP Apps with Key Patterns
Comprehensive documentation onMicrosoft.Extensions.Http.Resilience, strategy execution ordering, framework defaults, and custom pipeline configuration.
https://learn.microsoft.com/en-us/dotnet/core/resilience/http-resilience -
Microsoft Learn: Guidelines for Using HttpClient
Authoritative guidance on connection pooling, DNS rotation,PooledConnectionLifetime, socket exhaustion, andIHttpClientFactorymechanics.
https://learn.microsoft.com/en-us/dotnet/fundamentals/networking/http/httpclient-guidelines -
RFC 9110: HTTP Semantics
Standards specifications for safe methods, idempotent methods, automatic retry constraints, andRetry-Afterheader processing.
https://www.rfc-editor.org/rfc/rfc9110.html -
RFC 6585: Additional HTTP Status Codes
Specification forHTTP 429 Too Many Requestsand its integration with flow control headers.
https://www.rfc-editor.org/rfc/rfc6585.html -
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 -
Polly Documentation: Unified Resilience Pipelines v8
Architecture specifications for Polly v8 zero-allocation pipelines, rate limiters, and telemetry meters.
https://www.pollydocs.org/
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.
The Dual-Write Trap: Production-Grade Transactional Outbox and Idempotent Consumers in .NET 10
Next ArticleThe Distributed Monolith: Why Microservices Still Fail Together
Discussion & Comments
Share your thoughts, ask technical questions, or discuss architecture edge cases.