Lesson 13 / 19
Retry and Backoff
Measuring which layer a retry sits in: building the same rule separately into the client wrapper, the middle layer, and the gateway, whether the healthy call site gets retried too, the multiplier produced when three placements overlap, and the repeated side effect counted in deliveries.
Contents
The previous lesson placed the duration limit and freed the caller, but left it holding an error. The closest answer is retrying: if the dependency dropped only briefly, a second call may succeed. This decision also has a place, more dangerous than the timeout — a timeout did not change the dependency’s load, a retry multiplies it directly.
Exponential backoff, jitter, the cap, and retry-budget arithmetic were built in M14/K06 Application Architecture: Routing, State and Data, M16/K05, and M19/K05 Resilience and Reliability; the pattern’s effect on the system was measured in M19/K05. None of it repeats here. This lesson has one question: which layer the retry sits in, and what happens to calls reaching the dependency when that layer changes.
The same rule can be placed in three spots. The client wrapper retries a single outbound call. The middle layer reruns the request’s whole body. The gateway recalls the entire service and has not one line in the service’s code. All three make the same promise; each repeats something different.
Mechanism
The notification dependency carries this lesson’s measure. RS5 — notification actually
delivers; what’s lost is the acknowledgment: the delivery counter goes up on every call, but the
first failure calls return 503. So what the retry repeats is not just the call but the side
effect. Membership is a healthy dependency and always responds.
// services.mjs — 8202 notification (failing, counts deliveries), 8203 membership (healthy), // 8201 loan: carries both retry placements. Counters live at the 8202/counter endpoint. import { createServer, request } from "node:http"; const s = { loan: 0, membership: 0, notification: 0, delivered: 0, remaining: 0 }; // delivered: what notification actually sends createServer((req, res) => { // 8202 notification const q = new URL(req.url, "http://x").searchParams; if (req.url === "/counter") return res.end(JSON.stringify(s)); if (req.url.startsWith("/reset")) { for (const k of ["loan", "membership", "notification", "delivered"]) s[k] = 0; s.remaining = Number(q.get("failure")); return res.end("{}"); } s.notification += 1; s.delivered += 1; // delivery happens; the acknowledgment is lost for the first `remaining` calls if (s.remaining > 0) { s.remaining -= 1; res.statusCode = 503; } res.end("{}"); }).listen(8202); createServer((req, res) => { s.membership += 1; res.end("{}"); }).listen(8203); // healthy dependency const call = (port, path) => new Promise((resolve) => { const r = request({ port, path, agent: false }, (y) => { y.resume(); y.on("end", () => resolve(y.statusCode)); }); r.on("error", () => resolve(0)); r.end(); }); // >>> placement: client wrapper — retries only the call site it wraps const callWithRetry = async (port, path, tries) => { for (let n = 1; ; n += 1) { const k = await call(port, path); if (k < 500 || n >= tries) return k; } }; // <<< const handle = async (inner) => { // body of the loan request: two outbound call sites await callWithRetry(8203, "/member", 1); // healthy dependency, not retried return callWithRetry(8202, "/send", inner); }; createServer(async (req, res) => { // 8201 loan const q = new URL(req.url, "http://x").searchParams; s.loan += 1; // >>> placement: middle layer — reruns the request's whole body let k = 0; for (let n = 1; n <= Number(q.get("middle")); n += 1) { k = await handle(Number(q.get("inner"))); if (k < 500) break; } // <<< res.statusCode = k; res.end("{}"); }).listen(8201);
The third placement lives in the measurement script, because the gateway is outside the service.
RS6 — every layer’s retry count is 3; the comparison only means something while that number
stays fixed. The >>> placement: markers exist to count code cost; the script reads them straight
from the source.
// measure.mjs — which layer the retry sits in, the multiplier of overlapping retries, code cost. import { readFileSync } from "node:fs"; import { request } from "node:http"; const call = (port, path) => new Promise((resolve) => { const r = request({ port, path, agent: false }, (y) => { y.resume(); y.on("end", () => resolve(y.statusCode)); }); r.on("error", () => resolve(0)); r.end(); }); const read = (path) => new Promise((c) => request({ port: 8202, path }, (r) => { let g = ""; r.on("data", (p) => (g += p)); r.on("end", () => c(g === "{}" ? null : JSON.parse(g))); }).end()); const print = (g, a, ...r) => console.log(String(a).padEnd(32) + r.map((x) => String(x).padStart(g)).join("")); // >>> placement: gateway — not one line lives in the service's code const gateway = async (outer, middle, inner) => { for (let n = 1; ; n += 1) { const k = await call(8201, `/loan?middle=${middle}&inner=${inner}`); if (k < 500 || n >= outer) return k; } }; // <<< const attempt = async (failure, outer, middle, inner) => { await read(`/reset?failure=${failure}`); const k = await gateway(outer, middle, inner); return { k, ...(await read("/counter")) }; }; console.log("transient failure: notification delivers on the first 2 calls but loses the acknowledgment; single user request"); print(20, "placement (outer/middle/inner)", "result", "loan calls", "membership calls", "notification calls", "delivered"); for (const [name, o, m, i] of [["none (1/1/1)", 1, 1, 1], ["wrapper (1/1/3)", 1, 1, 3], ["middle layer (1/3/1)", 1, 3, 1], ["gateway (3/1/1)", 3, 1, 1]]) { const r = await attempt(2, o, m, i); print(20, name, r.k, r.loan, r.membership, r.notification, r.delivered); } console.log("\npersistent failure: overlapping retries"); print(20, "outer / middle / inner", "multiplier", "notification calls", "delivered", "membership calls"); for (const [o, m, i] of [[1, 1, 1], [3, 1, 1], [3, 3, 1], [3, 3, 3], [5, 3, 3]]) { const r = await attempt(999, o, m, i); print(20, `${o} / ${m} / ${i}`, o * m * i, r.notification, r.delivered, r.membership); } const code = (file) => [...readFileSync(file, "utf8").matchAll(/\/\/ >>> placement: ([^\n—]+)—[^\n]*\n([\s\S]*?)\/\/ <<</g)] .map(([, name, g]) => [name.trim(), file, g.trim().split("\n").length]); const sites = [...readFileSync("services.mjs", "utf8").matchAll(/callWithRetry\(\d+, "[^"]+", ([^)]+)\)/g)].map((m) => m[1]); console.log("\ncode cost and coverage"); print(15, "placement", "file", "line"); for (const [name, d, n] of [...code("services.mjs"), ...code("measure.mjs")]) print(15, name, d, n); console.log(`the loan body has ${sites.length} outbound call sites; the wrapper retries ${sites.filter((x) => x !== "1").length} ` + `of them, the middle layer and the gateway retry all ${sites.length}.`); console.log("configuration surface: 3 retry counts, in 3 separate places; none sees the others' value."); process.exit(0);
node services.mjs > /dev/null 2>&1 & SP=$! sleep 1; node measure.mjs; kill $SP
transient failure: notification delivers on the first 2 calls but loses the acknowledgment; single user request placement (outer/middle/inner) result loan calls membership calls notification calls delivered none (1/1/1) 503 1 1 1 1 wrapper (1/1/3) 200 1 1 3 3 middle layer (1/3/1) 200 1 3 3 3 gateway (3/1/1) 200 3 3 3 3 persistent failure: overlapping retries outer / middle / inner multiplier notification calls delivered membership calls 1 / 1 / 1 1 1 1 1 3 / 1 / 1 3 3 3 3 3 / 3 / 1 9 9 9 9 3 / 3 / 3 27 27 27 9 5 / 3 / 3 45 45 45 15 code cost and coverage placement file line client wrapper services.mjs 6 middle layer services.mjs 2 gateway measure.mjs 6 the loan body has 2 outbound call sites; the wrapper retries 1 of them, the middle layer and the gateway retry all 2. configuration surface: 3 retry counts, in 3 separate places; none sees the others' value.
How Three Placements Close the Same Failure
The first table meets a transient failure with three placements, and the result column is the same in all three: a 503 turns into 200. The retry keeps its promise. The difference is in the other columns.
Calls reaching the dependency stay at 3 in all three. This is not surprising; the failure lasts two calls and clears on the third. The retry’s load on the dependency comes from the retry count, not from the placement.
Calls reaching the healthy dependency, though, are 1, 3, and 3. This is where the split lies. The client wrapper retries only the call site it wraps; the membership call happens once, and that dependency — which has nothing to do with the failure — is never strained. The middle layer reruns the request body from the start, repeating the failure-free call site too. The gateway does the same and, on top of it, calls the loan service three times — shown in the loan-calls column as 1, 1, 3.
The rule that follows is a selection criterion: the outer placement cannot discriminate. The gateway does not know which call failed, so it repeats the whole request; the further out you go, the more repeated work grows. The inner placement discriminates, but it only covers the call site that passes through it — the previous lesson’s uncovered-call-site problem applies here exactly the same.
The delivered column is 3 in all three placements. For a single user request, notification was sent three times. Changing the placement does not fix this; the side effect comes from the retry itself. Whether a call site is safe to repeat is a property of the call site, not the placement, and the fix belongs there too: that call site must keep its retry count at 1.
Overlapping Retries
The second table opens all three placements at once. Under a persistent failure, every layer spends its share to the end, and calls reaching the dependency track the multiplier column exactly: 1, 3, 9, 27, 45. A single user request turns into 27 calls on the dependency, and a single notification gets delivered 27 times.
The real information in this table is not the multiplier itself, but that no layer sees it. Whoever wrote the gateway wrote 3 retries, whoever wrote the middle layer wrote 3, whoever wrote the wrapper wrote 3; none chose a wrong number. The configuration surface sits in three separate places and none can see the others’ value. The last row shows how fast this grows: the outer layer going from three to five takes the total from 27 to 45 — one number changing in one place adds eighteen calls to the dependency.
The membership-calls column is 9 on those same rows, not 27. The healthy dependency is only called as many times as the product of the two outer layers, because the innermost placement does not repeat it. Which factors of the multiplier land on which call site is a direct result of placement.
Code Cost and Coverage
The third table gives the size of each placement: client wrapper 6 lines, middle layer 2, gateway 6 — and the gateway’s six lines are not in the service’s code at all. This is what makes the outer placement attractive: every call site is covered without the service changing, even ones not yet written. It is also exactly the cost; coverage having no selectivity is not a flaw, it is that placement’s definition.
The inner placement is the opposite: of the two outbound call sites in the loan body, it retries one and deliberately leaves the other alone. Selectivity is only achieved by deciding call site by call site, and that decision is not made once — it is made again every time a new call site is added.
Testability
Placement also decides where the behavior can be pinned down in a test.
// test/retry.test.mjs — testability of placement: where the assertion is pinned. import { test } from "node:test"; import assert from "node:assert/strict"; import { request } from "node:http"; const call = (port, path) => new Promise((resolve) => { const r = request({ port, path, agent: false }, (y) => { y.resume(); y.on("end", () => resolve(y.statusCode)); }); r.on("error", () => resolve(0)); r.end(); }); const read = (path) => new Promise((c) => request({ port: 8202, path }, (r) => { let g = ""; r.on("data", (p) => (g += p)); r.on("end", () => c(g === "{}" ? null : JSON.parse(g))); }).end()); const run = async (failure, outer, middle, inner) => { await read(`/reset?failure=${failure}`); for (let n = 1; n <= outer; n += 1) if (await call(8201, `/loan?middle=${middle}&inner=${inner}`) < 500) break; return read("/counter"); }; test("wrapper placement: the assertion pins to a single call site", async () => { assert.equal((await run(2, 1, 1, 3)).notification, 3); assert.equal((await run(2, 1, 1, 3)).membership, 1); // healthy call site is not retried }); test("gateway placement: the same assertion changes with the inner setting", async () => { assert.equal((await run(999, 3, 1, 1)).notification, 3); assert.equal((await run(999, 3, 1, 3)).notification, 9); // gateway unchanged, result triples }); test("retry multiplies delivery", async () => { const r = await run(2, 1, 1, 3); assert.equal(r.delivered, 3); // single user request, three deliveries });
node services.mjs > /dev/null 2>&1 & SP=$! sleep 1 node --test --test-force-exit --test-reporter=tap test/retry.test.mjs | grep -E "^(ok|not ok|# (tests|pass|fail) )" kill $SP
ok 1 - wrapper placement: the assertion pins to a single call site ok 2 - gateway placement: the same assertion changes with the inner setting ok 3 - retry multiplies delivery # tests 3 # pass 3 # fail 0
The first test shows the wrapper placement’s testability: the assertion pins to a single call site, the number is local, and no other setting can change it. The second proves the gateway placement’s problem with the test itself: the gateway’s own setting is 3 in both runs, but the expected call count goes from 3 to 9, because a different number changed further in. An assertion about the outer placement’s behavior depends on a setting it cannot see, and breaks when that setting changes. The third pins the side effect directly: one user request, three deliveries.
Summary
- Retry arithmetic and its system effect were measured in M14/K06, M16/K05, and M19/K05 Resilience and Reliability; measured here is which layer it sits in.
- Under a transient failure all three placements save the request, and calls reaching the dependency stay at 3 in all three; the difference is in calls reaching the healthy dependency: 1 wrapper, 3 middle layer, 3 gateway. The gateway also calls the loan service 3 times instead of 1.
- The outer placement cannot discriminate: not knowing which call failed, it repeats the whole request. The inner placement discriminates, but covers only the call site passing through it.
- With all three placements open at once, calls reaching the dependency track the multiplier exactly: 1, 3, 9, 27, 45. No layer sees another’s number; the outer one going from three to five adds eighteen calls at the dependency. The healthy dependency gets 9 calls, not 27.
- The delivery counter is 3 in all three placements: the repeated side effect is a call-site problem, not a placement one, and the fix is keeping that call site’s retry count at 1.
- Code cost is 6 / 2 / 6 lines, and the gateway’s six are not in the service’s code. In tests the wrapper’s assertion is local; the gateway’s depends on a setting it cannot see and breaks when that setting changes.
Next Step
A retry closes a transient failure but worsens a persistent one: all 27 calls failed, none saved anything, and a dependency already down took twenty-seven times the load on top of it. What’s missing is not a number but a decision — recognizing the dependency is down and not making the call at all. The next lesson places the circuit breaker in the code and measures: whether the breaker’s state is kept per dependency or per call site, when the two placements open under the same failure, how many parameters make up the configuration surface, and what behavior a wrong threshold produces.
To keep your progress and take notes, Log in
My notes
Log in to take notes.