Ch 18 / 24 Designing for the Failure You Will Get 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Software Engineering for AI Engineers/Chapter 18

Part 4 · Making Systems Secure and Reliable · Chapter 18

Designing for the Failure You Will Get

Every dependency you have will be slow, wrong or absent at some point, and no amount of care prevents it. What you get to decide is what happens then — and most of that decision is made implicitly, in code that has no timeout, no fallback, and a retry loop somebody added in a hurry.

Reading
AWS, Timeouts, retries and backoff with jitter
Focus
Deciding in advance
Modes
JS · architecture · choice
Exercises
12

By the end of this chapter you can

  1. Derive every timeout in a call chain from one end-to-end budget
  2. Explain why a naive retry makes an outage worse, with the arithmetic
  3. Implement backoff with full jitter and a retry budget
  4. Write a circuit breaker that recovers without stampeding
  5. Size a bulkhead so one slow dependency cannot take the rest with it
  6. Decide, in advance, what a degraded response looks like

1Everything fails; the design decides what that means

The interesting question is never whether a dependency will fail. It is what your system does during the ninety seconds when it is failing, and that is decided by code you wrote months earlier.

Failures come in three shapes, and they are not equally easy to handle:

ShapeWhat you seeDifficulty
Fast failureA connection refused, a 503, an exceptionEasiest. It is immediate, unambiguous, and your code runs
Slow failureThe call takes 40 seconds instead of 80 ms and then succeedsHardest. Nothing is wrong, everything is stuck, and your thread pool fills
Wrong answerA 200 with stale, partial or nonsense dataWorst. Nothing detects it and the wrongness propagates

Slow is worse than down. A dependency that is refusing connections tells you immediately and your error handling runs; a dependency that answers in 40 seconds consumes a connection, a thread and a request slot for those 40 seconds, and does it to every concurrent caller at once. This is the same Little’s law arithmetic from Chapter 9: concurrency is capacity divided by hold time, and a slow dependency multiplies hold time by a factor nobody provisioned for.

Which produces the single most useful rule in this chapter: every call that crosses a process boundary needs a timeout, and the default timeout of most HTTP clients is either none or something absurd like two minutes. A call with no timeout is not a call — it is an open-ended promise to wait, and it is how one slow third party takes down a service that has nothing wrong with it.

2Timeouts from a budget, not a guess

Most timeouts are chosen by picking a round number that felt generous. That is how a call chain ends up with an inner timeout longer than the outer one, which makes both of them useless.

The correct method is top-down. Start from what the user will tolerate, subtract what you spend yourself, and divide the rest among the calls you make — leaving headroom, because a timeout that exactly equals the budget fires at the same moment the caller gives up.

LevelBudgetReasoning
User-facing request2,000 msThe product decision. Past this the page is considered broken
Your own work100 msSerialisation, rendering, application logic
Available for dependencies1,900 msWhat is left
Database call200 msGenerous for an indexed query; anything slower is a bug
Tax service500 msp99 is 180 ms, so this is roughly 3× and still well inside budget
Retries (2 × 500 ms)1,000 msRetries come out of the same budget — the mistake almost everyone makes
Headroom200 msBecause the arithmetic being exactly tight means it is already broken

Two rules fall out of the table, and both are violated constantly.

An inner timeout must be shorter than the outer one. If the caller gives up at 2 seconds and the dependency call is set to 5, the timeout never fires — the caller has already abandoned the request, and your service goes on holding a worker for a response nobody will read. Every timeout in a chain should be strictly smaller than the one above it, which means timeouts have to be propagated, not configured independently in six places.

Retries are part of the budget, not extra. A 500 ms timeout with two retries is a 1,500 ms operation in the worst case. Teams set the timeout against the budget, then add retries, and are surprised when the p99 triples.

3Retries, and the amplification problem

A retry is the most obviously correct thing to add and the easiest way to turn a recoverable incident into a long one.

The arithmetic is unforgiving. A service is failing and 1,000 clients are affected. Each retries three times, immediately. The struggling service now receives 4,000 requests instead of 1,000 — and because every client failed at roughly the same moment, they all retry at roughly the same moment, so the load arrives as a spike rather than a stream. A service that was at 110% of capacity is now at 440%, and it cannot recover, because every time it comes back up the backlog knocks it down again.

Three mechanisms fix this, and they are cumulative rather than alternatives:

MechanismWhat it doesWhat it does not do
Exponential backoffSpreads retries over time: 1s, 2s, 4s, 8sDoes not desynchronise them — every client backs off on the same schedule, so the spikes are smaller and still spikes
Full jitterwait = random(0, base × 2^n). Breaks the synchronisation completelyDoes not bound the total retry load over a long outage
Retry budgetCaps retries at a share of traffic — say 10%. Above that, fail fastNothing else. This is the one that makes a long outage survivable

Full jitter is the counter-intuitive one and it is worth stating precisely: you wait a random duration between zero and the exponential bound, not the bound plus a bit of noise. Choosing uniformly across the whole interval is what actually flattens the arrival curve; adding ±10% to a fixed delay leaves the spike almost intact.

And before any of that: only retry what is safe to retry. A retry of a non-idempotent write is a second charge, a second email, a second invoice. Chapter 9’s idempotency key is what makes a retry safe; without one, the correct number of retries on a write is zero.

ResponseRetry?Why
Connection refused, DNS failureYesThe request never arrived; nothing happened
500, 502, 503, 504Yes, with backoffTransient by definition — though a 500 may have had side effects, so idempotency still matters
429 Too Many RequestsYes, after Retry-AfterThey told you when. Ignoring it is how you get blocked
TimeoutOnly if idempotentYou do not know whether it succeeded. This is the case that produces double charges
400, 422 — bad requestNoIt will fail identically every time. Retrying is pure amplification
401, 403 — authNoUnless you can refresh a token first, in which case that is one retry, not a loop
404NoIt is not there

4Circuit breakers and bulkheads

Two patterns with one shared idea: stop spending resources on something that is not working, before it consumes resources something else needs.

A circuit breaker is a state machine in front of a dependency. Closed, calls pass through. After enough failures it opens, and calls fail immediately without being attempted — which protects both sides: you stop waiting, and the struggling dependency stops receiving load. After a cooling period it goes half-open and lets a small number of trial calls through; if they succeed it closes, and if they fail it opens again.

StateBehaviourThe parameter that matters
ClosedCalls pass; failures countedThe threshold — and it should be an error rate over a rolling window, not a raw count, or a low-traffic endpoint trips on three failures a day
OpenCalls fail instantly. No attempt is madeThe cooldown. Too short and you hammer a recovering service; too long and you stay down after it recovers
Half-openA limited number of trial callsThe concurrency limit — one is usually right. Letting the full load through on the first success is the stampede this state exists to prevent

The half-open concurrency limit is the detail most implementations get wrong, and it converts a breaker from a protection into an oscillator: the dependency recovers, the breaker closes, the full backlog arrives at once, the dependency falls over, the breaker opens. Allowing exactly one probe call at a time is what lets a service come back gently.

A bulkhead is the other half. Named after ship compartments, it means giving each dependency its own pool of resources so that exhausting one does not exhaust the others. If the tax service has its own pool of 10 connections, a tax outage consumes 10 workers and the other 40 keep serving the invoice list — which is exactly the failure from section one, contained.

The two together are how a system degrades instead of collapsing: the bulkhead limits how much of your capacity one sick dependency can hold, and the breaker stops you spending even that once it is clear the dependency is not answering.

5Graceful degradation is a product decision made early

“Degrade gracefully” is not an engineering technique. It is a series of product decisions about what the page shows when a piece of it is missing, and if nobody makes them the answer is a 500.

For each dependency, somebody has to answer one question: when this is unavailable, what does the user see? There are only four possible answers, and choosing among them is not a technical judgement.

AnswerLooks likeAppropriate when
Omit itThe section is absent, with a quiet noteThe feature is additive — recommendations, a tax estimate, a draft suggestion
Serve it staleCached data, labelled with its ageStaleness is tolerable and the label is honest. Chapter 11’s freshness machinery is what makes this possible
Queue it“We’ll do this and tell you”The user does not need the answer synchronously — sending, exporting, drafting
Fail honestlyAn error that says what is wrong and what to doThe data is the point. An invoice total cannot be omitted, estimated or queued

The fourth is a legitimate choice and the one engineers skip past, which produces the worst failure in this chapter: a fallback that silently returns wrong data. Returning a cached tax rate from three months ago with no label, or a zero where a real figure was expected, is worse than an error, because the wrongness propagates into invoices, reports and decisions with nothing marking it. If you cannot label a degraded value as degraded, do not serve it.

This is where the AI-specific version bites hardest. A model call that fails can produce an empty draft, which is obvious — or a fallback to a shorter prompt, a smaller model, or a cached response, all of which produce a fluent answer of lower quality that nothing marks as degraded. The rule from Chapter 11 applies unchanged: a system that cannot tell the user which of its answers came from the degraded path has no way to be honest about it.

6Build: Ledger’s failure plan

One table, filled in per dependency, before anything is written. It takes an hour and it is the difference between designing the failure and discovering it.

DependencyTimeoutRetryBreakerWhen it is down
Database200 ms1, on connection errors onlyNo — there is no fallback, so failing fast gains nothingFail honestly. The product does not exist without it
Tax service500 ms2, full jitter, idempotentYes, 50% over 30 s, own pool of 10Omit. Show the net, label the tax as unavailable, refuse to issue the invoice
Model provider20 s2, full jitter, idempotency keyYes, own pool of 8Queue. The draft is asynchronous already; the user is told it is pending
Email provider5 s5, exponential, over hoursYesQueue. Delivery is eventual by nature; the queue is the design
Object storage3 s3, full jitterYesOmit. The attachment is unavailable; the invoice still renders
Redis (sessions)50 ms1Yes, fail openDegrade. Fall back to database sessions, slower and correct

Three things about this table are worth extracting.

The database has no breaker, and that is deliberate rather than an omission. A breaker converts waiting into fast failure, which is valuable when you have something else to do — and for the database there is no fallback, so failing fast turns a slow page into an error page and gains nothing. A breaker is only useful where a degraded path exists.

The Redis breaker fails open, which is the pattern from Chapter 16’s rate-limiter example. A session cache that fails closed converts a cache outage into a total outage; failing open means everyone falls back to database sessions and the site is slower and works. Deciding the direction of a failure is a design decision that has to be made per component, and writing it in the table is what stops it being made by default.

The last column is the product’s, not engineering’s. Whether a missing tax figure blocks issuing an invoice is a business rule, and getting it answered before the code is written is worth more than any of the mechanisms in this chapter — because a mechanism with no stated behaviour behind it just picks one.

Checkpoint

Playground

Break things. The instructive experiment is to fail the model provider with and without the fallback mitigation, and then to fail the database with everything turned on.

Exercise set

Twelve problems. Most of them build the mechanisms in this chapter from scratch — timeout derivation, jittered backoff, a retry budget, a breaker state machine, a bulkhead — because every one of them is a few lines and every one of them is wrong in most codebases. Your work is saved in this browser.

All Warm-up Core Challenge Reset chapter

Chapter 19 — Security, Shifted Left

You are partly a security engineer now, whether or not anyone said so. Next chapter: threat modelling in fifteen minutes, and the one vulnerability class no scanner will ever find for you.

Continue →