APIs that stay up under pressure
Rate limits, timeouts, retries, circuit breakers, and the controls that keep APIs available under pressure.
Most API outages move through the same quiet sequence. A dependency slows down. Requests begin to gather. Clients retry. Workers block, and the connection pools fill. Meanwhile the database still has CPU to spare and the app still has memory, so the dashboards read as normal for a while. The thing the user actually feels is the waiting, and the waiting arrives before any alarm does.
Adding servers here only widens the loop. Each new server opens its own calls into the slow dependency, sends its own retries, and holds its own database connections.
What follows are the controls that interrupt the loop, and a calm way to reason about each one.
The failure is usually queueing
An API fails when code throws. It also fails, just as completely, when useful work sits behind work that will not finish in time.
Slow dependency calls hold onto request workers, and those workers hold memory, sockets, and database connections. New requests arrive and wait their turn. Clients time out and retry, and the retry lands while the first request is still running. Through that small chain, a modest rise in latency becomes an outage.
normal:
500 requests/sec
50ms service time
25 concurrent in-flight requests
dependency slows:
500 requests/sec
2s service time
1000 concurrent in-flight requests
The arrival rate is the same in both cases: 500 requests/sec. Only the service time changes, stretching from 50ms to 2 seconds, and in-flight work follows it from 25 to 1000.
This is why adding servers tracks the bottleneck rather than relieving it. Each new server simply opens more concurrent calls into the dependency that is already slow.
Rate limiting is admission control
Rate limiting decides which requests are allowed in. It answers a single question:
Should this request be allowed to enter the system right now?
A token bucket is a gentle way to express this. It gives each caller a burst allowance and a steady refill rate.
import time
class TokenBucket:
def __init__(self, capacity, refill_per_second):
self.capacity = capacity
self.refill_per_second = refill_per_second
self.tokens = capacity
self.updated_at = time.monotonic()
def consume(self, cost=1):
now = time.monotonic()
elapsed = max(0, now - self.updated_at)
self.tokens = min(
self.capacity,
self.tokens + elapsed * self.refill_per_second,
)
self.updated_at = now
if self.tokens < cost:
return False
self.tokens -= cost
return True
This is a teaching version, kept small on purpose. In production the bucket needs to be shared across app instances, which usually means Redis with an atomic script, a gateway, or an edge proxy.
The configuration has a simple shape:
capacity = burst size
refill = sustained rate
cost = request weight
A cheap read might cost 1 token. A report export might cost 20. A login attempt can sit behind a separate security limit. A tenant-level write limit can protect the database from one large customer. Each weight is just an honest estimate of the work the request will ask for.
Pick the limit key carefully
The limit key decides who is isolated from whom. The common choices each carry their own tradeoff.
| Key | Protects against | Risk |
|---|---|---|
| IP address | Anonymous abuse and simple scraping | NATs and mobile networks can group many users |
| User id | One user overwhelming their own quota | Does not protect multi-user tenants |
| API key | External integrations and partner apps | One customer can create many keys unless controlled |
| Tenant id | One organization dominating shared resources | Large tenants may need purchased capacity |
| Endpoint | One expensive route overwhelming internals | Does not distinguish good callers from bad callers |
| Global | Total system overload | Can let noisy callers crowd out important traffic |
A common and forgiving approach is to layer the limits:
global limit
tenant limit
user/API key limit
endpoint cost limit
Cost tracks weight. A GET /status spends a token or two; a POST /exports spends a larger share of the budget, in proportion to the work it sets in motion.
Fixed window, sliding window, token bucket, leaky bucket
Each strategy earns its place through how it behaves. Read the rows by what they do rather than by their names.
| Strategy | Behavior | Use it when |
|---|---|---|
| Fixed window | 100 requests per minute resets on the minute | Simple counters and coarse limits |
| Sliding window | Counts recent requests across a moving interval | Fairer user-facing quotas |
| Token bucket | Allows bursts up to capacity, then sustained refill | Most API admission control |
| Leaky bucket | Smooths output at a steady rate | Protecting a downstream with strict throughput |
Fixed windows count with a single counter that resets on the boundary. That leaves an edge: a caller can send 100 requests at 12:00:59 and 100 more at 12:01:00, landing 200 requests inside two seconds.
Token buckets sit comfortably with the bursty traffic real systems actually see. They allow short bursts up to capacity, then settle back to the refill rate, which keeps sustained pressure in check.
Timeouts are part of the contract
Every network call needs a timeout. The public API, internal HTTP calls, database queries, Redis calls, search calls, queue publishes, object storage reads. Anything that waits on another system deserves a deadline.
Without one, the dependency decides how long your worker lives, and that is not a decision worth giving away.
def get_profile(user_id, request_deadline):
remaining = request_deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("request deadline exceeded")
return http.get(
f"https://profiles.internal/users/{user_id}",
timeout=min(0.150, remaining),
)
A timeout budget makes the whole constraint visible at a glance:
public request deadline: 800ms
auth: 50ms
database: 150ms
profile service: 150ms
recommendation call: 200ms
rendering/marshal: 50ms
slack: 200ms
By the time the outer request times out at 800ms, the user has already moved on. An inner call still waiting at 2 seconds is spending a worker on a result nobody is there to receive.
Retries are load multipliers
Retries earn their keep when the failure is temporary and the operation is safe to repeat. When the failure is permanent, or the operation carries side effects, each retry only adds load and leaves reliability exactly where it was.
They also compound across layers, quietly. One retry at three layers multiplies into something larger than it looks:
client retries 2x
gateway retries 2x
service retries 2x
one user request can become 8 downstream attempts
This is a retry storm.
A retry budget holds it in bounds:
import random
import time
def call_with_retries(fn, deadline, max_attempts=3, base_delay=0.025):
last_error = None
for attempt in range(max_attempts):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("deadline exceeded") from last_error
try:
return fn(timeout=remaining)
except TemporaryError as error:
last_error = error
if attempt == max_attempts - 1:
break
delay = min(base_delay * (2 ** attempt), 0.250)
jitter = random.uniform(0, delay)
time.sleep(min(jitter, max(0, deadline - time.monotonic())))
raise last_error
Synchronized retries are a burst in their own right. Backoff spreads them out over time, and jitter makes that spread uneven, which gives a struggling dependency the room it needs to recover.
Circuit breakers fail fast
A circuit breaker simply stops calling a dependency that is already failing. It rests in one of three states:
closed: calls flow normally
open: calls fail fast
half-open: a few probe calls test recovery
In its simplest form:
import time
class CircuitBreaker:
def __init__(self, failure_threshold, reset_after_seconds):
self.failure_threshold = failure_threshold
self.reset_after_seconds = reset_after_seconds
self.failure_count = 0
self.opened_at = None
def call(self, fn):
if self.opened_at is not None:
elapsed = time.monotonic() - self.opened_at
if elapsed < self.reset_after_seconds:
raise ServiceUnavailable("circuit open")
return self._half_open_call(fn)
try:
result = fn()
self.failure_count = 0
return result
except Exception:
self.failure_count += 1
if self.failure_count >= self.failure_threshold:
self.opened_at = time.monotonic()
raise
def _half_open_call(self, fn):
try:
result = fn()
self.failure_count = 0
self.opened_at = None
return result
except Exception:
self.opened_at = time.monotonic()
raise
This code is intentionally small. Real breakers add rolling windows, error rates, probe limits, and metrics. The principle underneath them does not change: once a dependency is unhealthy, there is little to gain from spending request threads on it.
Choosing the numbers
The patterns themselves are well known. The real work is choosing the specific numbers, and deciding what the product does when those numbers take effect:
- What status code is returned?
- What does the user see?
- Which endpoint is protected first?
- Which customer is allowed to burst?
- Which dependency is optional?
- Which write is safe to retry?
These are architecture decisions, and they are worth making calmly, before pressure forces them.
Bulkheads and concurrency limits
Rate limits govern arrival. Concurrency limits govern in-flight work. A reliable API tends to use both, each covering its own surface.
If an endpoint can spend 2 seconds waiting on a dependency, even a modest request rate can consume every worker. A concurrency limit caps how many calls run at once.
class ConcurrencyLimiter:
def __init__(self, max_in_flight):
self.semaphore = Semaphore(max_in_flight)
def call(self, fn):
if not self.semaphore.acquire(blocking=False):
raise ServiceUnavailable("too many in-flight requests")
try:
return fn()
finally:
self.semaphore.release()
Give separate classes of work their own pools:
checkout pool: small, protected, high priority
search pool: medium, user-facing
export pool: small, background
analytics pool: best effort
admin report pool: isolated
This is a bulkhead. Each pool holds its own capacity, so a flooded endpoint drains only its own pool and the rest of the product keeps its share. One part can struggle without pulling the whole down with it.
Graceful degradation
A dependency can fail while the API stays up. That is often a choice you can design for rather than an accident you suffer.
- If recommendations are unavailable, return the page without recommendations.
- If the personalization service is slow, return the default ranking.
- If a profile badge service fails, omit the badge.
- If a search cluster is unhealthy, return cached results with a stale marker.
The goal is steady: preserve the core product path when the optional pieces fall away.
def get_home_feed(user_id, deadline):
feed = db.get_recent_feed_items(user_id, timeout=0.120)
try:
recommendations = recommender.get_items(
user_id,
timeout=min(0.080, deadline - time.monotonic()),
)
except (TimeoutError, ServiceUnavailable):
recommendations = []
return {
"feed": feed,
"recommendations": recommendations,
}
The design question worth keeping nearby:
What can we remove and still serve something useful?
Load shedding
Load shedding is rejecting some work on purpose so the system stays available for the rest of the traffic. It is an act of care for the requests you can still serve well.
It pays off most when it rejects early:
- Before expensive authentication fanout if possible.
- Before opening database transactions.
- Before calling slow dependencies.
- Before accepting background jobs that cannot be processed.
A rejection that lands after most of the work is done has already spent the very capacity it was meant to save.
When the system is overloaded, a fast 429 or 503 with Retry-After hands the client a clear signal in milliseconds and frees the worker right away. A 30-second timeout holds that worker for the full 30 seconds and leaves the client to retry blindly into the dark.
HTTP/1.1 429 Too Many Requests
Retry-After: 3
Content-Type: application/json
{"error":"rate_limited","retry_after_seconds":3}
Status codes are control signals
Clients behave well when they are told clearly what happened. Distinct signals give them that.
| Code | Meaning in this context | Client behavior |
|---|---|---|
408 | Request timed out before completion | Usually safe to retry only if operation is idempotent |
409 | Conflicting command or reused idempotency key with different body | Do not blindly retry |
425 | Too early for unsafe replay | Retry later if protocol supports it |
429 | Caller exceeded a limit | Wait according to Retry-After |
500 | Unexpected server error | Retry only with budget and idempotency |
502 | Bad upstream response | Retry cautiously |
503 | Service unavailable or circuit open | Retry later with backoff |
504 | Gateway timeout | Retry cautiously; original work may still be running |
A clear error tells the client exactly what to do next, so it waits, backs off, or stops. That kind of precision is itself a form of reliability.
The retry matrix
Before adding a retry, pause and decide whether it is safe:
| Operation | Retry? | Why |
|---|---|---|
GET /profile/123 | Usually yes | Read is naturally safe if bounded |
POST /payments without idempotency key | No | Duplicate charge risk |
POST /payments with idempotency key | Yes, carefully | Server can dedupe the command |
| Queue publish after DB commit without outbox | No simple retry is enough | Can create lost or duplicate effects |
| Search index update from event handler | Yes | Handler should be idempotent |
| Email send | Usually no direct retry without dedupe | Duplicate emails hurt trust |
Retry policy follows from operation semantics. A PUT is safe to retry when its implementation lands the same final state on every attempt. A POST is safe to retry when an idempotency key lets the server dedupe the command.
Metrics that help
A small handful of metrics deserves a place on the first dashboard for any API:
| Metric | Why it matters |
|---|---|
| Request rate by endpoint and caller | Shows who is creating load |
| In-flight requests | Exposes queueing before CPU does |
| p50, p95, p99 latency | Shows the latency tail |
| Timeout count by dependency | Shows where budget is spent |
| Retry attempts by caller and dependency | Finds retry storms |
| Rate-limit rejects | Shows protected pressure |
| Circuit breaker state | Shows fast-fail behavior |
| Queue lag and age | Shows async backlog |
| Saturation by pool | Shows bulkhead pressure |
| Error rate by status code | Separates overload, bugs, dependency failure, and client misuse |
Watch in-flight work and saturation right alongside CPU. An API can go unresponsive at low CPU, simply because every worker is sitting and waiting on I/O.
Practical sequence
A workable order for putting these controls in place:
- Define the core product path.
- Put deadlines around every downstream call.
- Limit in-flight work per route and dependency.
- Add caller-aware rate limits.
- Retry only idempotent operations, inside a deadline, with jitter.
- Use circuit breakers for dependencies that fail slowly.
- Decide which pieces can degrade.
- Return clear status codes that teach clients what to do.
- Watch in-flight work, tail latency, retries, and rejects.
It all starts with a few plain questions:
How many requests are allowed in?
How long can they run?
How many can wait?
What happens when dependency X is slow?
What should the client do next?
Once those answers exist, the API has a reliability story to stand on, and the architecture diagram stops being a hope and starts describing a system that already knows how it behaves under pressure.
Endpoint checklist
For any important API endpoint, it helps to fill in:
| Question | Example answer |
|---|---|
| Endpoint | POST /exports |
| Caller key | tenant_id and user_id |
| Cost | 20 tokens per export request |
| Burst limit | 10 requests per tenant |
| Sustained limit | 2 requests per minute per tenant |
| Public deadline | 1 second for enqueue response |
| Downstream timeouts | DB 150ms, queue publish 150ms |
| Retry policy | Queue publish retry 2x with jitter inside deadline |
| Idempotency | Required Idempotency-Key header |
| Concurrency limit | 5 active export starts per tenant |
| Degraded response | Return existing export if duplicate key is reused |
| Overload response | 429 with Retry-After |
| Dashboard | rate, rejects, in-flight, p99, queue lag, duplicate keys |
Then ask:
If this endpoint gets 10x traffic for five minutes, what protects the rest of the system?
A complete answer names a control beyond autoscaling. Autoscaling adds capacity; admission control governs what gets in. Both belong in the answer, and they work best together.
Summary
- API failures often arrive as queueing failures, with the code still running fine.
- Rate limits control which work enters the system.
- Concurrency limits control how much work can be in flight.
- Every downstream call needs a timeout and should respect the request deadline.
- Retries multiply load; use budgets, backoff, jitter, and idempotency.
- Circuit breakers fail fast when a dependency is already unhealthy.
- Graceful degradation preserves the core product path.
- Load shedding rejects work early to keep capacity for the traffic the system can still serve.
- Status codes and
Retry-Afterheaders are part of the reliability contract. - Watch in-flight work, p99 latency, retry attempts, rate-limit rejects, and queue lag.
Pop quiz
Interactive quiz
API reliability controls
A relaxed, randomized review of rate limits, timeouts, retries, circuit breakers, and load shedding.