Errors and exceptions: failing loudly, safely and usefully
Wednesday, 06:10: the nightly rate refresh died at 02:04 on one timeout, at room 1,212 of 36,000. The other 34,788 chain rooms are still showing yesterday's prices, and guests are booking them.
An error is a message: something didn't happen, and here's why. Good handling makes sure that message reaches someone who can act on it.
- Problem
Every real system fails: networks drop, payment providers time out, users send bad input, and code has bugs. A booking flow must handle all of it without charging twice or losing bookings.
- Why the simple system fails
The naive approach either crashes the whole process on the first error, or wraps everything in try/catch and ignores the error, so failures become silent data corruption nobody can debug.
- Engineering concept
Classify errors (expected vs bug, transient vs permanent), fail fast on invalid state, handle errors only at boundaries that can do something useful, and log them with context.
- Technology
Exceptions and typed error classes, result types, input validation, retries with backoff for transient errors, top-level handlers and UI error boundaries, structured logs with request IDs.
- Trade-offs
Exceptions keep happy paths clean but hide failure modes from signatures; result types make failure explicit but add ceremony. Retries recover transient failures but amplify bugs and outages.
The problem
2 min- Maya · Product managerChain hotels show yesterday's prices. Guests are booking rates the chain changed overnight.
- Omar · On-call SREThe refresh crashed at 02:04: a 504 timeout on room 1,212 of 36,000. Nothing ran after it.
- Lina · Senior engineer, your mentorYou lead this one. I'll only check your answers.
The big idea
4 minAn error is information. Route it to someone who can act on it: a retry, the user, or an engineer.
Error handling never means making an error disappear. It means deciding, per kind of error, who deals with it.
How it works
5 minAn is an error that's thrown and travels up the call stack until a catch handles it, or the program stops. The 504 travelled out of the loop and ended the script. Last night's errors: 1,900 timeouts or 504s, 300 × 429, 12 rooms priced "N/A". Ask four questions:
- 1Expected or a bug?
"Price is N/A" is bad partner data we should expect. "
ratesis undefined" in our code is a bug. - 2Transient or permanent?
Transient: may work if you try again (). Permanent: fails the same way every time (bad data, wrong key, a bug).
- 3Who can handle it?
A retry, for transient errors. The chain, for bad data. An engineer, for a bug or a dead key: stop and alert.
- 4Where's the boundary?
An error boundary catches errors for one unit of work. Here: one room. One room failing must not stop the other 35,999.
- 5What context?
Room id, status code, attempt number, the original error with its .
So: retry transient errors with growing waits; skip and record permanent per-room errors; stop and alert when everything will fail (a 401) or our code is broken. Log once, with context. Never swallow an error: catch it and do nothing.
Watch a decision being made
4 minYou fix the nightly refresh (Lina checks)
One 504 killed the refresh at room 1,212 and left 34,788 rooms stale. Make the job finish, recover what it can, and make every failure visible.
- 1Put the boundary at one room
refreshRoom(id)gets its own try/catch and returns an outcome:ok,bad-dataorfailed. The loop collects outcomes instead of dying.Why: A boundary around the whole job stops at the first error; one on every line hides where it failed.
- 2Retry only transient errors
Retry 408, 429, 502, 503, 504 up to 3 times, waiting longer each time (1 s, 2 s, plus a little randomness). Anything else is rethrown.
Why: Randomness stops all 40 in-flight calls retrying at the same instant.
- 3Stop on errors that affect everything
A 401 or 403 rethrows out of the boundary and ends the job with an alert.
ts} catch (err) { if (isAuthError(err)) throw err; // every call will fail: stop, alert log.warn({ roomId: id, status: statusOf(err), attempt, err }, "refresh failed"); return { id, status: "failed" }; }Why: With a shared cause, continuing produces 36,000 copies of one error.
- 4Log once, make it safe to re-run
One line per failed room, one summary (updated, failed, skipped).
saveRatesoverwrites, so a 03:00 pass can re-try only thefailedrooms.Why: Re-runs are safe only if doing the work twice equals doing it once ().
In real life
3 minGo: errors are returned values
- Functions return
value, err; the caller checksif err != nil - Every place that can fail is visible in the code
- Cost: repetitive checks, and an ignored
erris still possible
Failure is hard to miss when reading.
Java, Python, JavaScript: exceptions
- A
throwtravels up until acatchhandles it - Short happy path; nothing in a signature says it can fail
Clean code, if the boundaries are deliberate.
What goes wrong
3 minThe failure still happened; now it's invisible.
Handle it, or log it with context and rethrow.
throw new Error("payment failed") loses the stack trace and cause.
Pass the original as cause.
3 layers × 3 retries turns one call into dozens.
Retry in one place; for payments, only with an .
Trade-offs
3 min| Option | Fits when | You pay | Main risk |
|---|---|---|---|
| Exceptions | Unexpected failures and bugs | Deliberate boundaries | Catching too broadly |
| Result values | Everyday outcomes: sold out, bad data | More code per call | Awkward with exception libraries |
| Degrade gracefully | A stale answer beats none | More paths to test | Users see stale data |
- Maya · Product managerShow the last price, labelled "price may change", and re-check the live price before charging.
- Omar · On-call SRELast price is a guess. We just paid for three days of wrong prices. Hide the room until it's fresh.
- Maya · Product managerHide 48 rooms every night, and guests go to a competitor.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- For each kind of failure: retry (timeouts, 504, 429), skip and record (one room priced "N/A"), or stop everything and alert (401).
- When a job counts as failed: "finished" with 0 of 36,000 rooms updated is a failure, and you set the threshold that wakes someone.
- What the guest sees for each error, and what stays in the logs (stack traces never reach the browser).
The nightly chain-rates refresh died at 02:04 on one 504 for room #1,212, leaving 34,788 rooms stale until 06:10. Last night: 1,900 timeouts or 504s, 300 × 429, 12 rooms priced "N/A". Make one room's failure never stop the others: retry timeouts, 504s and 429s with backoff up to 3 times; skip and record bad prices; stop and alert on a 401. Log a summary and fail the run if over 5% weren't updated. No empty catch blocks. Plan first; test each case with a fake API.
- For each error type (504, 429, bad price, 401), Claude shows which branch handles it and what's logged.
- With the fake API returning 401 for everything, the job stops and reports failure.
- The run summary adds up to 36,000 rooms.
try { … } catch {}around every call "to make the job robust".- Every error retried the same way, including 401 and bad data.
- "Job fixed" because it no longer crashes, with no count of rooms updated.
Check yourself
2 minasync function createBooking(input: BookingInput) {
const booking = buildBooking(input);
try {
await db.bookings.insert(booking);
} catch (e) {
console.log("insert failed");
}
return { ok: true, booking };
}Next episode: search results stutter at 11 frames per second on a mid-range Android phone, while the same page is smooth on Karim's laptop. Time to look inside the browser.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You write the plan, Lina checks it. She only changes one thing.
You fix the nightly refresh
One 504 timeout killed the refresh at room 1,212 and left 34,788 rooms stale. Make the job finish, recover what can be recovered, and make every failure visible.
- 1Classify last night's errors
From the chain's log: 1,900 timeouts/504, 300 × 429 (transient), 12 × price "N/A" (permanent, per room), possible 401 (permanent, affects everything), and bugs in our code (permanent).
Why: Each class needs a different response. Without classifying, you either retry everything or nothing.
- 2Put the boundary at one room
Wrap
refreshRoom(id)in its own try/catch that returns an outcome:ok,bad-dataorfailed. The loop collects outcomes instead of dying.Why: The unit of work is one room. A boundary around the whole job still stops at the first error; a try/catch on every line hides where it failed.
- 3Retry only transient errors
Inside the boundary, retry 408, 429, 502, 503, 504 up to 3 times, waiting longer each time (1 s, 2 s, plus a little randomness). Anything else is rethrown at once.
Why: Transient errors often succeed seconds later. Growing waits with randomness stop all 40 in-flight calls from retrying at the same instant.
- 4Stop on errors that affect everything
A 401 or 403 rethrows out of the boundary and ends the job with an alert.
ts} catch (err) { if (isAuthError(err)) throw err; // every call will fail: stop, alert log.warn({ roomId: id, status: statusOf(err), attempt, err }, "refresh failed"); return { id, status: "failed" }; }Why: When the cause is shared, continuing just produces 36,000 copies of one error. Stopping loudly gets a human to fix the key.
- 5Log once, with context
One structured line per failed room (room id, status, attempt count, original error as
cause), and one summary line: updated, failed, skipped.Why: "Error: request failed" at 02:04 is useless. The room id lets us re-run just that room; the counts tell Omar if tonight was normal.
- 6Make it safe to run again
saveRatesoverwrites the row for (room, date), so re-running a room twice gives the same result. A second pass at 03:00 re-tries only thefailedones.Why: Retries and re-runs are only safe if doing the work twice equals doing it once ().
export class AppError extends Error {
constructor(message: string, readonly code: string, readonly status: number, options?: { cause?: unknown }) {
super(message, options);
this.name = new.target.name;
}
}
export class RoomSoldOut extends AppError {
constructor(roomId: string) { super("Room " + roomId + " is sold out", "SOLD_OUT", 409); }
}
export class PaymentUnavailable extends AppError {
constructor(cause: unknown) { super("Payment provider unavailable", "PAYMENT_UNAVAILABLE", 503, { cause }); }
}| Error kind | Example | HTTP status | Retry? | Who acts |
|---|---|---|---|---|
| Validation | check-out before check-in | 400 | No: fix the input | The user |
| Business conflict | room sold out | 409 | No | The user |
| Transient dependency | timeout, 503, 429 | 503 after retries | Yes, with growing waits, if idempotent | Retry logic, then on-call |
| Shared permanent | 401: API key revoked | n/a (stop the job) | No | An engineer, now |
| Bug | undefined is not a function | 500 | No | An engineer |
Interactive simulation
Visual diagram
Where a payment timeout is thrown, translated, retried, logged and shown. Start the walkthrough.
Analogy
- At a hotelRefusing a form with no name at the deskIn codeFailing fast on bad input
- At a hotelThe duty manager handling escalationsIn codeAn error boundary
- At a hotel"Connection lost, try again"In codeA temporary error worth retrying
- At a hotel"Card declined"In codeA permanent error: don't retry
- At a hotelSeeing a burst pipe and saying nothingIn codeSwallowing an error
- At a hotel"Room 412, 14:03, pipe under sink"In codeA log with context
Where the analogy breaks: hotel staff can use judgement in the moment. Code can't, so every decision about retrying, translating or logging has to be written in advance.
Check your understanding
Practice ladder
Trace a booking through the error handler
Debugging Warm-upStayHub's booking flow:
async function book(req) {
assertValidStay(req.checkIn, req.checkOut, req.guests); // throws ValidationError (400)
const room = await reserveRoom(req.roomId); // throws RoomSoldOut (409)
const payment = await chargeWithRetry(req.quote, req.idempotencyKey); // retries 503 ×3, then PaymentUnavailable (503)
return saveBooking(room, payment);
}
// The HTTP boundary maps AppError → its status, anything else → 500 "Something went wrong" + requestId, and logs once.For each case, trace what happens: which line fails, is anything retried, what status and message does the guest get, and what is logged?
- check-out is before check-in
- the payment provider returns 503 twice, then succeeds
reserveRoomhas a bug:room.price.amountwhenpriceis undefined
Finish the classification table
Explain PracticeLina's table for the new hotel-photo import has gaps. Fill in each ___: transient or permanent, and what the job does (retry, skip and record, or stop and alert).
| Error | Transient? | Job does |
|---|---|---|
| Photo server times out | transient | retry with growing waits, max 3 |
| Photo URL returns 404 | ___ | ___ |
| Storage bucket says "403 access denied" | ___ | ___ |
| Image file is corrupted (can't be decoded) | ___ | ___ |
| 429 Too Many Requests | ___ | ___ |
The confirmation emails nobody received
Design PracticeSupport: "Five guests this week say they never got a confirmation email." The email step in the booking flow:
try { await email.send(confirmation); } catch (e) { /* email is not critical */ }There are no logs or metrics for email. Redesign the error handling for confirmation emails: what to catch, what to retry, what to log, and how you'd find out next time before guests tell support.
Retry a payment that timed out?
Decision StretchThe payment provider sometimes takes over 10 s and our call times out. A timeout doesn't tell us whether the card was charged. Last month: 40 timeouts out of about 12,000 payments.
Karim: "Just retry timeouts automatically, three times." The provider supports idempotency keys, but our code doesn't send one yet.
Write your decision: "We chose X over Y because…, accepting the risk of…". Say what must be true before any automatic retry.
Explain it yourself
A payment call in your booking API fails. Walk through how your code should decide what to do, from the failing call up to what the guest and the on-call engineer see.