Skip to content
chiltepin

Generated from: “Postmortem for the outage on 14 Aug: what happened, in what order, and why.

Checkout outage — 14 Aug

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTPOSTMORTEM

Checkout outage — 14 Aug

INC-2026-0814 · SEV-1 · Orders API unavailable for 47 minutes

On 14 Aug the Orders API returned errors for 47 minutes and no customer could complete checkout. The trigger was a routine deploy that enabled the price-check-v2 flag. The flag made Orders call Pricing synchronously on every request. Pricing ran out of database connections, and retries without backoff turned a slowdown into a full outage.

Assumptions in this document: all times are UTC. The deploy, flag, and service names come from the incident channel. The customer counts come from the checkout funnel dashboard, not from support tickets.

Detection to resolution

SECTION 01 · Roadmap
09:41
done
Deploy 2026.08.14-3 ships
Enables price-check-v2 for 100% of traffic in one step
09:46
done
Pricing p95 crosses 2 s
Pool wait time alert fires but pages nobody
09:52
done
Orders 5xx rate reaches 38%
Load balancer marks 6 of 8 Orders pods unhealthy
09:55
done
Pager fires for checkout errors
On-call (M. Okafor) acknowledges in 2 minutes
10:09
done
Root cause suspected
Deploy diff shows the flag flip; Pricing pool at 100% for 20 minutes
10:14
done
Flag set to 0%
Orders pods recover within 3 minutes; Pricing pool drains by 10:20
10:28
done
Incident resolved
Error rate below 0.1% for 10 consecutive minutes
15 Aug
done
Deploy reverted, flag rollout policy changed
Any flag over 10% needs a canary stage

Fourteen minutes passed between the first symptom and the page. The pool wait alert existed but routed to a Slack channel, not to the pager, so the first human signal was the customer-facing error rate. The diagnosis itself took 14 minutes. The deploy shipped four unrelated changes, and the flag flip was not in the release notes.

How the failure spread

SECTION 02 · Sequence
SEQUENCE
Sequence diagram: 12 messages between 5 actorsWeb checkoutLoad balancerOrders APIPricing servicePricing DBLOOP[retry ×3, no backoff]1POST /orders2forward3get price (sync, 5 s timeout)4wait for pool connection5timeout after 5 s6same request again7503 pool exhausted8502 after 20 s9502 Bad Gateway10health check11timeout (threads busy retrying)LB removes the pod; the remainingpods take more traffic
Legendcallresponseerrorfragment (alt / opt / loop)active
Amplification: 4 Pricing calls per checkoutPool size: 20 connections per Pricing pod

Each checkout held a Pricing connection open for up to 20 seconds and retried three times without backoff, so one slow query cost four connections. With the pool at 20 connections per pod, 45 concurrent checkouts were enough to block every Pricing pod. The load balancer then removed Orders pods whose threads were stuck in retries, which pushed the remaining pods over the same cliff.

Why it broke

SECTION 03 · Cause & effect
FISHBONE
FishboneChangeFlag went 0% to 100% inone stepFlag flip bundled with 4unrelated changesNo canary for the newPricing call pathCapacityPricing pool sized for 1call per checkout, not 4No circuit breaker between Orders and PricingNo circuit breakerbetween Orders and…CodeSynchronous Pricing callon the checkout hot path3 retries with nobackoff and no jitter5 s timeout per attempt, 20 s worst case per request5 s timeout per attempt,20 s worst case per…DetectionPool wait alert routedto Slack, not the pagerHealth check shared a thread pool with request handlingHealth check shared athread pool with request…Orders APIunavailable for47 minutes
LegendeffectBONEcause categoryspecific cause

The direct cause was the flag flip, but the outage needed all four bones. Without the retry storm, the pool degrades gracefully. Without the shared thread pool, the load balancer keeps healthy pods. With a paged alert, the response starts nine minutes earlier.

Impact

SECTION 04 · Metrics
47 min
Checkout unavailable
▼ +47 min
6,130
Failed checkouts
▼ +6,130
$184k
Estimated lost revenue
▼ -$184k
14 min
Time to page
▼ +9 min vs target

Failed checkouts are counted once per session. The revenue figure uses the average order value of the prior seven days. It does not subtract orders that customers retried after 10:28. The funnel shows that 41% of affected sessions converted later that day.

Remediation

SECTION 05 · Steps
  1. Make the Pricing call asynchronous

    Orders reads a cached price and enqueues a verification job; a stale price never blocks checkout. Owner M. Okafor, due 28 Aug.

  2. Add backoff, jitter, and a circuit breaker to the Pricing client

    Retries use exponential backoff from 200 ms with jitter, and the breaker opens after 50% failures in 30 s. Owner R. Lindqvist, due 21 Aug.

    yaml
    retry: { attempts: 3, backoff: exponential, base: 200ms, jitter: full }
  3. Route the pool wait alert to the pager

    Pool wait over 500 ms for 2 minutes pages the Pricing on-call. Owner A. Ferreira, done 15 Aug.

  4. Separate the health check thread pool

    The load balancer probe answers from its own pool so busy request threads never mark a pod unhealthy. Owner R. Lindqvist, due 4 Sep.

  5. Require a canary stage for every flag above 10%

    The flag service refuses a jump from under 10% to over 50% without a 30 minute canary. Policy shipped 15 Aug; enforcement owner A. Ferreira, due 11 Sep.

    Applies to all services, not only Orders.

Items 1 and 2 remove the mechanism; items 3 and 4 shorten detection and stop the cascade; item 5 removes the trigger class. The team accepts that item 1 can show a price up to 60 seconds stale at checkout, which is within the pricing contract.

View the Markdown
```meta
title: Checkout outage — 14 Aug
subtitle: "INC-2026-0814 · SEV-1 · Orders API unavailable for 47 minutes"
tag: POSTMORTEM
```

On 14 Aug the Orders API returned errors for 47 minutes and no customer could complete checkout. The trigger was a routine deploy that enabled the `price-check-v2` flag. The flag made Orders call Pricing synchronously on every request. Pricing ran out of database connections, and retries without backoff turned a slowdown into a full outage.

Assumptions in this document: all times are UTC. The deploy, flag, and service names come from the incident channel. The customer counts come from the checkout funnel dashboard, not from support tickets.

## Detection to resolution

```timeline
items:
  - "[done] 09:41 · Deploy 2026.08.14-3 ships · Enables price-check-v2 for 100% of traffic in one step"
  - "[done] 09:46 · Pricing p95 crosses 2 s · Pool wait time alert fires but pages nobody"
  - "[done] 09:52 · Orders 5xx rate reaches 38% · Load balancer marks 6 of 8 Orders pods unhealthy"
  - "[done] 09:55 · Pager fires for checkout errors · On-call (M. Okafor) acknowledges in 2 minutes"
  - "[done] 10:09 · Root cause suspected · Deploy diff shows the flag flip; Pricing pool at 100% for 20 minutes"
  - "[done] 10:14 · Flag set to 0% · Orders pods recover within 3 minutes; Pricing pool drains by 10:20"
  - "[done] 10:28 · Incident resolved · Error rate below 0.1% for 10 consecutive minutes"
  - "[done] 15 Aug · Deploy reverted, flag rollout policy changed · Any flag over 10% needs a canary stage"
```

Fourteen minutes passed between the first symptom and the page. The pool wait alert existed but routed to a Slack channel, not to the pager, so the first human signal was the customer-facing error rate. The diagnosis itself took 14 minutes. The deploy shipped four unrelated changes, and the flag flip was not in the release notes.

## How the failure spread

```sequence
id: failure-mechanism
actors:
  - { id: Client, name: Web checkout }
  - { id: LB, name: Load balancer }
  - { id: Orders, name: Orders API }
  - { id: Pricing, name: Pricing service }
  - { id: DB, name: Pricing DB }
messages:
  - "Client -> LB: POST /orders"
  - "LB -> +Orders: forward"
  - "Orders -> +Pricing: get price (sync, 5 s timeout)"
  - "Pricing -> DB: wait for pool connection"
  - "loop: retry ×3, no backoff"
  - "Orders -x-> Pricing: timeout after 5 s"
  - "Orders -> Pricing: same request again"
  - "end"
  - "Pricing --> -Orders: 503 pool exhausted"
  - "Orders --> -LB: 502 after 20 s"
  - "LB -x-> Client: 502 Bad Gateway"
  - "LB -> Orders: health check"
  - "Orders --> LB: timeout (threads busy retrying)"
  - { from: LB, to: Orders, kind: note, label: "LB removes the pod; the remaining pods take more traffic" }
foot:
  - { label: Amplification, value: "4 Pricing calls per checkout" }
  - { label: Pool size, value: "20 connections per Pricing pod" }
```

Each checkout held a Pricing connection open for up to 20 seconds and retried three times without backoff, so one slow query cost four connections. With the pool at 20 connections per pod, 45 concurrent checkouts were enough to block every Pricing pod. The load balancer then removed Orders pods whose threads were stuck in retries, which pushed the remaining pods over the same cliff.

## Why it broke

```fishbone
id: root-causes
effect: Orders API unavailable for 47 minutes
causes:
  - label: Change
    items:
      - "Flag went 0% to 100% in one step"
      - "Flag flip bundled with 4 unrelated changes"
      - "No canary for the new Pricing call path"
  - label: Code
    items:
      - "Synchronous Pricing call on the checkout hot path"
      - "3 retries with no backoff and no jitter"
      - "5 s timeout per attempt, 20 s worst case per request"
  - label: Capacity
    items:
      - "Pricing pool sized for 1 call per checkout, not 4"
      - "No circuit breaker between Orders and Pricing"
  - label: Detection
    items:
      - "Pool wait alert routed to Slack, not the pager"
      - "Health check shared a thread pool with request handling"
```

The direct cause was the flag flip, but the outage needed all four bones. Without the retry storm, the pool degrades gracefully. Without the shared thread pool, the load balancer keeps healthy pods. With a paged alert, the response starts nine minutes earlier.

## Impact

```stats
stats:
  - { value: "47 min", label: Checkout unavailable, delta: "+47 min", trend: down }
  - { value: "6,130", label: Failed checkouts, delta: "+6,130", trend: down }
  - { value: "$184k", label: Estimated lost revenue, delta: "-$184k", trend: down }
  - { value: "14 min", label: Time to page, delta: "+9 min vs target", trend: down }
```

Failed checkouts are counted once per session. The revenue figure uses the average order value of the prior seven days. It does not subtract orders that customers retried after 10:28. The funnel shows that 41% of affected sessions converted later that day.

## Remediation

```steps
items:
  - title: Make the Pricing call asynchronous
    body: Orders reads a cached price and enqueues a verification job; a stale price never blocks checkout. Owner M. Okafor, due 28 Aug.
  - title: Add backoff, jitter, and a circuit breaker to the Pricing client
    body: Retries use exponential backoff from 200 ms with jitter, and the breaker opens after 50% failures in 30 s. Owner R. Lindqvist, due 21 Aug.
    code: "retry: { attempts: 3, backoff: exponential, base: 200ms, jitter: full }"
    lang: yaml
  - title: Route the pool wait alert to the pager
    body: Pool wait over 500 ms for 2 minutes pages the Pricing on-call. Owner A. Ferreira, done 15 Aug.
  - title: Separate the health check thread pool
    body: The load balancer probe answers from its own pool so busy request threads never mark a pod unhealthy. Owner R. Lindqvist, due 4 Sep.
  - title: Require a canary stage for every flag above 10%
    body: The flag service refuses a jump from under 10% to over 50% without a 30 minute canary. Policy shipped 15 Aug; enforcement owner A. Ferreira, due 11 Sep.
    note: Applies to all services, not only Orders.
```

Items 1 and 2 remove the mechanism; items 3 and 4 shorten detection and stop the cascade; item 5 removes the trigger class. The team accepts that item 1 can show a price up to 60 seconds stale at checkout, which is within the pricing contract.