Skip to main content
IncidentPaged 14:02 UTC, Friday

tenacity retry loop turns a 30-second upstream slowdown into a self-amplifying outage

Service: order-processor (FastAPI on Python 3.11)

Symptom: Error rate 0% for an hour, then 82% for 90 seconds, then back to 0%. Stripe's status page shows a 30-second slowdown in their charges endpoint right at the start of the spike. Database team reports the connection pool maxed out for 90s.

The code that's running in prod

# orders/charge.py

from tenacity import retry, wait_fixed
from db import get_connection

@retry(wait=wait_fixed(1))  # retry forever every 1 second
async def charge_card(order_id: int, amount_cents: int):
    async with get_connection() as conn:                       # holds a pool slot
        order = await conn.fetchrow(
            "SELECT * FROM orders WHERE id = $1 FOR UPDATE",
            order_id,
        )
        # Slow path: Stripe API call. Default timeout = 30s.
        charge = await stripe.Charge.create(
            amount=amount_cents,
            currency="usd",
            source=order["card_token"],
        )
        await conn.execute(
            "UPDATE orders SET charge_id = $1 WHERE id = $2",
            charge.id,
            order_id,
        )
        return charge

Log dashboard (last 60 seconds)

14:02:01INFO[stripe]POST /v1/charges → 200 in 95ms
14:02:14WARN[stripe]POST /v1/charges → 504 in 30000ms (upstream timeout)
14:02:15INFO[retry]charge_card retry attempt 2/∞ in 1.0s — order_id=8821
14:02:15WARN[stripe]POST /v1/charges → 504 in 30000ms
14:02:16ERROR[db]cannot acquire connection — pool exhausted (40/40 in use)
14:02:18WARN[api]POST /orders → 500 (asyncpg.TooManyConnectionsError); 12 requests queued
14:02:25INFO[retry]charge_card retry attempt 3/∞ in 1.0s — order_id=8821
14:02:55ERROR[pagerduty]ALERT order-processor error_rate=82%
14:03:30INFO[stripe]upstream recovered (per status.stripe.com)
14:03:31INFO[retry]charge_card resolved order_id=8821 in 90s after 3 retries
14:03:31INFO[db]~2,400 retry attempts in last 90s — all eventually 200; pool back to 12/40

Pick the root cause

Four candidates. Three are plausible but mistake symptom for cause. Pick what you'd write in the postmortem timeline.