Blast radius, fail-open vs fail-closed, and the resilience patterns that keep a failure local. Two live drills, four real outages.
Recommendations was never business-critical. So why is checkout down? By the last slide you'll have a firebreak for every line above, and a name for each one.
At scale something is always broken. Reliability isn't the absence of failure, it's making sure a failure stays small and visible. The design question is never "will it fail" but "what does it take down with it?"
You can't stop a fuse from ever blowing. So you wire the house so one blown fuse darkens one room, not the whole building, and you make sure a light comes on to tell you which one went.
Before a component ships, you should be able to say exactly what dies when it dies. Walk the dependency graph outward from the component: every caller that can't tolerate its absence is inside the blast radius. You compute this before the incident, not during.
flowchart TB
AUTH(["Auth service"]) --> API(["API gateway"])
CACHE(["Session cache"]) --> API
API --> WEB(["Web app"])
API --> MOB(["Mobile app"])
REC(["Recommendations"]) --> WEB
classDef hit fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef opt fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
class AUTH,API,WEB,MOB hit;
class CACHE ok;
class REC opt;
A command typo took S3 offline in us-east-1 for ~4 hours and much of the web went with it. The kicker: the AWS status dashboard itself depended on S3, so it stayed green through the whole outage. Nobody had mapped their own blast radius.
For every dependency edge, label it hard (caller cannot function without it) or soft (caller degrades but survives). The blast radius of a node is everything reachable through hard edges. The cheapest reliability win in any review is turning a hard edge into a soft one.
"Hard" is a load-bearing wall, knock it out and the floor above falls. "Soft" is a decorative panel, lose it and the room just looks worse. Reliability work is mostly converting walls into panels.
You learn resilience by drawing your own graph, not by watching mine. Four minutes now saves an hour of the capstone later.
An unbounded call is how a single slow dependency becomes a site-wide outage. These four patterns each bound a different axis: time, repetition, persistence, and concurrency. We take one slide each, with the failure it prevents.
A crash is honest, it frees the thread and surfaces an error. A hang holds the thread, the connection, and the user, forever. Give every outbound call a timeout, and make the timeouts add up: each hop gets a slice of the budget left by the hop above it.
flowchart LR U(["User
budget 1000ms"]) --> A(["API
900ms left"]) A --> B(["Service
600ms left"]) B --> D(["DB query
400ms cap"]) D -.->|"over budget?"| X(["fail fast,
don't hang the chain"]) classDef io fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; class U,A,B io; class D ok; class X bad;
If you've got 10 seconds to catch a train, you don't give the ticket machine 9 and the platform run 9. You split the 10 across every step, and bail the moment a step blows its share. Timeout budgets are that split.
Retries paper over a blip, but a naïve retry is a loaded gun. Retry only idempotent, transient failures (timeouts, 503s), never a 400. Back off exponentially, and add jitter so a thousand clients don't all retry on the same beat and hammer a recovering service flat again.
flowchart TB
F(["dependency blips"]) --> S{"retry strategy?"}
S -- "immediate, in lockstep" --> STORM(["retry storm:
synchronized spike
re-kills the service"])
S -- "backoff + jitter" --> CALM(["spread-out retries:
service recovers"])
classDef io fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
classDef ask fill:#ffffff,stroke:#1F2937,color:#0E1726;
classDef good fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
class F io;
class S ask;
class CALM good;
class STORM bad;
An overloaded metadata service triggered a retry storm that kept re-killing it for hours across us-east-1. AWS's fix, and the write-up engineers still cite today, was exponential backoff plus jitter.
Retries feel local, but they compound at every hop. If each of four layers retries three times on failure, one user click doesn't make one backend call. It makes…
Four people each ask three times when they don't hear back. The person at the bottom of the chain gets asked eighty-one times. They were only slow, now they're buried.
When a dependency is clearly down, retrying just wastes threads and slows everyone. A breaker watches the failure rate: trip open and fail fast for a cool-off, then go half-open to let one trial call test the water before you trust it again. Without half-open you either stay dark forever or slam it back open blind.
stateDiagram-v2
[*] --> Closed
Closed --> Open: failure rate over threshold
Open --> HalfOpen: cool-off elapsed
HalfOpen --> Closed: trial call succeeds
HalfOpen --> Open: trial call fails
note right of Closed: calls flow, failures counted
note right of Open: fail fast, no calls
At Netflix scale, one struggling downstream tied up threads across dozens of services until the whole edge browned out. Their answer, the Hystrix circuit breaker, made "stop calling the dead, then probe gently" an industry default.
Share one thread or connection pool across every dependency and the slowest one starves all the others: a stuck "recommendations" call holds threads that "checkout" needed. Give each dependency its own bounded pool. One floods, the rest sail on.
flowchart TB
subgraph Shared["One shared pool · fragile"]
R1(["slow dep eats
every thread"]) --> DOWN(["checkout starves too"])
end
subgraph Bulk["Bulkheaded · isolated"]
P1(["pool: checkout"]) --> OKC(["unaffected"])
P2(["pool: recommendations"]) --> SLOW(["only this degrades"])
end
classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726;
classDef good fill:#D6F5E3,stroke:#1F2937,color:#0E1726;
classDef opt fill:#FEF3C7,stroke:#1F2937,color:#0E1726;
class R1,DOWN,SLOW bad;
class P1,OKC good;
class P2 opt;
Ships have bulkheads: seal the hull into compartments and one breach floods one room, not the whole vessel. Separate pools are watertight compartments for your threads, and it's exactly the pool at 02:14 that sank checkout.
When a dependency dies, the path either keeps working permissively (fail-open, favours availability) or refuses safely (fail-closed, favours safety). There is no global answer, it's per path. Saying which, and why, is the highest-signal sentence in a design review.
flowchart LR AUTH(["Auth check down"]) --> AC(["FAIL CLOSED:
deny access
safety beats uptime"]) REC(["Recommendations down"]) --> RO(["FAIL OPEN:
show generic list
uptime beats perfection"]) PAY(["Fraud score down"]) --> PC(["FAIL CLOSED:
hold the charge
money is safety-critical"]) classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef good fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef opt fill:#FEF3C7,stroke:#1F2937,color:#0E1726; class AUTH,PAY bad; class AC,PC bad; class REC opt; class RO good;
Knight Capital, 2012: a bad deploy fired live trades with no kill switch — ~$440M gone in 45 minutes. Cloudflare, 2019: one unbounded regex failed the whole edge closed — global 502s in minutes. Wrong mode, either way, is fatal.
This exact sentence is the staff move in a real design review. Saying it under mild pressure now is how it becomes reflex when it counts.
When you can't serve the perfect response, serve a useful one. Degradation is a designed mode, not an accident: decide in advance what you drop first and what you protect to the last. The user should notice less, not everything at once.
When a kitchen gets slammed it doesn't shut, it trims the menu to the dishes it can still cook well. Customers get fed, just fewer choices. That's graceful degradation.
Here is the cold open, drawn out. A single slow dependency fills retry queues, the retries exhaust the shared pool, callers time out and retry harder, and the slowness climbs the stack until the whole site is down, often well after the original fault healed. Every pattern tonight is a firebreak that stops the climb at one hop.
flowchart LR D(["one DB slows"]) --> Q(["retries pile up"]) Q --> P(["shared pool exhausted"]) P --> T(["callers time out,
retry harder"]) T --> G(["site-wide outage"]) P -. "bulkhead" .-> FB1(["pool stays local"]) Q -. "breaker + jitter" .-> FB2(["storm never forms"]) classDef bad fill:#FEE4E2,stroke:#1F2937,color:#0E1726; classDef good fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class D,Q,P,T,G bad; class FB1,FB2 good;
One stalled car becomes a five-mile jam because everyone behind brakes and reacts. Breakers, bulkheads and jitter are the hard shoulder and the merge lights: they keep one stall from freezing the whole motorway.
A booking app (think the ClubCP case study): web → API → bookings DB, with an email provider and a recommendations service hanging off the side. The email provider goes slow. Walk the toolkit and the failure stays a footnote instead of a front-page outage.
flowchart TB WEB(["Web"]) --> API(["API"]) API --> DB(["Bookings DB
hard edge · fail closed"]) API --> EMAIL(["Email provider
soft · timeout 800ms"]) API --> REC(["Recommendations
soft · fail open"]) EMAIL -. "slow" .-> BRK(["breaker trips →
queue email, return 200"]) REC -. "down" .-> DEG(["serve generic list"]) classDef ok fill:#D6F5E3,stroke:#1F2937,color:#0E1726; classDef io fill:#FEF3C7,stroke:#1F2937,color:#0E1726; classDef good fill:#D6F5E3,stroke:#1F2937,color:#0E1726; class WEB,API,DB ok; class EMAIL,REC io; class BRK,DEG good;
The booking still confirms. The receipt email is queued for later (breaker + bulkhead kept it off the critical path), the "you might also like" quietly falls back (fail open), and the booking write itself is protected (fail closed). One dependency limped, nobody lost a booking.
Every pattern tonight is a scar. Somebody shipped the version without it, at scale, and wrote the postmortem so you don't have to.
# PaymentService · resilience.yaml (up for review)
http:
connect_timeout: none
read_timeout: none
retry:
attempts: 5
backoff: fixed 200ms
retry_on: [500, 503, 400, timeout]
breaker:
trip_after: 20 failures
reset: manual
pools:
mode: shared
size: 200 threads
used_by: [fraud, ledger, email, recs]
These aren't exotic. They're the default you get when nobody decided. Naming them in a review is cheap insurance, and it's exactly the staff-level instinct we're building.
Assume everything fails, map the blast radius, and bound every call with timeouts, retries with jitter, breakers and bulkheads. Then decide fail-open vs fail-closed per path, out loud, and design the degradation modes before you need them.