“Payments got slow for two minutes. Then it stayed down for forty — and got four times our normal traffic.”
case name: The retries that kept payments down
- Service
- shoplite-api → payments
- Impact
- All checkouts failed for 40 minutes; the payment provider throttled us
- Detected by
- CheckoutErrorRatioHigh + PaymentsLatencyP99High
- Time to resolve
- 40 min
Skills you'll use on this case
00 It starts
Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.
01 The investigation
- 18:00
FINDING
Yesterday's 'resilience' change: retry payments up to 3 times
obs-lab/shoplite/server.js (the change under investigation)add to filejs let pay; for (let attempt = 0; attempt < 4; attempt++) { // 1 try + 3 retries, no delay try { pay = await fetch(`${PAYMENTS_URL}/charge`, { method: "POST", /* ... */ signal: AbortSignal.timeout(1000) }); if (pay.ok) break; } catch (err) { if (attempt === 3) throw err; } } - 18:22
ALERT
Checkout errors at 100%; payments p99 at 2.6 s
Every checkout times out four times, then fails.
- 18:26
QUERY
Payments is receiving 4× the calls, while checkouts are flat
Compare the rate of requests INTO payments with the rate of checkouts. Normally the ratio is 1.0. Right now each checkout makes four attempts, so the dependency that was struggling now has four times the load, which is exactly what stops it recovering.
PromQL· Prometheussum(rate(http_request_duration_seconds_count{job="payments", route="/charge"}[1m])) / sum(rate(http_request_duration_seconds_count{job="shoplite", route="/checkout"}[1m]))result{} 3.97requests/scheckoutspayments /charge - 18:28
QUERY
One checkout, four failed siblings
The trace shows four
POSTspans to payments back to back, each cut off at exactly 1,000 ms by the timeout and marked as errors. Retrying immediately gives a struggling service no time to recover, and all clients retrying in lockstep make each wave of load arrive at once.POST /checkout — naive retries4012 ms totalshoplitepayments POST /checkoutshoplite4012 msPOST (payments) attempt 1shoplite1000 msPOST /chargepayments2590 msPOST (payments) attempt 2shoplite1000 msPOST /chargepayments2600 msPOST (payments) attempt 3shoplite1000 msPOST (payments) attempt 4shoplite1000 ms - 18:31
HYPOTHESIS
Worse: payments kept processing the abandoned attempts
Notice the server spans continue for 2.6 s after the client gave up. Timed-out attempts still reach the provider. Without an idempotency key, a retried charge that eventually succeeds could charge the customer TWICE.
- 19:02
RESOLVED
Retries disabled via config; payments recovers within 2 minutes of the load dropping
Root cause
Immediate, unconditional retries multiplied load on an already-degraded dependency by 4×, turning a short slowdown into a sustained outage (a retry storm), while risking duplicate charges because the calls weren't idempotent. Per-service metrics looked like 'payments is slow'; the cross-service rate ratio and the trace's repeated sibling spans revealed the amplification.
02 The concepts behind it
Retry amplification
If each layer of a call chain retries 3 times, a failure at the bottom of a 3-layer chain produces 4 × 4 × 4 = 64 attempts per user request. Retries help with rare, independent, transient failures (a dropped connection); they HURT when the dependency is overloaded, because they add load exactly when it has none to spare.
Safe retries
1) Retry only idempotent operations, or make them idempotent with an idempotency key the server deduplicates on. 2) Exponential backoff with JITTER (random delay) so clients don't retry in synchronised waves. 3) A small cap (1–2 retries) and an overall deadline. 4) A RETRY BUDGET: retries may add at most ~10% to normal traffic. 5) A CIRCUIT BREAKER: after repeated failures, fail fast for a while instead of calling at all.
Observing amplification
Traces show retries as repeated sibling spans. Metrics show them as a ratio: calls into the dependency divided by user requests. Export an attempt or retry counter from your client code too, so 'retries per second' is a first-class signal on the dashboard.
03 The fix
01Backoff, jitter, one retry, an idempotency key
One retry at most, after 100–300 ms of randomised delay, and only while the overall 2-second deadline allows. The same
Idempotency-Keyon both attempts lets payments (and real providers like Stripe or Razorpay) return the original result instead of charging twice. Switch the scenario off afterwards.obs-lab/shoplite/server.jsadd to filejs const idempotencyKey = crypto.randomUUID(); const deadline = Date.now() + 2000; let pay; for (let attempt = 0; attempt < 2; attempt++) { const remaining = deadline - Date.now(); if (remaining <= 0) break; try { pay = await fetch(`${PAYMENTS_URL}/charge`, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": idempotencyKey }, body: JSON.stringify({ amount: qty * 120 }), signal: AbortSignal.timeout(Math.min(1000, remaining)), }); if (pay.ok || pay.status < 500) break; // don't retry 4xx } catch (err) { req.log.warn({ err, attempt }, "payments attempt failed"); } retries.inc(); await new Promise((r) => setTimeout(r, 100 + Math.random() * 200)); // backoff + jitter }terminal$ curl -s -X POST localhost:8081/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-payments","enabled":false}'── expected output ──{"active":[]}
04 Make sure it never surprises you again
01Measure retries, and alert on amplification
Declare
const retries = new client.Counter({ name: "shoplite_payment_retries_total", help: "Payment call retries" }). Alert when retries exceed 10% of checkouts: at that point retries are adding load rather than masking rare blips.obs-lab/prometheus/alerts.ymladd to fileyaml - alert: PaymentRetryAmplification expr: | sum(rate(shoplite_payment_retries_total[5m])) / sum(rate(http_request_duration_seconds_count{job="shoplite", route="/checkout"}[5m])) > 0.1 for: 2m labels: { severity: warning, service: shoplite } annotations: summary: "Payment retries are {{ $value | humanizePercentage }} of checkouts — dependency degraded, retries amplifying load"
05 Your turn: write the query
Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.
Write a TraceQL query that finds checkout traces containing more than one call to payments.
Three services call each other in a chain (A → B → C) and each retries up to 2 times (3 attempts). If C is fully down, how many calls does C receive per user request?
06 Interview questions from this case
When are retries harmful, and how do you make them safe?
What is a circuit breaker?