Lesson 1 · Milestone: Partial failure

Partial failure: the third outcome

Inside one program, a function call either returns or throws. Across a network there is a third outcome, and every technique in this course exists because of it.

By the end you'll be able to look at any failed remote call and say what the caller can know: it was done, it wasn't done, or it's unknown. You'll also see, with real numbers from the lab, why the obvious fix ("no answer? send it again") charges customers twice.

remote callAsking another machine to do something, by sending it a message and waiting for a reply.
side effectA change in the world the call makes: a charge, an email, a row. Repeating it repeats the change.
partial failureSome parts of the system fail while others keep working, and nobody sees the whole picture.
unknown outcomeThe caller can't tell whether the side effect happened.
seedA number that fixes the lab's random fault schedule, so a run can be repeated.

The problem: you can't see the other machine

Our running example is a shop. When a customer checks out, the shop calls PayCo, a payment provider like Stripe, to charge their card. In code it looks like a function call: await payco.charge(order). But it isn't one. It's two messages between two computers that share nothing except a network. The request travels to PayCo, PayCo does the work, and a response travels back. Either message can be lost or delayed, and either computer can crash, independently of the other.

Martin Kleppmann's Cambridge course puts the consequence plainly: "there is no way for one node to have certainty about the state of another node. The only way how a node can know something is by having that knowledge communicated in a message"[1]. If the message doesn't come, the shop knows nothing.

Mental model: a remote call is a letter, not a function call. You post a letter asking your bank to transfer money. If no reply comes, you can't tell whether your letter was lost, the bank did the transfer and its reply was lost, or the reply is just slow. Silence carries no information. The model breaks down in one way: real networks also duplicate and reorder messages, and the server can crash halfway through the work. Stripe's engineers list exactly these three ways a call can fail: the connection fails before reaching the server, the call fails "midway while the server is fulfilling the operation", or "the call could succeed, but the connection break before the server can tell its client about it"[2].

Shopcaller PayCodoes the work ① request ② response WHERE IT BREAKSCHARGED?CALLER SEES request lost on ①nosilence response lost on ②YESsilence response slow on ②YESsilence (for now) identical view → unknown outcome
The caller's view is the same in three different worlds. It can't decide between them without more information.

Watch it happen. Press ▶, use the chapters, or turn on 🔇 narration. The terminal shows the real output of npm run l1.

Three outcomes, not two

After a remote call, the caller is in exactly one of three states. What decides it is what came back, not what the caller hoped for:

What came backThe caller knowsExample
A success responseDone.200 · charge ch_41
A refusal the server promises it made before doing any workNot done.503 from an overloaded PayCo · 400 "amount must be positive"
Nothing (yet), or the connection broke after the request was sentUnknown.a timeout · a connection reset

Two details matter. First, "not done" depends on a contract: a 503 only means "not charged" if PayCo promises to refuse before doing work. The lab's PayCo makes that promise; check the docs before you assume it of a real API. Stripe, the model for PayCo, does not: "Treat the outcome of a request that returns 500 as indeterminate: the operation might have produced side effects even though you received an error"[3]. For Stripe, a 5xx is unknown. Second, a reply that arrives late is still a reply: if it says "charged", the charge happened, even though you already gave up waiting. (The lab's network quietly discards replies that arrive after you've timed out, so you'll only see their effect in the ledger.)

Real HTTP clients give you one useful refinement. A connect timeout means the connection was never set up, so the request was never sent: not done. A timeout after the request was sent, while waiting for the reply, is unknown.

The two generals, and why a shop isn't doomed

The classic version of this problem is the two generals problem: two armies must attack together, and can only communicate by messengers who might be captured. Kleppmann shows that no protocol, however many confirmations it sends, gives both generals certainty[1]. His notes then apply it to an online shop and a payment service, where we want "online shop dispatches if and only if payment made".

Then comes the hopeful part. The shop's version is solvable, because "a payment is something that can be undone (unlike an army being defeated)". And: "If the communication between shop and payment service is interrupted, the shop can wait until the connection is restored, and then query the payments service to find out the status of any transactions whose outcome was unknown"[1]. Keep that sentence in mind. Lesson 6 builds exactly that query, called reconciliation.

What the lab shows

The lab, labs/payflow/, simulates the network inside one Node process, so you can make each fault happen on purpose. lib/net.ts has five faults. Read its header comment: it's the table above, in code. Part A of npm run l1 sends one charge per fault, with a 30 ms timeout:

  fault           caller saw   charges at PayCo   caller can know
  ok              ok ch_1                     1   charged
  drop-request    timeout                     0   ??? unknown
  drop-response   timeout                     1   ??? unknown
  slow            timeout                     1   ??? unknown
  reject-503      error 503                   0   not charged

Part B runs 200 checkouts with each fault at 5% and the naive policy: "on a timeout or a 503, send the charge again, up to 3 tries". The result, from the PayCo ledger:

  charged once, shop says paid               178
  not charged, shop says failed                0
  charged 2+ times  ← double charge           22
  charged, but shop says failed                0

22 of 200 customers paid twice (seed 1). Watch the last row on other seeds too: with SEED=42 one customer was charged while the shop told them the payment failed. That's the other half of the harm. The resend was the right move for a lost request and for a 503, but the caller couldn't tell those apart from a lost or slow response. Not retrying isn't safe either: then every lost request becomes a lost sale, and an order may sit there charged while the shop says "payment failed". Kleppmann names the two simple policies: at-most-once ("send request, don't retry, update may not happen") and at-least-once ("retry request until acknowledged, may repeat update")[1]. Neither is what a shop wants. The rest of the course gets you to the third: retries that are safe.

unknownL1 L2 stop waiting L3 retry, gently L4 make it safe L5 across servicesoutbox never twiceL6 final
Where the course goes from here. Each lesson adds one file to labs/payflow/src/, and the final lesson composes them.

Practice: from memory

No scrolling up.

Your charge call to PayCo times out after 30 ms. Was the customer charged?

PayCo replies 503, and its docs promise it refuses before doing any work. The charge…

Why can't the caller tell a lost request from a lost response?

With the naive "no answer? send it again" policy, which fault turns into a double charge?

The two generals can never be certain. Why is the shop-and-payments version still solvable?

Sort the observations

Which leg broke?

🏆 Milestone challenge: Partial failure

Two parts. The first is in the lab, the second is about a system you actually use.

cd labs/payflow
npm run l1                                              # seed 1
cp results/01-partial-failure.json results/01-seed1.json   # the next run overwrites it
SEED=42 npm run l1                                      # pick any other seed too
  1. Read your results. In results/01-partial-failure.json, find Part B's double charges and partB.faultsBehindDoubles (the faults hit by the calls of double-charged orders; an order may also have hit a harmless fault on another attempt). In 2–3 sentences: which fault types caused them, and why would not retrying be wrong too? If your second seed has a "charged, but shop says failed" order, explain it as well.
  2. Transfer it. Pick one remote call from something you've built or use: sending an email through an API, creating a GitHub issue, an LLM tool call that writes to a database, a webhook. Write a 4-row table: where it breaks (request lost · server crashes midway · response lost · server refuses; the lab has no "crash midway" fault, but its drop-response looks the same to the caller) → did the side effect happen? → what the caller sees → done / not done / unknown. Then say what goes wrong for this call if you retry blindly, and if you never retry.
  3. Look ahead. For your unknown rows, name one piece of information the caller could get afterwards to resolve the outcome. Does the real API offer a way to get it?

Send me back: both result files (01-seed1.json and your seed's 01-partial-failure.json), your answer to 1, and the table plus answer for 2 and 3.

Pass criteria (check yourself before sending):

When I've reviewed it, I'll write a learning record and unlock Partial failure.

Read or watch next

Primary source: Distributed Systems 2.1: The two generals problem, Martin Kleppmann. It's about 12 minutes, and ends with the shop-and-payment version of the problem this lesson used.

💬 Not sure whether a row in your table is "unknown" or "not done"? Ask me in the terminal. Paste the API's docs or error list and we'll work through it together.