Resilience Patterns
Purpose
Make every outbound call and every mutating endpoint survive partial failure without causing duplicate effects or cascading outages. Retries, timeouts, breakers, and idempotency keys are the primitives — apply them deliberately, not reflexively.
Universal — capped exponential backoff + jitter, circuit breakers, timeout-from-p99, and idempotency keys are distributed-systems primitives independent of language; only the library differs.
Procedure
Set timeouts from p99.9 latency — never infinite
- Measure the dependency's p99.9; set timeout slightly above it
- No timeout = one slow dependency exhausts your connection pool → cascading failure
Retry ONLY idempotent operations
- GET / PUT / DELETE are naturally idempotent → safe to retry
- POST is NOT → retry only with an idempotency key (step 4)
- Never blindly retry a non-idempotent mutation
Use capped exponential backoff + full jitter
delay = random(0, min(cap, base * 2^attempt))
- Full jitter prevents thundering herd (synchronized retries hammering a recovering service)
- Cap total attempts (e.g., 3-5); after cap, fail to a fallback or DLQ
Idempotency keys for mutations
- Client sends
Idempotency-Key: <uuid> header on POST
- Server stores key → result mapping; on replay, return the stored result (don't re-execute)
- Store the key in the SAME transaction as the business effect
Circuit breaker for repeatedly-failing dependencies
- Open the breaker after N consecutive failures → fail fast instead of waiting on timeouts
- Half-open after a cooldown to test recovery
- Pair with a fallback (cached value, degraded response, queue-for-later)
5b. Bulkhead: isolate critical paths from one bad dependency
- Partition resources (connection pools, worker concurrency) per dependency so one slow integration can't drain the pool that serves the rest
- Without bulkheading, your healthy endpoints fail because the unhealthy dependency holds every connection
5c. Define a fallback chain for high-availability paths
- Primary → secondary → cached/degraded → user-friendly error
- Each rung must be decided in advance (not improvised mid-incident); annotate which rung the response came from so observability sees the degradation
5d. Propagate deadlines across hops
- When request A times out at 800ms, its outbound call to B shouldn't have a 2s timeout — by the time B returns, A is already done. Pass the remaining budget (header or context) so each hop's timeout = min(local-budget, deadline-remaining)
- Otherwise downstream services do work nobody waits for
- Validate (validation loop)
- Simulate dependency failure (kill the service / inject latency)
- Verify: no duplicate effects on retry, no cascading timeout, breaker opens and recovers
- If duplicates occur → idempotency key not applied correctly; fix and re-test
Anti-patterns
| ❌ Anti-pattern |
✅ Correct |
| Retry a POST with no idempotency key |
Idempotency-Key header + stored result |
| Exponential backoff without jitter |
Capped exponential + full jitter |
| No timeout on outbound call |
Timeout from dependency p99.9 |
| Infinite retries |
Cap attempts → fallback / DLQ |
| Catch-all retry on every error |
Retry only transient errors (timeout/503), not 4xx |
| One bad dependency drains the shared connection pool |
Bulkhead per dependency (separate pool / concurrency limit) |
| Improvising the fallback during the incident |
Predeclared fallback chain (primary → secondary → degraded) |
| 2s downstream timeout under a 800ms caller deadline |
Propagate the deadline; downstream timeout = min(local, remaining) |
Severity tiers
| Tier |
Examples |
Action SLA |
| Critical |
Payment/order POST retried without idempotency key (duplicate charges); no timeout on a synchronous external call |
Block release; fix immediately |
| Major |
Backoff without jitter; missing circuit breaker on a known-flaky dependency |
Fix this sprint |
| Minor |
Retry cap slightly high; breaker cooldown untuned |
Schedule within 2 sprints |
Completion Criteria
Output
- Resilience config: timeout/retry/breaker policy per dependency (documented)
- Idempotency table: schema + the transaction wiring
- Commit format:
feat(resilience): add idempotency key to <endpoint> / fix(resilience): cap backoff + jitter on <client>
Implementation
TypeScript + NestJS (default)
- Library: Cockatiel (composable retry + circuit breaker + timeout + bulkhead)
- Idempotency: store
idempotency_key → response in Postgres, checked in the same Prisma $transaction as the write
- HTTP client: wrap
fetch/axios calls in a Cockatiel policy
Other stacks
- Python / FastAPI:
tenacity (retry/backoff) + pybreaker (circuit breaker)
- Go:
cenkalti/backoff + sony/gobreaker
- Universal: idempotency key pattern is HTTP-level (Stripe's
Idempotency-Key is the reference); jitter formula is math, not library-specific
Related skills
webhook-design — webhook delivery uses the same retry + idempotency primitives
background-jobs — job retries must be idempotent
transaction-management — idempotency keys often stored in the same transaction as the effect
Reference
- Key insight encoded: Cap exponential backoff and add full jitter to prevent thundering herd; retry only idempotent operations (use a Stripe-style
Idempotency-Key for POST). Set timeouts from p99.9, not guesses.
1---2name: resilience-patterns3description: Apply reliability primitives — capped exponential backoff with jitter, circuit breakers, timeouts, and idempotency keys — to every outbound call and mutating endpoint. Use when integrating an external service, when retries cause duplicate effects, or before shipping a payment/order flow. Not for job-runner retry config specifically (use background-jobs) or webhook-delivery specifics (use webhook-design, which reuses these primitives).4license: MIT5---67# Resilience Patterns89## Purpose10Make every outbound call and every mutating endpoint survive partial failure without causing duplicate effects or cascading outages. Retries, timeouts, breakers, and idempotency keys are the primitives — apply them deliberately, not reflexively.1112**Universal** — capped exponential backoff + jitter, circuit breakers, timeout-from-p99, and idempotency keys are distributed-systems primitives independent of language; only the library differs.1314## Procedure15161. **Set timeouts from p99.9 latency — never infinite**17 - Measure the dependency's p99.9; set timeout slightly above it18 - No timeout = one slow dependency exhausts your connection pool → cascading failure19202. **Retry ONLY idempotent operations**21 - GET / PUT / DELETE are naturally idempotent → safe to retry22 - POST is NOT → retry only with an idempotency key (step 4)23 - Never blindly retry a non-idempotent mutation24253. **Use capped exponential backoff + full jitter**26 - `delay = random(0, min(cap, base * 2^attempt))`27 - Full jitter prevents thundering herd (synchronized retries hammering a recovering service)28 - Cap total attempts (e.g., 3-5); after cap, fail to a fallback or DLQ29304. **Idempotency keys for mutations**31 - Client sends `Idempotency-Key: <uuid>` header on POST32 - Server stores key → result mapping; on replay, return the stored result (don't re-execute)33 - Store the key in the SAME transaction as the business effect34355. **Circuit breaker for repeatedly-failing dependencies**36 - Open the breaker after N consecutive failures → fail fast instead of waiting on timeouts37 - Half-open after a cooldown to test recovery38 - Pair with a fallback (cached value, degraded response, queue-for-later)39405b. **Bulkhead: isolate critical paths from one bad dependency**41 - Partition resources (connection pools, worker concurrency) per dependency so one slow integration can't drain the pool that serves the rest42 - Without bulkheading, your healthy endpoints fail because the unhealthy dependency holds every connection43445c. **Define a fallback chain for high-availability paths**45 - Primary → secondary → cached/degraded → user-friendly error46 - Each rung must be decided in advance (not improvised mid-incident); annotate which rung the response came from so observability sees the degradation47485d. **Propagate deadlines across hops**49 - When request A times out at 800ms, its outbound call to B shouldn't have a 2s timeout — by the time B returns, A is already done. Pass the remaining budget (header or context) so each hop's timeout = min(local-budget, deadline-remaining)50 - Otherwise downstream services do work nobody waits for51526. **Validate (validation loop)**53 - Simulate dependency failure (kill the service / inject latency)54 - Verify: no duplicate effects on retry, no cascading timeout, breaker opens and recovers55 - If duplicates occur → idempotency key not applied correctly; fix and re-test5657## Anti-patterns5859| ❌ Anti-pattern | ✅ Correct |60|---|---|61| Retry a POST with no idempotency key | Idempotency-Key header + stored result |62| Exponential backoff without jitter | Capped exponential + full jitter |63| No timeout on outbound call | Timeout from dependency p99.9 |64| Infinite retries | Cap attempts → fallback / DLQ |65| Catch-all retry on every error | Retry only transient errors (timeout/503), not 4xx |66| One bad dependency drains the shared connection pool | Bulkhead per dependency (separate pool / concurrency limit) |67| Improvising the fallback during the incident | Predeclared fallback chain (primary → secondary → degraded) |68| 2s downstream timeout under a 800ms caller deadline | Propagate the deadline; downstream timeout = min(local, remaining) |6970## Severity tiers7172| Tier | Examples | Action SLA |73|---|---|---|74| **Critical** | Payment/order POST retried without idempotency key (duplicate charges); no timeout on a synchronous external call | Block release; fix immediately |75| **Major** | Backoff without jitter; missing circuit breaker on a known-flaky dependency | Fix this sprint |76| **Minor** | Retry cap slightly high; breaker cooldown untuned | Schedule within 2 sprints |7778## Completion Criteria79- [ ] Every outbound call has a timeout derived from p99.980- [ ] Every POST that can be retried uses an idempotency key81- [ ] Backoff is capped + jittered82- [ ] Flaky dependencies wrapped in a circuit breaker + fallback83- [ ] Failure simulation confirms no duplicate effects8485## Output86- **Resilience config**: timeout/retry/breaker policy per dependency (documented)87- **Idempotency table**: schema + the transaction wiring88- **Commit format**: `feat(resilience): add idempotency key to <endpoint>` / `fix(resilience): cap backoff + jitter on <client>`8990## Implementation9192### TypeScript + NestJS (default)93- Library: **Cockatiel** (composable retry + circuit breaker + timeout + bulkhead)94- Idempotency: store `idempotency_key → response` in Postgres, checked in the same Prisma `$transaction` as the write95- HTTP client: wrap `fetch`/`axios` calls in a Cockatiel policy9697### Other stacks98- **Python / FastAPI**: `tenacity` (retry/backoff) + `pybreaker` (circuit breaker)99- **Go**: `cenkalti/backoff` + `sony/gobreaker`100- **Universal**: idempotency key pattern is HTTP-level (Stripe's `Idempotency-Key` is the reference); jitter formula is math, not library-specific101102## Related skills103- `webhook-design` — webhook delivery uses the same retry + idempotency primitives104- `background-jobs` — job retries must be idempotent105- `transaction-management` — idempotency keys often stored in the same transaction as the effect106107## Reference108- **Key insight encoded**: Cap exponential backoff and add full jitter to prevent thundering herd; retry only idempotent operations (use a Stripe-style `Idempotency-Key` for POST). Set timeouts from p99.9, not guesses.