27 min read

Chapter 1 — Timeouts and Deadlines

Every call needs a limit on how long it waits, and every request needs one budget that all its hops share

Prerequisites: SD QA Q33 — Timeout, deadline, retry budget (the three ideas side by side), JS 7, Part 7 — Timeouts per attempt vs an overall deadline (AbortSignal.any in code), Debugging 13 — Slow, flaky and falling over (Case 3: timeouts in the wrong order, Case 7: one slow dependency took everything down), Debugging 9 — Inside the database (Part 6: Postgres timeouts).


How to read this chapter

The deep chapters above already explain why timeouts and deadlines exist. This chapter covers what they leave thin: which part of a call each timeout measures, how a deadline is actually carried from one service to the next, and what happens, step by step, when there is no timeout at all. Every number in Parts 2, 3 and 4 was measured on Node 22.23 against local servers. The code is plain ESM and runs as written.

The interviewer's version of this topic is short: "That downstream now takes 30 seconds. What happens?" A strong answer traces the wait through the design and names the resource that runs out first. The parts below give you the mechanism for that answer.

TermMeaning
Connect timeoutHow long to wait for a TCP connection (and, in most clients, the TLS handshake) to complete
Headers timeout / time to first byteHow long to wait, after sending the request, for the response to start
Idle (read) timeoutHow long the connection may go silent between chunks. It resets on every byte
Total timeoutA cap on the whole call, from start to the last byte of the body
Pool acquire timeoutHow long a request may wait in your own process for a free pooled connection
Keep-alive idle timeoutHow long an unused pooled connection is kept open before it is closed
DeadlineAn absolute moment after which the result is useless, for the whole request across every hop
Deadline propagationPassing the remaining time to each downstream call, so each hop works inside the same budget
Cascading failureOne part's failure adds load or waiting to others until they fail too
Little's LawRequests in flight = arrival rate × time each one takes

Part 1: One call has six phases, and each can hang

"Add a timeout" hides a question: a timeout on what? An outbound HTTP call from a Node service goes through these phases, in order:

#PhaseWhat is happeningWhat a hang here usually meansThe timeout that catches it
1Pool acquireWaiting inside your process for a free connection to that hostYour own pool is full, often because of other slow callsPool acquire timeout, or a total timeout that started before the wait
2DNSResolving the hostnameResolver down or slowRarely its own setting; the total timeout covers it
3TCP connectSYN, SYN-ACK, ACKThe host is gone and its packets are dropped (a blackhole), or its accept queue is fullConnect timeout
4TLS handshakeCertificates and keys, one round trip in TLS 1.3, two in TLS 1.2The process accepts connections but is wedgedConnect timeout (in undici, as measured below)
5Time to first byteThe request is sent; the server is workingThe server is slow or stuck on its dependencyHeaders timeout, or the total timeout
6BodyBytes arrive in chunksA stream stalls, or trickles foreverIdle timeout for a stall; only a total timeout for a trickle

Two consequences matter in the room.

First, a failure in phases 1–4 means the request never reached the server's application code. A connect timeout on a POST /charges is safe to retry: no charge happened. A timeout in phase 5 or 6 is different: the server may have done the work. Chapter 2 builds on this. One caveat: behind a load balancer, your connect timeout only measures the hop to the load balancer. Its own connection to the backend is a separate hop with its own timeout, and it reports that failure to you as a 502 or 504.

Second, the phases fail for different reasons, so they need different values. Inside one region a TCP connect takes about a millisecond. A connect that has not finished in a second is not "slow"; the host is gone. A response, on the other hand, can legitimately take 800 ms. One shared 10-second number is too long for the first and possibly too short for the second.

Idle timeouts mean two different things

The word idle is used for two unrelated timers, and mixing them up is a common interview slip.

Idle timeout during a responseKeep-alive idle timeout on a pooled connection
MeasuresSilence between chunks of one responseHow long an unused connection stays open for reuse
Node / undici settingbodyTimeout (undici), socket.setTimeout / the timeout option of http.requestkeepAliveTimeout on the server, the client pool's idle timeout
CatchesA stream that stoppedNothing; it frees resources
Classic bugThinking it caps the total time (it doesn't, see Part 2)The load balancer reuses a connection the app just closed, giving rare 502s (Debugging 13, Case 2)

The keep-alive rule from that case is worth one sentence in an interview: the side that reuses connections must give up on idle ones first. If the load balancer keeps idle connections for 60 s, the app's keepAliveTimeout must be longer than 60 s.

A streaming LLM call needs three timeouts

The reader's own systems stream LLM tokens, which makes the distinction concrete. One total timeout cannot tell a healthy long answer from a stuck one. A streaming call needs:

TimeoutExample valueWhy
Time to first token10 sA provider that has not started in 10 s is queueing or down. Fail over now, while the user is still waiting
Idle between tokens15 sA stream that goes silent mid-answer has stalled. A healthy one sends tokens every few hundred ms
Total120 s, or the job step's deadlineCaps a runaway answer and the bill

The values are examples; derive yours from the provider's measured latency (Part 5).


Part 2: What Node 22 actually does

These are the defaults you get if you write await fetch(url) and nothing else. Each row was run against a local server: one that accepts the connection and never answers, one that sends a byte per second forever, a plain TCP server that never speaks TLS, and the unroutable address 10.255.255.1, which drops packets the way a dead host behind a firewall does.

SetupWhat happenedMeasured
fetch, no options, server accepts and never answersRejected with fetch failed, cause UND_ERR_HEADERS_TIMEOUT300.9 s
fetch, no options, blackholed addressUND_ERR_CONNECT_TIMEOUT, "timeout: 10000ms"10.5 s
fetch to https:// on a TCP server that never does TLSSame connect timeout error10.0 s
node:http get, no options, blackholed addressETIMEDOUT from the operating system (macOS)75.0 s
node:http with timeout: 1000, silent serverA 'timeout' event at 1,003 ms. The request was not destroyed and was still open at 3.5 snever ends on its own
fetch with signal: AbortSignal.timeout(2000), silent serverTimeoutError; the server saw the connection close2,007 ms
undici bodyTimeout: 2000, server trickles 1 byte per secondNever fired: still reading after 7 snever ends
Same trickle, AbortSignal.timeout(3000)TimeoutError during the body read, after fetch had resolved3,004 ms
undici headersTimeout: 2000 / connect: { timeout: 1000 }Fired late2,502 ms / 1,501 ms

What to take from the table:

  • Node's fetch (undici) has defaults, and they are long: a 10 s connect timeout, then 300 s for the response headers and 300 s of idle time between body chunks. "No timeout" in Node fetch means "five minutes per stuck call", which under load is the same as forever.
  • node:http has no connect timeout of its own. The operating system gives up: 75 s on macOS, about two minutes on Linux with default settings (net.ipv4.tcp_syn_retries = 6).
  • The timeout option of http.request only emits an event. It is an idle timer. Unless your handler calls req.destroy(), the request stays open. This is one of the most common "we had a timeout" bugs in Node code review.
  • Idle timeouts don't cap total time. A server that sends one byte per second resets bodyTimeout forever. Only a total timeout (AbortSignal.timeout) stops it, and it works during the body read too, not only until headers arrive.
  • undici's own timers are coarse, about half a second late in every run. That is fine for safety limits. Use AbortSignal.timeout for anything you want precise.

A Node outbound client that covers every phase looks like this:

js
import { Agent } from 'undici'; // the npm package; Node's built-in fetch uses its own copy

// One Agent per dependency (see Part 3). Short connect, coarse safety limits.
const pricingPool = new Agent({
  connections: 20,              // per origin; the default is unlimited
  connect: { timeout: 1_000 },  // TCP + TLS
  headersTimeout: 2_000,
  bodyTimeout: 2_000,           // idle gap, not total
});

export async function getPrice(sku, { signal } = {}) {
  // Total cap for this call, and the request's deadline, whichever is first.
  const attempt = AbortSignal.any([signal, AbortSignal.timeout(500)].filter(Boolean));
  const res = await fetch(`https://pricing.internal/prices/${sku}`, { dispatcher: pricingPool, signal: attempt });
  if (!res.ok) throw Object.assign(new Error(`pricing ${res.status}`), { status: res.status });
  return res.json(); // the signal still covers the body read
}

One more measured detail: a total timeout started before the pool wait includes the pool wait. In Part 3's run, a /price call with a 2 s AbortSignal.timeout failed with TimeoutError after 2,003 ms without ever leaving the process, because all 10 connections to that host were held by hung calls. The error looked exactly like a slow server. Log the time spent waiting for a connection separately, or you will blame the wrong service.

The server side has timeouts too

Your service is someone else's downstream. Defaults, measured on Node 22 and Postgres 18:

SettingDefaultWhat it protects
Node server.requestTimeout300,000 msCaps how long a client may take to send the whole request (slow-request attacks), not your handler's time
Node server.headersTimeout60,000 msCaps how long a client may take to send headers
Node server.keepAliveTimeout5,000 msHow long an idle keep-alive connection is held
Postgres statement_timeout0 (no limit)Caps one statement. SET LOCAL statement_timeout = '500ms' inside a transaction gave canceling statement due to statement timeout at 500 ms
Postgres lock_timeout0 (no limit)Caps waiting for a lock
Postgres idle_in_transaction_session_timeout0 (no limit)Ends a session left open inside a transaction
node-postgres connectionTimeoutMillis0 (wait forever)How long a query waits for a pooled connection (pool max defaults to 10)

Nothing in Node stops your handler from running for an hour. That is your code's job, and Part 4 is how.


Part 3: No timeout, as a cascade

Here is the failure the interviewer wants you to trace. The physics is Little's Law: in flight = rate × duration. At 50 requests per second and 80 ms each, 4 requests are in flight. If the duration becomes "forever", the in-flight count grows by 50 every second, and every one of them holds something: memory, a socket, a pooled connection, maybe a database connection.

Slow is worse than down. A dead server refuses connections or fails health checks; callers get fast errors and the load balancer removes it. A slow server passes health checks and keeps getting its share of traffic. In the sim, server B becomes 5× slower at 1 s but stays healthy. Predict what happens to p99 under round robin, then watch B's queue.

Loading simulation…

The measured run

The setup: an API with two routes. /home calls a recommendations endpoint and a price endpoint in parallel. /checkout calls only the price endpoint. Both go to the same downstream host through one shared undici Agent with 20 connections. At t = 0 the recommendations endpoint stops answering; the price endpoint stays healthy (5 ms). Load: 50 /home and 10 /checkout per second for 10 s. The client gives /checkout 5 s.

Variant/home in flight after 10 s/checkout succeeded/checkout failed
No timeout, one shared pool479, growing about 48 per second4 of 99 (the first 0.4 s)95
300 ms timeout on recommendations, page renders without them15, flat99 of 99, p99 10 ms0
Separate pool for recommendations, no timeout480, still growing99 of 99, p99 11 ms0

Read it as a timeline:

#
Recommendations
/home handlers
Shared pool (20)
/checkout
1
stops answering at 0 s
50 per second arrive, each awaits recs
0 of 20 used
8 ms, fine
1 / 7

Three points to say out loud:

  1. The downstream was not overloaded. It had 20 open requests the whole time. The failure was entirely waiting, inside the caller. Its database and CPU graphs look fine, which is why this failure confuses people during an incident.
  2. The healthy endpoint died first in user terms. /checkout failed because it shared a queue with a call that never returned. The general version: a shared, bounded resource (connection pool, worker pool, thread pool, DB pool) plus an unbounded wait equals a full resource.
  3. The timeout and the bulkhead fix different things. The timeout bounds how long anything waits (15 in flight instead of 480). The bulkhead bounds who shares the waiting. Chapter 3 covers bulkheads in depth.

Where else unbounded waits hide

WaitDefaultWhat fills up
Outbound HTTP with plain fetch300 s for headersSockets, the per-origin pool, handler memory
node:http with the default agentNo limit on sockets (maxSockets: Infinity, measured)The downstream: 500 hung calls opened 500 connections to it in the run
Waiting for a pooled DB connection (node-postgres)ForeverEvery route that touches the DB
A Postgres lock waitForeverThe connection holding the wait, then the pool
A message the worker awaits from a dead LLM providerWhatever the SDK setsWorker concurrency slots; the queue backs up
A Promise.race timeout that does not abortThe losing call keeps runningSockets, tokens you pay for (JS 7, Part 9)

The last measured detail: with the unlimited default dispatcher, 500 hung requests became 500 TCP connections on the downstream. An unbounded client pool does not protect you, and it moves the problem to the dependency, which now has to hold 500 connections it cannot serve.


Part 4: Deadlines that travel

A deadline is one budget for the whole request. Deadline propagation means every hop knows how much of it is left. Q33 explains why; here is how it is carried and what each hop does with it.

Relative, not absolute, on the wire

You can send either an absolute deadline ("finish by 14:02:07.250") or the time remaining ("you have 1,450 ms"). An absolute timestamp only works if both machines' clocks agree; a 300 ms clock skew silently moves the deadline by 300 ms. So the usual choice is to send the remaining time and convert it to a local deadline on arrival:

  • gRPC does exactly this. The caller's deadline is sent as a grpc-timeout header holding a relative value (for example 1450m, meaning 1,450 milliseconds). The receiving server turns it into a local deadline, and a call that runs past it fails with status DEADLINE_EXCEEDED. In Go the deadline rides in the context and is passed on when you use that context for the next call. In Node's @grpc/grpc-js you pass a deadline in each call's options.
  • Envoy sets x-envoy-expected-rq-timeout-ms on requests it forwards, telling the upstream the timeout Envoy will apply, so the service can give up early.
  • Plain HTTP has no standard header. Pick one (x-request-timeout-ms below) and treat it like a trace ID: every outbound call carries it.

Relative time has one flaw: the time the request spent on the wire is not subtracted, so each hop's local deadline is slightly later than the caller's. Each hop should subtract a small reserve for that and for writing its own reply.

What each hop does

At every hop, the same five steps:

  1. On arrival, compute a local deadline: now + header - reserve. If there is no header, use your own default for this route.
  2. Before each outbound call, compute remaining = deadline - now. If it is less than what the call needs to be useful (say, the callee's p50), don't make the call: fail fast or use the fallback. Sending work that cannot finish only adds load.
  3. Set the call's timeout to min(remaining, this call's own cap) and send remaining in the header.
  4. Stop when the caller leaves. If the upstream aborts, its connection closes; in Node, res.on('close') fires with res.writableEnded === false. Abort your downstream calls and queries.
  5. Pass the signal into everything that waits: fetch, the DB query, the LLM SDK call, the sleep between retries.

In Node it is a small middleware:

js
const RESERVE_MS = 50;

export function deadline(defaultMs) {
  return (req, res, next) => {
    const header = Number(req.get('x-request-timeout-ms'));
    const budget = Number.isFinite(header) && header > 0 ? Math.floor(header) : defaultMs;
    const at = Date.now() + budget - RESERVE_MS;

    const callerLeft = new AbortController();
    res.on('close', () => { if (!res.writableEnded) callerLeft.abort(new Error('caller left')); });

    req.deadline = {
      at,
      signal: AbortSignal.any([callerLeft.signal, AbortSignal.timeout(Math.max(0, at - Date.now()))]), // ms are integers
      remaining: () => at - Date.now(),
      // Options for one outbound call: its own cap, the shared deadline, and the header for the next hop.
      forCall(capMs) {
        const left = Math.floor(at - Date.now());
        if (left <= 0) throw Object.assign(new Error('deadline exceeded'), { status: 504 });
        const ms = Math.min(left, capMs);
        return {
          signal: AbortSignal.any([this.signal, AbortSignal.timeout(ms)]),
          headers: { 'x-request-timeout-ms': String(left) },
        };
      },
    };
    next();
  };
}

// usage
app.get('/cart', deadline(1_800), async (req, res) => {
  const opts = req.deadline.forCall(500);
  const price = await fetch(PRICING_URL, opts).then((r) => r.json());
  res.json({ price });
});

For the database, set the limit per transaction from the same budget: SET LOCAL statement_timeout = '<remaining> ms' at the start of the transaction (it resets at commit or rollback). For a queued job, store the absolute deadline on the job row; a worker that picks up an expired job drops it instead of running it. Chapter 4 covers that queue-timeout idea in depth.

Measured: fixed timeouts vs a propagated budget

A three-hop chain: A (200 ms of work) calls B (300 ms of work), which calls C. C needs 3 s. The user waits 2 s.

Fixed timeouts (A→B 1.8 s, B→C 2 s, no cancellation)Propagated budget plus "caller left"
A answers the user504 at 2,004 ms504 at 1,966 ms, from B
B gives up on C2,509 ms, after the user already left1,963 ms
C's 3 s of workFinished and committed at 3,510 ms, for nobodyStopped at 1,966 ms ("caller left")

In the fixed version, every layer gave up only on its own wait. C did all its work after the user had been told it failed. If that work was a write and the client retries, it runs twice (Chapter 2). In the propagated version, C was told it had 1,448 ms when it started, and it stopped when B hung up.


Part 5: Choosing the numbers

Work top-down from what the user will wait

Start from the user-facing target, then split it. Each inner timeout must be shorter than its caller's, so the innermost work gives up first and its error reaches someone still listening (Debugging 13, Case 3 shows what happens when they grow inwards).

text
browser 10 s  >  edge / LB 3 s  >  API route deadline 2 s
                                     ├─ pricing call   500 ms   (critical)
                                     ├─ recs call      300 ms   (optional, has a fallback)
                                     └─ DB statement   800 ms   (SET LOCAL from the remaining budget)

Set each per-call timeout from the callee's measured latency

  • Measure from the caller's side. The caller's p99 includes the network, TLS and the pool wait; the callee's dashboard does not.
  • A timeout at p99 fails 1% of normal requests. That is too tight for most calls; a timeout should fire for broken, not for slow but normal. A common choice is a little above p99.9, or 2–3× p99, capped by the remaining deadline. Example: pricing p50 40 ms, p99 180 ms, p99.9 310 ms → 500 ms.
  • Connect timeouts are short. Inside a region a connect takes about 1 ms, so 1–3 s is generous; across the internet, 3–5 s. A lost SYN is retransmitted after about a second on Linux, so a connect timeout below that allows no second try.
  • Optional dependencies get tight timeouts and a fallback. Recommendations at 300 ms with "render without them" costs one strip; at 5 s it costs the page.
  • Fan-out makes the tail worse. If a request waits for 5 parallel calls and each is slower than its p99 1% of the time, the request is slow whenever any of them is: 1 − 0.99⁵ ≈ 4.9%. Your page's p95 is roughly your dependencies' p99.
  • Retries need room inside the deadline. With a 2 s budget and a 500 ms per-attempt timeout, at most three attempts fit, minus backoff. Retry only if remaining exceeds the expected attempt time (Chapter 2).
  • Revisit the values. Latency drifts. Alert on the timeout rate per dependency; a rising rate means the value or the dependency changed.

When the Interviewer Breaks It

Try each one out loud before opening the answer.

Break 1: "Your payment provider now takes 30 seconds to respond."

Answer

30-second answer: "Checkout holds a connection and a handler for each payment call. At 20 checkouts a second and 30 s each, that's 600 in flight instead of about 10, so the outbound pool to the provider fills in under a second and every later checkout queues behind it. If that pool or the DB pool is shared with other routes, they fail too. First I'd make sure the payment call has a timeout inside the request deadline, say 5 s, and its own pool, so the wait is bounded and contained. Then I'd stop doing the charge inline: write the order as pending, return 'processing', and let a worker charge with an idempotency key and retry with backoff. A payment that times out has an unknown outcome, so the worker checks the provider before charging again."

The follow-up they push on: "You timed out after 5 s. Did the customer get charged?" You don't know. A timeout in the response phase is an unknown outcome. Retry with the same idempotency key, or look the charge up by your own reference before retrying (Chapter 2).

A weak answer sounds like: "Increase the timeout to 60 seconds." That doubles the in-flight count and the time to fill the pool.

Break 2: "Every call has a 2-second timeout, but some users wait 12 seconds."

InvestigationTime spent: 0 min
Symptom
The search API sets a 2 s timeout on every outbound call. p50 for GET /search is 140 ms, but p99 is 12.4 s, and some users get results after 11 s.
1What do you check first?
Answer

30-second answer: "A per-call timeout bounds one attempt, not the request. If the client retries twice and the handler makes two calls in sequence, the worst case is two calls times three attempts times 2 s, 12 s. I'd give the route a deadline of 2 s, derive each attempt's timeout from the remaining budget, retry only if enough time is left for a useful attempt, and return a partial result rather than wait. And I'd pass the remaining time downstream, so catalog stops working on requests we've already given up on."

The follow-up they push on: "How does catalog know we gave up?" Either it receives the remaining budget in a header and enforces it, or it sees the connection close when we abort and cancels its own work. Without one of those, it finishes every abandoned request.

A weak answer sounds like: "Lower the timeouts." It helps the number, not the cause.

Break 3: "The browser gave up after 2 seconds. Is your server still doing the work?"

Answer

30-second answer: "Yes, unless I built cancellation. Node doesn't stop a handler when the client disconnects, and neither do the services behind it. Every layer only gives up on its own wait. In a test, the innermost service finished three seconds of work a full second and a half after the user got an error. I'd propagate the deadline in a header, watch for the caller's connection closing (res.on('close')), and pass one abort signal into every fetch, query and SDK call. For the database, statement_timeout set from the remaining budget makes Postgres cancel the query itself."

The follow-up they push on: "What if the work is a write that already committed?" Cancellation narrows the window but can't close it: the commit can succeed just before the abort arrives. That's why writes also need idempotency keys (Chapter 2).

A weak answer sounds like: "The request timed out, so it stopped." Timeouts stop the waiter, not the worker.

Break 4: "Why 500 ms? Defend the number."

Answer

30-second answer: "It comes from the callee's latency measured on our side: p99 is 180 ms and p99.9 is about 310 ms, so 500 ms fires for broken calls, not for slow-but-normal ones. It also fits the budget: the route has 2 s, this call is on the critical path, and 500 ms leaves room for one retry plus the rest of the work. If it were optional, like recommendations, I'd go tighter, 300 ms, and render without it. And I'd alert on its timeout rate, because if p99 drifts to 450 ms the number is wrong."

The follow-up they push on: "What if you set it at exactly p99?" Then 1% of healthy calls time out. With a retry, that's 1% extra load all the time, and it grows exactly when the callee slows down.

A weak answer sounds like: "500 ms is a standard value." There are no standard values. There's the distribution and the budget.

Break 5: "After a failover, the old database IP drops all packets. What do your services see?"

Answer

30-second answer: "Dropped packets mean a connect hang, not a fast 'connection refused'. With Node's fetch that's 10 s per attempt by default. With node:http, or node-postgres on its defaults, it's the OS's SYN timeout, 75 s on macOS and about two minutes on Linux. Every new connection hangs for that long, so pools fill. I'd set connect timeouts at 1 to 3 s inside a region. A connect failure means the request never reached the server, so it's safe to retry immediately, even a write, against the new address. And I'd make sure clients re-resolve DNS and drop pooled connections to the old IP rather than keep reusing them."

The follow-up they push on: "Why not use one 2 s total timeout for everything?" It can't tell "never connected, safe to retry" from "sent, outcome unknown". Separate connect and response timeouts give you that distinction for free.

A weak answer sounds like: "The health check will catch it." It catches the database. It doesn't catch the clients hanging on connects.


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If a downstream call has no timeout
  • If you use Node's fetch with no signal
  • If you rely on the timeout option of http.request
  • If you cap only the idle time between chunks
  • If each hop sets its own fixed timeout
  • If the remaining budget travels with every call and each hop stops when its caller leaves
  • If a timeout is set at the callee's p99

Checklist: add this to any design

  1. A deadline per route, derived from what the user will wait, passed to every downstream call as remaining time.
  2. A connect timeout (1–3 s in-region) separate from the response timeout (from the callee's p99.9, capped by the remaining deadline).
  3. A total timeout that covers the body, plus an idle timeout for streams. Three for LLM streams: first token, between tokens, total.
  4. Cancellation downward: one signal into fetch, DB queries, SDK calls and retry sleeps, aborted when the caller disconnects.
  5. Database limits: statement_timeout (per role, and SET LOCAL from the budget), lock_timeout on migrations, idle_in_transaction_session_timeout, a pool acquire timeout.
  6. A bounded client pool per dependency, so a hung dependency fills its own pool, not a shared one.
  7. Fallbacks for optional calls, with tighter timeouts than critical ones.
  8. Inner timeouts shorter than outer ones, all the way from the browser to statement_timeout.
  9. Metrics: timeout rate per dependency, pool wait time, in-flight count. Alert on the in-flight count. It rises before anything else does.

What's next

A timeout tells you to stop waiting. It doesn't tell you whether the work happened. Chapter 2 — Retries and Idempotency covers what to do next: which failures are safe to retry, why a timeout means an unknown outcome, and how idempotency keys and deduplication make a second attempt harmless.