When the neighbour is slow: timeout, retry, circuit breaker
In short: A timeout stops waiting for a slow service, a retry with exponential backoff and jitter repeats only temporary errors, and a circuit breaker stops requests until the service recovers.
Problem
Thirty seconds that stopped the shop
On the morning of Black Friday the payment provider starts answering in 30 seconds instead of 300 ms. It does not go down: it answers, but slowly. A minute later the shop does not even show the catalogue, and the catalogue never calls the provider.
Here is why, in small. The shop has a pool of 4 threads (the lesson on thread pools). Every tick one payment and one catalogue request arrive. The provider answers in 30 ticks:
// a pool of 4 threads; every tick one payment and one catalogue request arrivestatic void Run(string title, int timeout){ var busyUntil = new int[4]; // until which tick each thread is busy int refused = 0; for (int t = 0; t < 8; t++) { int free = Array.FindIndex(busyUntil, u => u <= t); // the payment holds the thread until the provider answers if (free >= 0) busyUntil[free] = t + Math.Min(30, timeout); if (Array.FindIndex(busyUntil, u => u <= t) < 0) refused++; // the catalogue has no free thread } int busy = busyUntil.Count(u => u > 7); Console.WriteLine($"{title}: busy threads {busy} of 4, the catalogue refused {refused} requests");}Run("no timeout", int.MaxValue);no timeout: busy threads 4 of 4, the catalogue refused 5 requestsAfter four ticks all four threads wait for the provider. The catalogue refuses everything after that, although it has nothing to do with payments. A slow neighbour is more dangerous than a dead one: the dead one at least answers at once with an error.
The idea
Timeout: do not wait forever
A timeout is the longest time you wait for an answer. After it you stop, free the thread and tell the customer something clear: "The payment did not go through, the order is saved, try again in a moment." The same shop with a timeout of 2 ticks:
Run("timeout of 2 ticks", 2);timeout of 2 ticks: busy threads 2 of 4, the catalogue refused 0 requestsThe payments still fail, because the provider is slow. But each one holds a thread for at most 2 ticks, there are always free threads, and the catalogue works.
How long should the timeout be? It comes from the latency budget: if the payment has 450 ms for p95, a 30 second timeout protects nothing. Usually it is a little above the neighbour's p99, measured, not guessed. And the timeout of the lower layer must be shorter than that of the upper one: otherwise the upper layer has given up while the lower one still works for nobody.
The idea
Retry: try again, but wisely
Many errors are temporary: a lost packet, a restarting server, a 503 answer ("I am busy"). A second attempt a little later often succeeds. This is a retry. Three rules make it safe:
- Only temporary errors. Timeout and 503 are retried. "The card was declined" or 400 (a bad request) will be the same on the second attempt.
- A wait that grows: 1, 2, 4, 8 ticks. This is exponential backoff: a neighbour who is drowning gets more and more air.
- A limit on attempts. After it you give up and return the last error.
// only temporary errors: the provider may recoverstatic bool Retryable(string answer) => answer is "timeout" or "503";static string WithRetry(List<string> answers, int maxAttempts){ int tick = 0; for (int attempt = 1; ; attempt++) { string answer = answers[attempt - 1]; // the recorded answer to attempt number attempt Console.WriteLine($"tick {tick}, attempt {attempt}: {answer}"); if (!Retryable(answer) || attempt == maxAttempts) return answer; tick += 1 << (attempt - 1); // wait 1, 2, 4, 8… ticks }}The provider answers from a recorded script: timeout, then 503, then ok.
Console.WriteLine("result: " + WithRetry(["timeout", "503", "ok"], 4));tick 0, attempt 1: timeouttick 1, attempt 2: 503tick 3, attempt 3: okresult: okCareful: a repeated payment can take the money twice if the first one went through and only the answer was lost. That is why the payment is retried only with an idempotency key: the provider recognises the repeated request and returns the first result.
Try it
Predict the output
The provider answers timeout three times and ok on the fourth attempt. The limit is 3 attempts. What does the program print?
Console.WriteLine("result: " + WithRetry(["timeout", "timeout", "timeout", "ok"], 3));The attempts are on ticks 0, 1 and 3 (a wait of 1, then 2). The third one is the last allowed, so the function returns timeout without reaching the ok on the fourth.
Try it
Find the bug
A customer with a declined card waits a whole 7 ticks, and the bank sends four decline notifications. Which line has the bug?
Pick the line with the bug.
Everything except ok counts as temporary, the declined card too. Waiting does not fix it. Only timeout and 503 are retried; the rest is returned at once.
The idea
Jitter: so they do not all start together
When the provider fails, thousands of payments fail at once. With exponential backoff they all try again on tick 1, then on 3, then on 7: together, right when the provider is trying to recover. This is the thundering herd.
Jitter adds randomness to the wait: each client waits a random number of ticks, from 1 to double the wait. The program uses a small generator with a seed, so that it gives the same numbers in every language and on every run:
int seed = 7;
// a generator with a seed: the same numbers in every languageint Random(int bound){ seed = (seed * 75 + 74) % 65537; return seed % bound;}
string RetryTicks(bool jitter){ var ticks = new List<int>(); int tick = 0; for (int attempt = 0; attempt < 4; attempt++) { int wait = 1 << attempt; // 1, 2, 4, 8 if (jitter) wait = 1 + Random(2 * wait); // from 1 to 2 · wait tick += wait; ticks.Add(tick); } return string.Join(" ", ticks);}foreach (var client in new[] { "A", "B", "C" }) Console.WriteLine($"{client} no jitter: {RetryTicks(false)}");foreach (var client in new[] { "A", "B", "C" }) Console.WriteLine($"{client} jitter : {RetryTicks(true)}");A no jitter: 1 3 7 15B no jitter: 1 3 7 15C no jitter: 1 3 7 15A jitter : 2 6 11 13B jitter : 2 5 12 19C jitter : 1 5 8 9Without jitter the three hit the provider on the very same ticks. With jitter they spread out. In real code you do not set a seed: you use the language's generator, and in tests you pass your own so that the output is predictable.
The idea
Circuit breaker: stop calling
A retry helps with a short problem. If the provider has been down for ten minutes, every retry is one more request to someone who is drowning, and one more thread that waits. Then you need a circuit breaker, like the fuse in an electrical panel. It has three states:
- closed: requests go through. The breaker counts the failures in a row;
- open: after 3 failures in a row it stops calling. Every request is refused at once, without waiting;
- half-open: after 4 ticks it lets one probe through. If it succeeds, the breaker closes. If it fails, it opens again.
enum State { Closed, Open, HalfOpen }
class CircuitBreaker{ const int Threshold = 3; // failures in a row before it opens const int Cooldown = 4; // ticks in OPEN before a probe public State State { get; private set; } = State.Closed; int failures; int openedAt; public int Calls { get; private set; } // how many times the provider was called
public string Call(int tick, bool providerUp) { if (State == State.Open) { if (tick - openedAt < Cooldown) return "refused at once"; State = State.HalfOpen; // one probe } Calls++; if (providerUp) { State = State.Closed; failures = 0; return "ok"; } failures++; bool tooMany = failures >= Threshold; if (State == State.HalfOpen || tooMany) { State = State.Open; openedAt = tick; } return "error"; }}See it
The breaker over 12 ticks
public string Call(int tick, bool providerUp){ if (State == State.Open) { if (tick - openedAt < Cooldown) return "refused at once"; State = State.HalfOpen; // one probe } Calls++; if (providerUp) { State = State.Closed; failures = 0; return "ok"; } failures++; bool tooMany = failures >= Threshold; if (State == State.HalfOpen || tooMany) { State = State.Open; openedAt = tick; } return "error";}Step by step, in words
- Closed: every call goes to the payment provider. The provider does not answer: tick 0 and tick 1 are errors, the counter is at 2 of 3.
- Tick 2: the third error in a row. The breaker opens (Open) and remembers when.
- Ticks 3, 4 and 5 are refused at once. They do not reach the provider and do not hold a thread. The shop can say "pay later" right away.
- Tick 6: 4 ticks have passed. HalfOpen: one probe goes through. The provider is still down, so the breaker opens again.
- From tick 7 the provider works again, but the breaker does not know yet. Ticks 7 to 9 are refused: the price of waiting.
- Tick 10: the probe succeeds, the breaker closes (Closed) and the counter is reset. The provider was called 6 times out of 12.
The provider does not answer until tick 7 and works after that. The recorded script is tick >= 7:
var breaker = new CircuitBreaker();for (int tick = 0; tick < 12; tick++){ bool up = tick >= 7; // recorded: the provider is back on tick 7 string result = breaker.Call(tick, up); Console.WriteLine($"tick {tick}: {result} → {breaker.State}");}Console.WriteLine($"calls to the provider: {breaker.Calls} of 12");tick 0: error → Closedtick 1: error → Closedtick 2: error → Opentick 3: refused at once → Opentick 4: refused at once → Opentick 5: refused at once → Opentick 6: error → Opentick 7: refused at once → Opentick 8: refused at once → Opentick 9: refused at once → Opentick 10: ok → Closedtick 11: ok → Closedcalls to the provider: 6 of 12Of 12 requests only 6 reach the provider. The others are refused at once: they hold no thread and put no load on someone who does not answer anyway. The price shows on ticks 7 to 9: the provider already works, and the breaker still refuses. A shorter cooldown brings the work back sooner, but hits the provider more often.
The propertyState is of type State. C# allows it: in State == State.Open the compiler understands that on the left is the property and on the right the enum.Try it
Fill in
How many ticks does the open breaker wait before it lets a probe through?
if (State == State.Open)
{
if (tick - openedAt < )
return "refused at once";
State = State.HalfOpen;
}The cooldown. Until it has passed, every request is refused at once. Then the breaker becomes half-open and lets one probe through.
The idea
Bulkhead and the answer on refusal
A bulkhead (as in the hull of a ship) is a separate thread pool for each neighbour: the payment has its own 4 threads and the catalogue its own. Then a slow provider eats only its own.
The breaker and the timeout say "not now", and the system must know what to do then. This is the fallback: the order is saved as "waiting for payment" and the customer pays later; the page shows the price from the cache; the payment goes through a second provider. Which answer is acceptable is for the business to decide, as with trade-offs.
The idea
When it hurts
- Retries on every layer. The gateway retries 3 times, the orders 3 times, the payment 3 times: up to 27 requests to the provider for one click. Retry in one place.
- Retrying an operation that is not idempotent. A double payment, two emails, two parcels. Without an idempotency key, do not retry.
- A timeout longer than the caller's. The upper layer has already told the customer "error", and the lower one still waits and retries.
- A breaker for the whole service because of one slow address. If only search is slow, do not stop the payment too.
- Numbers guessed, not measured. A threshold too low opens the breaker on every bit of noise; a cooldown too long keeps it open when the neighbour has long been working.
The same mechanisms are often provided by the infrastructure too (a service mesh such as Istio or Linkerd, an API gateway). It is taught in the DevOps course ZeroToOps. Here is the part in the code: what you do when the neighbour does not answer.
More precisely, if you are curious
The libraries. In a real project you do not write a breaker yourself: Java has Resilience4j (Spring Cloud Circuit Breaker uses it); C# has Polly and Microsoft.Extensions.Http.Resilience for HttpClient; Python has tenacity for retries and pybreaker; Node has cockatiel and opossum. Netflix's Hystrix is the historical model and is no longer developed.
The default timeouts. In .NET HttpClient waits 100 seconds by default. In Java's HttpClient and in requests for Python there is no timeout until you set one. In Node fetch (undici) waits for the headers up to 300 seconds. You met the network clients in the lesson "Network programming".
The deadline travels. If the client has 2 seconds in total and the gateway has already spent 0.5, the service below gets 1.5, not its own 2. gRPC does this with a deadline that travels with the request.
Retry-After and a retry budget. The answers 503 and 429 can say when to try (Retry-After). Many systems also limit retries overall, for example to 10% of the requests, so that they do not double the load exactly when it is worst.
Kinds of jitter. Here it is "full" jitter: random from 1 to double. There is also "equal" jitter (half fixed, half random) and "decorrelated" jitter. The goal is the same: the clients must not synchronise.
The generator injitter is a local function in the top-level code and sees the variable seed outside it.Exercise
Retries with a deadline
Solve the task in your own editor.
The customer waits for the payment at most 5 ticks. Write WithDeadline, which retries like WithRetry, but without a limit on attempts: it stops when the next attempt would come after the deadline, and says why. The provider answers timeout three times, then ok.
tick 0, attempt 1: timeouttick 1, attempt 2: timeouttick 3, attempt 3: timeoutstopping: attempt 4 would be on tick 7, after the deadline 5result: timeoutMore tasks
- A breaker and retries together. Put the breaker in front of
WithRetry. Is a refusal "at once" a temporary error? - A bulkhead. Split the pool from the problem in two: 2 threads for payments and 2 for the catalogue. How many catalogue requests are refused now without a timeout?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Network programming · A pool of threads and tasks · API design: contract, versions, retried requests · Non-functional requirements you can measure
- Continues in: A transaction across services (saga) · Integrating old and foreign systems · Architecture review · Your first architecture task
- Tasks on this topic: The architect at work