Resilience
By the end of this lesson
Apply timeouts, retries and circuit breakers so failure degrades gracefully.
Resilience is deciding, in advance and in writing, what your application does when something it depends on is slow or unavailable. Not whether that happens — it happens — but what the user sees when it does, and whether your application is still able to do the things that do not need the failing dependency.
Slow is the case worth thinking about first, because it does more damage than down. A dependency that refuses connections fails your calls in milliseconds and everyone finds out immediately. A dependency answering in forty seconds holds a thread, a connection and a request slot for each caller, and within a minute your pools are full. Requests to perfectly healthy dependencies then start queueing behind the slow one, and the application looks entirely broken while every dashboard says only one service has a problem.
builder.Services
.AddHttpClient<IInvoicingApi, InvoicingApi>(client =>
{
client.BaseAddress = new Uri("https://invoicing.internal/");
})
.AddStandardResilienceHandler(options =>
{
// No single attempt may take longer than two seconds.
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
// Three retries: roughly 200ms, 400ms, 800ms, each nudged by a random amount.
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromMilliseconds(200);
options.Retry.BackoffType = DelayBackoffType.Exponential;
options.Retry.UseJitter = true;
// Stop calling when a tenth of a 30-second window has failed, over at
// least 20 calls. Fail instantly for 5 seconds, then test one request.
options.CircuitBreaker.FailureRatio = 0.1;
options.CircuitBreaker.MinimumThroughput = 20;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(5);
// Everything above, including all retries, inside eight seconds.
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(8);
});- Start with the timeout, because it is the cheapest change with the largest effect. HttpClient's default is 100 seconds, which for a web application is indistinguishable from forever: a handful of requests per second against a hung dependency exhausts the thread and connection pools, and then nothing works. A call with no timeout is a call that can hang until you restart the process.
- Two timeouts, and both are needed. The per-attempt timeout abandons one hung call. The total timeout stops three retries plus their delays from adding up to more time than any caller is prepared to wait.
- Exponential delay gives the dependency room. Retrying immediately means your recovery attempt arrives while the dependency is least able to serve it, which is how a two-second blip becomes a two-minute outage.
- Jitter matters more than it looks. Without it, a thousand callers that failed at the same instant all retry at the same instant, and the dependency is struck by synchronised waves. A small random offset spreads the same number of calls across the window.
- The circuit breaker protects both sides. It stops your retries piling onto something already failing, and it stops each of your own requests spending eight seconds discovering the same fact. While it is open, calls fail in microseconds, which is what makes a degraded response fast rather than slow.
- Every number here is a decision, and the caller's deadline is the thing to hold in view. Eight seconds of retrying is reasonable for a background job and poor for a screen someone is watching, where failing at two seconds with a retry button is usually kinder.
The techniques, each answering a different failure:
- Timeout
- A maximum wait, on every call that leaves your process. This is the one to have if you only have one: without it, a slow dependency consumes threads and connections until nothing else can run.
- Retry
- Try again, on the assumption the fault was transient. Only ever safe for operations that can be repeated without changing the outcome.
- Exponential backoff
- Wait longer between successive attempts. Turns a retry from added pressure into a genuine second chance.
- Jitter
- A random offset on each delay so that callers who failed together do not retry together. Cheap, and the difference between a spread of load and a series of spikes.
- Circuit breaker
- After enough failures, stop calling and fail immediately. Closed is normal, open is failing fast, half-open lets one probe through to see whether the dependency is back. It protects the dependency and your own threads at once.
- Bulkhead
- A concurrency limit per dependency, so calls to the slow one cannot consume every worker. Named after ship compartments: one flooded section does not sink the vessel.
- Fallback and graceful degradation
- A defined answer for when the call cannot be made: a cached figure, an empty section, or an explicit "unavailable" the user can see. Deciding this in advance is what separates degradation from an error page.
- Deadline propagation
- Passing the remaining time budget down the call chain. Without it, a service happily spends eight seconds retrying for a caller that gave up after three.
Two rules get skipped, and each one turns a resilience feature into a defect.
The first: retry only what is safe to repeat. A request that charges an invoice is not safe. Your first attempt may have reached the provider, been processed, and had its reply lost on the way back — from your side that is indistinguishable from never arriving. The retry takes the money a second time, no exception is thrown anywhere, and finance finds it. Retries look like a transport concern and they are a correctness concern. There are two honest ways out: give the operation an idempotency key the remote side deduplicates on, which is why the payment port in the adapter lesson carried a reference, or do not retry and record the uncertain outcome for a reconciliation job to settle.
The second: retries without backoff amplify an outage. A dependency at its limit starts failing. Every caller retries three times, so it now receives four times the traffic it could not handle in the first place, and it cannot recover while that lasts. This is a retry storm, and it routinely makes an outage last far longer than the fault that started it. Backoff, jitter and a circuit breaker are what stop your recovery attempts from being the reason recovery is impossible.
public async Task<InvoiceListView> BuildAsync(int customerId, CancellationToken token)
{
// Essential: without these there is no page.
IReadOnlyList<InvoiceListRow> invoices =
await invoiceQuery.GetOutstandingAsync(customerId, token);
// Useful, not essential: a credit badge beside the customer's name.
CreditDecision? credit = null;
try
{
credit = await creditAssessment.AssessAsync(new CreditEnquiry(customerId, 0m), token);
}
catch (Exception ex) when (ex is HttpRequestException
or TimeoutRejectedException
or BrokenCircuitException)
{
log.LogWarning(ex, "Credit assessment unavailable for customer {CustomerId}.", customerId);
}
return new InvoiceListView(
Invoices: invoices,
Credit: credit,
CreditUnavailable: credit is null);
}- The design decision happens before any of this code: which parts of this screen are essential. Invoices are the page. The credit badge is a nicety. Once that is written down, a credit outage costs a badge rather than a screen.
- The catch lists three transport failures rather than catching everything. Catching Exception would also swallow a defect in your own mapping code, and a permanently degraded page caused by your own bug is worse than an error, because nobody is alerted.
- BrokenCircuitException is what the breaker raises while it is open, and it arrives almost instantly. That is what keeps a degraded page fast: without the breaker, every request waits for the timeout before degrading.
- CreditUnavailable is in the view model deliberately. A missing figure rendered as a blank reads as "no credit issues", which is a worse outcome than an error — the user acts on an absence as though it were information. Say the value is unavailable.
- The timeout for this call must be shorter than the page's own budget. If the screen has to render in two seconds and the credit call is allowed five, the degradation never happens; the page fails slowly instead.
- One more thing to add before this is finished: a metric counting how often the degraded path runs. Otherwise the credit service can be failing a third of the time for a fortnight and every dashboard stays green.
Before adding a retry anywhere, have an answer for each of these:
- Is the operation safe to repeat? If not, either obtain an idempotency key from the other side or do not retry it.
- Is this failure worth retrying? A validation error or a not-found will fail identically forever. A 429 is a request to wait, and usually says for how long in a Retry-After header worth honouring.
- What is the total time budget, and does it fit inside the caller's? A policy that spends eight seconds behind a gateway that gives up at three does its retrying after nobody is listening.
- Is there a circuit breaker behind the retries? Retries alone convert a brief fault into sustained load.
- Who retries in this chain? Client, gateway and service each retrying three times means up to sixty-four calls reach the failing dependency for one user action. Pick one layer and let the others fail.
- Is the retry counted? A dependency failing a third of the time looks healthy when retries hide it, and then it fails completely with no warning in the history.
- Has it been tested by making the dependency fail? Resilience configuration that has never been exercised is a guess, and misconfigured policies cause outages of their own.
Summary
- A call with no timeout can hang until the process is restarted, taking unrelated features with it
- Retry only what is safe to repeat; without an idempotency key a retry duplicates work silently
- Backoff and jitter stop your retries from being the reason a struggling dependency cannot recover
- A circuit breaker protects the dependency and keeps your own failure fast
- Decide in advance which parts of a screen are essential, and never fail open on a security or money decision
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Make a dependency stop answering
Point one outbound call at an address that accepts connections and never replies — a listener that does nothing is enough. Then use the feature that depends on it.
Time how long the request takes, then load the page a few times concurrently and watch what happens to requests that have nothing to do with that dependency.
Show solution
If the call has no timeout, the request hangs for the default 100 seconds. With a few concurrent users, unrelated endpoints slow down and then stop responding, because the threads and connections are all waiting on one silent dependency. That is the failure this lesson exists to prevent, and it is reproducible in minutes.
Add a two-second attempt timeout and repeat. The feature fails quickly, the rest of the application keeps working, and the difference in the logs is unmistakable.
Then add the breaker and repeat again. After the failure ratio is reached, calls fail instantly and the affected feature degrades without any wait at all.
Keep this as a test rather than a one-off experiment. A configurable base address pointing at a black-hole listener in an integration test turns the whole of this lesson into something the build checks, and resilience settings that are never exercised drift into being wrong.
Think about it
Retry, or do not retry?
Decide for each, and say what you would do instead where a retry is unsafe: reading a customer's invoice list; charging a card for an invoice; sending an approval email; a database write that failed on a deadlock; a request that returned 422 because the postcode was invalid.
Show solution
Reading the invoice list: retry. A read changes nothing, so repetition is free, and a short backoff with a low attempt limit is enough.
Charging the card: do not retry blindly. Send an idempotency key the provider deduplicates on and then a retry is safe, because their side recognises the repeat. Without one, mark the payment as uncertain and reconcile against the provider rather than guessing. Nobody wants to explain a double charge.
Sending the email: retry, with a caveat. Most providers will send twice if you ask twice, so use their idempotency key if they offer one, and accept a duplicate email as the lesser risk. It is an irritation rather than a financial event, which is a judgement to make consciously rather than by default.
The deadlock: retry, and this is the clearest case on the list. Deadlocks are transient by nature, the transaction rolled back so nothing partial remains, and a short randomised delay usually succeeds. Retry the whole transaction, not the failed statement.
The invalid postcode: do not retry. It is deterministic and will fail identically forever. Retrying turns a fast, clear validation error into a slow one and multiplies the log entries.
The line running through these: retry transient faults, never retry a decision, and treat anything that moves money as needing an idempotency key before it is allowed a second attempt.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.
End of the published lessons
That is everything written so far in Architecture
More lessons in this course are on the way. In the meantime, the course page shows the full roadmap, and the projects are the best way to consolidate what you have covered.