Carry one absolute deadline through every hop, size each call's timeout from what's left, and cap each dependency's concurrent calls so a slow bank can't take the whole service down.
The user will wait 5 seconds. If the gateway has already used 3 of them and the payment service still gives the bank its own 5-second timeout, the request can take 8 seconds, long after the user left (Day 1, section 3.3). The fix is to pass one absolute deadline down the chain and size every call's timeout from what is left. The second half of Day 1's advice is the bulkhead (section 7.4, Mitigation 2): cap the concurrent calls to each dependency and reject the overflow at once, so a slow bank ties up at most its own permits and fraud checks keep working.
You get a FakeClock (seconds), three error classes and FakeService (see Given). Build three pieces.
1. Deadline(expires_at, clock)
| Member | Does |
|---|---|
Deadline.after(seconds, clock) |
a deadline seconds from now |
remaining() |
seconds left, never below 0 |
expired() |
True once now >= expires_at |
timeout_for(default, reserve=0.1) |
the timeout for the next call: min(default, remaining − reserve). If remaining <= reserve there's no time worth calling with: raise DeadlineExceeded |
to_header() |
the deadline for the next hop's X-Deadline header: expires_at with 3 decimals ("1005.000") |
Deadline.from_header(value, clock, default=5.0) |
parse an incoming header. A missing (None) or non-numeric value means the caller sent no deadline: start one default seconds from now. A deadline already in the past stays in the past |
2. Bulkhead(max_concurrent): try_acquire() returns True and takes a permit, or False at once when all are taken (no waiting, no queue); release() gives one back; in_flight is the number held; call(fn) takes a permit or raises BulkheadFull, runs fn(), and always gives the permit back, even when fn raises.
3. process_payment(deadline, fraud, bank, notify, bulkheads): the lesson's payment flow (section 6.6). Each step calls service.call(timeout, deadline_header) through bulkheads[name].call(...) with timeout = deadline.timeout_for(DEFAULT) (given: FRAUD_TIMEOUT = 0.6, BANK_TIMEOUT = 3.5, NOTIFY_TIMEOUT = 0.3) and deadline_header = deadline.to_header(). Return a dict:
| What happens | Return |
|---|---|
| no time for the fraud check | {"status": "error", "reason": "deadline_exceeded"}, nothing called |
| fraud check times out or its bulkhead is full (it's required: in doubt, refuse) | {"status": "rejected", "reason": "fraud_unavailable"} |
| no time left for the bank | {"status": "error", "reason": "deadline_exceeded"} |
| bank bulkhead full: the bank was never called, so the client can retry safely | {"status": "error", "reason": "bank_busy"} |
| bank times out: the charge may or may not have happened | {"status": "error", "reason": "bank_timeout"} |
| bank succeeds | {"status": "success", "notified": True}, or "notified": False when the notification can't be sent (timeout, full bulkhead or no time left): it's optional and must never fail a payment that went through |
In JavaScript and TypeScript: Deadline.after(seconds, clock), remaining(), expired(), timeoutFor(defaultTimeout, reserve = 0.1), toHeader(), Deadline.fromHeader(value, clock, defaultTimeout = 5) (a missing header is null); Bulkhead with tryAcquire(), release(), inFlight and call(fn); processPayment(deadline, fraud, bank, notify, bulkheads) with service.call(timeout, deadlineHeader), returning { status, reason } or { status: "success", notified }.
Write your code, then run 3 example tests and 10 hidden tests. Each run is limited to 8 seconds: an infinite loop gets stopped and the runtime restarts, your code stays.