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 arrivedef run(title, timeout): busy_until = [0] * 4 # until which tick each thread is busy refused = 0 for t in range(8): free = free_thread(busy_until, t) # the payment holds the thread until the provider answers if free is not None: busy_until[free] = t + min(30, timeout) if free_thread(busy_until, t) is None: refused += 1 # the catalogue has no free thread busy = sum(1 for u in busy_until if u > 7) print(f"{title}: busy threads {busy} of 4, the catalogue refused {refused} requests")
def free_thread(busy_until, t): return next((i for i, u in enumerate(busy_until) if u <= t), None)run("no timeout", math.inf)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 recoverdef retryable(answer): return answer in ("timeout", "503")def with_retry(answers, max_attempts): tick = 0 for attempt in range(1, max_attempts + 1): answer = answers[attempt - 1] # the recorded answer to attempt number attempt print(f"tick {tick}, attempt {attempt}: {answer}") if not retryable(answer) or attempt == max_attempts: return answer tick += 2 ** (attempt - 1) # wait 1, 2, 4, 8… ticksThe provider answers from a recorded script: timeout, then 503, then ok.
print("result:", with_retry(["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?
print("result:", with_retry(["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:
seed = 7
# a generator with a seed: the same numbers in every languagedef random(bound): global seed seed = (seed * 75 + 74) % 65537 return seed % bound
def retry_ticks(jitter): ticks = [] tick = 0 for attempt in range(4): wait = 2 ** attempt # 1, 2, 4, 8 if jitter: wait = 1 + random(2 * wait) # from 1 to 2 · wait tick += wait ticks.append(str(tick)) return " ".join(ticks)for client in ["A", "B", "C"]: print(f"{client} no jitter: {retry_ticks(False)}")for client in ["A", "B", "C"]: print(f"{client} jitter : {retry_ticks(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.
THRESHOLD = 3 # failures in a row before it opensCOOLDOWN = 4 # ticks in OPEN before a probe
class State(Enum): CLOSED = 1 OPEN = 2 HALF_OPEN = 3
class CircuitBreaker: def __init__(self): self.state = State.CLOSED self.failures = 0 self.opened_at = 0 self.calls = 0 # how many times the provider was called
def call(self, tick, provider_up): if self.state == State.OPEN: if tick - self.opened_at < COOLDOWN: return "refused at once" # one probe self.state = State.HALF_OPEN self.calls += 1 if provider_up: self.state = State.CLOSED self.failures = 0 return "ok" self.failures += 1 too_many = self.failures >= THRESHOLD if self.state == State.HALF_OPEN or too_many: self.state = State.OPEN self.opened_at = tick return "error"See it
The breaker over 12 ticks
def call(self, tick, provider_up): if self.state == State.OPEN: if tick - self.opened_at < COOLDOWN: return "refused at once" # one probe self.state = State.HALF_OPEN self.calls += 1 if provider_up: self.state = State.CLOSED self.failures = 0 return "ok" self.failures += 1 too_many = self.failures >= THRESHOLD if self.state == State.HALF_OPEN or too_many: self.state = State.OPEN self.opened_at = 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. HALF_OPEN: 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:
breaker = CircuitBreaker()for tick in range(12): up = tick >= 7 # recorded: the provider is back on tick 7 result = breaker.call(tick, up) print(f"tick {tick}: {result} → {breaker.state.name}")print(f"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.
State(Enum) gives the states names, and .name prints them: CLOSED, OPEN, HALF_OPEN.Try it
Fill in
How many ticks does the open breaker wait before it lets a probe through?
if self.state == State.OPEN:
if tick - self.opened_at < :
return "refused at once"
self.state = State.HALF_OPENThe 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.
global seed lets the function change the module's variable. In a class, or in an object from random.Random(7), the state is tidier.Exercise
Retries with a deadline
Solve the task in your own editor.
The customer waits for the payment at most 5 ticks. Write with_deadline, which retries like with_retry, 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
with_retry. 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