Command Palette

Search for a command to run...

Hectal
Case 3.2·Tracing Deep DiveSEV1

“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

retry amplificationsibling spans in a tracerequest-rate ratios across servicesbackoff, jitter, budgetsidempotency

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.

terminal
$ # 1. add the naive retry below (see "The investigation") to server.js and rebuild shoplite
docker compose up -d --build shoplite
# 2. slow payments down past ShopLite's 1s timeout (from Case 0.4)
curl -s -X POST localhost:8081/admin/chaos -H 'content-type: application/json' -d '{"scenario":"slow-payments"}'
── lab output ──
{"active":["slow-payments"]}

01 The investigation

  1. 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;
      }
    }
  2. 18:22

    ALERT

    Checkout errors at 100%; payments p99 at 2.6 s

    Every checkout times out four times, then fails.

  3. 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· Prometheus
    sum(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.97
    requests/scheckoutspayments /charge
    0.0013.927.718:1518:35
  4. 18:28

    QUERY

    One checkout, four failed siblings

    The trace shows four POST spans 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 /checkoutshoplite
    4012 ms
    POST (payments) attempt 1shoplite
    1000 ms
    POST /chargepayments
    2590 ms
    POST (payments) attempt 2shoplite
    1000 ms
    POST /chargepayments
    2600 ms
    POST (payments) attempt 3shoplite
    1000 ms
    POST (payments) attempt 4shoplite
    1000 ms
  5. 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.

  6. 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

  1. 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-Key on 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

  1. 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.

01

Write a TraceQL query that finds checkout traces containing more than one call to payments.

02

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

01

When are retries harmful, and how do you make them safe?

02

What is a circuit breaker?

0/4 · 0%