Lesson 9 · Errors & limits · visual edition

Errors, rate limiting & observability

~7 min · one diagram per idea · retrieval practice at the end

Published

API error designRate limitingObservabilityStatus codesHTTP methods

Why this, first. These are the “has this person actually operated an API in production?” topics. Anyone ships a happy path. A consistent error contract, principled abuse protection, and real observability are senior table stakes — they decide whether partners can integrate against you and whether you can see what broke at 3am. Mid-level answers stop at “return 500 and log it.” This lesson makes the senior version something you can draw.

Errors must be one contract

limits protect availability

observability sees the failure

idempotency survives the retry

Error design: one machine-readable contract

The fastest way to make an API “impossible to integrate against” is inconsistent errors — a bare 500 here, a 200 with {"ok":false} there, a different shape per endpoint. The senior move is one consistent, machine-readable error contract across the entire API. The standard is RFC 9457, “Problem Details for HTTP APIs” — media type application/problem+json — which supersedes RFC 7807. 1

ANATOMY OF A problem+json ERROR BODY Content-Type: application/problem+json · RFC 9457 (supersedes 7807) RFC 9457 CORE FIELDS "type" URI naming the problem class "title" short human summary "status" HTTP code, e.g. 422 "detail" this-occurrence explanation "instance" URI of this specific event EXTENSION MEMBERS (you add) "code" stable, clients branch on it e.g. "card_declined" — never the title string "message" human, for logs/devs "errors" field-level validation list which field failed, and why ✗ NEVER LEAK INTERNALS no stack traces · no SQL fragments · no internal ids · no "which layer failed" — that is reconnaissance for an attacker.
Clients branch on a stable code, humans read the title/message, and errors[] says which field failed — all under one media type.

Pick the correct status code — this ties back to Lesson 2 — and never tunnel errors through 200; the status line is part of your contract and breaks every intermediary that reads it. 3

ROUTE THE FAILURE TO THE RIGHT CODE 400 malformed syntax server can't even parse it 422 parsed, but invalid semantic / validation fail 409 state conflict collides with current state ✗ Don't tunnel any of these through 200 — the status line IS the contract.
400 malformed · 422 well-formed-but-invalid · 409 conflict. Same body shape, honest status line.
✗ Myth: clients should read the human error message to decide what to do.

✓ Truth: clients branch on a stable code; the prose is for humans and can be reworded freely.

Clients branch on your error codes, not your error prose — so make the codes stable and the prose human. The moment you reword a string and a partner’s if -statement breaks, you’ve taught them to never trust your contract again.

Rate limiting: protect availability and cost

Without limits, one client — buggy retry loop, scraper, or attacker — can exhaust your capacity or your cloud bill. This is OWASP API4:2023 Unrestricted Resource Consumption, a top-ten risk for a reason. 2 The contract for a throttled request: 429 Too Many Requests with Retry-After, plus RateLimit-* headers on every response so well-behaved clients self-throttle before they hit the wall.

TOKEN BUCKET → 429 WHEN EMPTY refill: steady rate cap = burst size spend 1 / req token left? per principal yes 200 · served RateLimit-Remaining decremented empty 429 Too Many Requests Retry-After: 30 RateLimit-Limit / -Remaining / -Reset headers sent on EVERY response
Tokens refill steadily; a burst drains the cap, then it's steady-state. Empty bucket → 429 + Retry-After, so good clients back off on their own.

Fixed window Count per calendar window. Trivial, but allows a boundary burst — up to 2× the limit straddling the window edge.

Sliding window Rolling time window (weighted log/counter). Smooths the boundary burst at modest extra state cost.

Token bucket Tokens refill at a steady rate; a request spends one; the cap bounds the burst. The common choice — tolerates spiky-but-bounded traffic.

Leaky bucket Requests queue and drain at a fixed rate. Smooths output to a constant rate; protects a downstream that hates spikes.

Limit per principal — API key, account, or user — so one tenant can’t starve the rest; fall back to per-IP only for unauthenticated traffic. And separate burst from sustained quotas (e.g. 100/sec burst, 10k/hour sustained) so a legitimate spike isn’t punished like sustained abuse.

Observability: see what actually broke

The three pillars are logs (structured/JSON, not free-text), metrics, and traces. The connective tissue is a correlation / request id — W3C traceparent or X-Request-Id — propagated across every service so one request is traceable end to end. Without it, a distributed bug is a needle in N haystacks.

THE OBSERVABILITY TRIAD, STITCHED BY ONE ID correlation / request id traceparent · X-Request-Id LOGS structured JSON not free-text METRICS · RED Rate · Errors · Duration duration = p50/p95/p99 TRACES OpenTelemetry where time goes
One id flows through every service, so logs, RED metrics, and OpenTelemetry traces all point at the same request. Define SLOs and error budgets so "page someone?" has a numeric answer, not a vibe.

Track RED metrics per endpoint — Rate, Errors, Duration — and measure duration as latency percentiles (p50/p95/p99), never averages: an average of 50ms hides the p99 of 4s that’s losing you the partner.

🛡️ Memory rule: one problem+json contract (stable code, human prose, no leaks) · throttle per principal with token bucket → 429 + Retry-After · see it via logs + RED metrics + traces stitched by one request id · keep retries safe with idempotency keys.

Tie it back to retries: rate limits and outages cause client retries, and retries on writes cause duplicates — which is exactly why idempotency keys (Lesson 3) are the safety net under all of this. Observability tells you it’s falling over; idempotency keeps the recovery from corrupting state.

Retrieval practice

Close the lesson in your mind first, then answer from memory. Effortful recall is what converts this from “I read it” to “I own it.” Immediate feedback below.

Q1. The current standard contract for HTTP API errors is…

Q2. A well-formed request that fails validation should return…

Q3. Token bucket is the common rate-limit choice because it…

Q4. Endpoint latency should be reported and judged on…

Rehearse the answer out loud

Interviewer: "A partner says our API is impossible to integrate against and keeps falling over under their traffic. Redesign the error responses and protect the service." ~60 seconds, from memory (no scrolling up) — then reveal the model answer and compare.


“Three moves. First, one consistent error contract: RFC 9457 problem+json everywhere, with a stable machine-readable code clients branch on, a human message, and field-level validation errors — correct status codes (400 malformed vs 422 validation) and zero internal leakage, no stack traces or SQL. Then I’d publish an error catalog so partners code against documented codes.

Second, rate limiting: token bucket per API key, returning 429 with Retry-After and RateLimit-* headers so good clients back off on their own. Third, make their retries safe with idempotency keys on writes. And to actually see where it falls over, I’d instrument with correlation ids, structured logs, and RED metrics — p95/p99 latency — plus distributed tracing.”

Why this scores: it fixes the DX complaint with a standard contract, protects availability with principled rate limiting, and proves production observability — three distinct senior signals in one answer.

I’m your teacher — ask me anything. Want a worked problem+json payload with field-level errors? Curious how token bucket compares to sliding window under a thundering herd? Want me to grade your rehearsal answer or push harder on SLOs and error budgets? Just say so in the chat.

Primary source (read this next)

📖

RFC 9457 — “Problem Details for HTTP APIs”

: short and concrete; the contract you’ll actually quote in design reviews. Then skim

OWASP API Security Top 10 (2023)

for the abuse-and-leakage risks this lesson hardens against.

Interview — pick your bar

Answer out loud in ~60s, then reveal. Core = recall · Senior = trade-offs & failure modes · Staff = synthesis under ambiguity · System Design = open design round (a different axis, not a harder level).

What is the current standard for HTTP API error bodies?

RFC 9457, "Problem Details for HTTP APIs" — media type application/problem+json. It defines a single, consistent shape for error responses across an API. Naming the RFC number (not just "problem+json") is the senior tell; it supersedes RFC 7807, so cite the right one in the room.

400 vs 422 — when each?

400 Bad Request for a syntactically malformed request — broken JSON, missing required structure, can't even parse it. 422 Unprocessable Content for a request that parsed fine but is semantically invalid — e.g. valid JSON whose email field fails validation. Parsed-but-wrong is the 422 signal.

What's the HTTP contract for a throttled request?

429 Too Many Requests with a Retry-After header telling the client when to come back. Ideally also expose RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset on every response so well-behaved clients self-throttle before they hit the wall.

What are the RED metrics?

Rate (requests/sec), Errors (failed requests), Duration (latency). Tracked per endpoint, they're the minimal dashboard that tells you whether a service is healthy — and the first thing to pull up when something's wrong.

Why must clients branch on error codes, not error prose?

Prose is for humans and changes freely; codes are part of your contract and must stay stable. The moment you reword a string and a partner's if-statement breaks, you've taught them to never trust your contract again. So: stable codes, human prose — and document the codes as an error catalog they can code against.

Why does consistent status mapping matter beyond aesthetics?

Because the whole HTTP ecosystem acts on the status: caches, retry logic, circuit breakers, monitoring, and clients all decide behavior from it. Map a transient overload to 400 and clients won't retry; map a validation error to 500 and you page yourself for client mistakes and inflate your error budget. The status class (4xx = "you," 5xx = "me") drives who retries and who gets paged.

Token bucket — how it works and why it's the common default?

Tokens refill at a steady rate; each request spends one; the bucket size caps how many can accumulate. It allows bursts up to the bucket size, then settles to the steady refill rate. It's the common choice because real traffic is spiky-but-bounded — token bucket tolerates a legitimate spike while still enforcing a sustained average.

Why report latency as p95/p99 instead of the mean?

Because the mean hides the tail. A p50 of 50ms can sit alongside a p99 of 4s — and that p99 is exactly the slow experience losing you the partner. Averages get dragged around by outliers and conceal them at once. Percentiles describe what real users actually feel; judge and alert on p95/p99, never the mean.

How do you enforce a global rate limit across many distributed API nodes?

The naive per-node counter lets the real limit balloon by N nodes. Options, in tension: a shared store (Redis with atomic INCR/Lua, or a token-bucket script) gives an accurate global count but adds a hop and a dependency on every request; local counters with a per-node share are fast but approximate and unfair under skew; sampled/sync'd local buckets trade a little accuracy for latency. Most real systems do limiting at the gateway/edge with a shared store, accepting eventual-consistency slop near the boundary.

How do rate limiting, retries, idempotency, and circuit breakers fit together as one story?

They're a closed loop. Rate limits/outages trigger retries; retries need backoff + jitter so they don't stampede; retries on writes need idempotency keys so they don't duplicate; a persistently failing dependency needs a circuit breaker so you stop retrying into the void and degrade gracefully; and observability (correlation ids, RED, p99, traces) is how you see the whole loop and tune it. Naming the loop, not just one piece, is the staff signal.

Tracing every request is too expensive at scale. How do you sample without going blind?

Mix strategies. Head-based sampling (decide at ingress, e.g. keep 1%) is cheap but may drop the rare failure you needed. Tail-based sampling buffers spans and keeps traces that are interesting — errors, high latency — at the cost of more infrastructure. Keep 100% of error/slow traces and sample the boring success path; keep full-fidelity metrics always so aggregates stay accurate even when individual traces are sampled out.

Design-round framework — drive any error/rate-limit/observability prompt through these, out loud:
  1. Clarify scale & clients: traffic shape (spiky vs steady), public vs partner, write- vs read-heavy, latency & cost budget, SLO targets.
  2. Error contract: one RFC 9457 problem+json shape everywhere + a stable machine-readable code and field-level errors; no internal leakage.
  3. Status mapping: correct 4xx vs 5xx so retries, caches, and paging behave (400 malformed vs 422 validation; 409 conflict; 429/503 + Retry-After).
  4. Rate limiting & load shedding: pick an algorithm (token bucket default; leaky bucket for fragile downstreams) per principal, burst vs sustained, enforced at the gateway with a shared store.
  5. Safe recovery: idempotency keys on writes, exponential backoff + jitter, circuit breakers, graceful degradation under stress.
  6. Observability: structured logs + RED metrics + traces tied by a correlation id; alert on p95/p99 and SLO error-budget burn, not the mean.
  7. Failure modes & guardrails: thundering-herd retries, miscategorized status polluting SLOs, secrets/PII in logs, sampling that drops the failures you need.
Design the error contract, abuse protection, and observability for a public write-heavy REST API a partner says is "impossible to integrate against and keeps falling over."

A strong answer covers: diagnose the two complaints separately — "impossible to integrate" is an error-contract/DX problem, "keeps falling over" is an abuse/resilience problem → error contract: adopt one RFC 9457 problem+json shape across every endpoint, add a stable machine-readable code as an extension member (clients branch on the code, never the prose), emit field-level validation errors, map status correctly (400 malformed vs 422 validation, 409 conflict, 401/403), leak no stack traces/SQL, and publish an error catalog enforced by contract tests in CI → abuse protection: token bucket per API key (absorbs legitimate bursts, enforces a sustained average) with separate burst vs sustained quotas tied to billing tiers, return 429 + Retry-After and expose RateLimit-* headers so good clients self-throttle; enforce at the gateway with a shared store (Redis atomic INCR/Lua) accepting eventual-consistency slop; add load shedding (503 + Retry-After) before you fall over → safe recovery: require idempotency keys on writes so retried POSTs don't double-charge, advise exponential backoff + jitter (and vary Retry-After server-side) to avoid a thundering herd, and circuit-break failing downstreams → observability: structured logs keyed by a propagated correlation id (W3C traceparent), RED metrics per endpoint, OpenTelemetry traces, alert on p95/p99 and SLO error-budget burn (not the mean), and split dashboards by status class so partner 4xx doesn't pollute server 5xx signals → name the trade-off: a shared-store global limiter is accurate but adds a hop/dependency vs fast-but-approximate local counters, and tail-based trace sampling keeps the failures you need at the cost of more infrastructure.

Design the end-to-end resilience and observability story for a payments-style API where retries must never double-charge and outages must degrade gracefully.

A strong answer covers: treat resilience as a closed loop, not isolated features → idempotency is the backbone: every write carries an Idempotency-Key; the server stores key → result on first success and returns the stored response on replay, with a lock/"in progress" record for the concurrent in-flight retry and a sensible key-expiry window — this makes a non-idempotent charge safe to retry → retry discipline: clients use exponential backoff + jitter so a recovering service isn't stampeded; the server signals retryability honestly — 429 + Retry-After for throttling, 503 + Retry-After for temporary overload, and never dresses a client error as a retryable 5xxcircuit breakers around the payment processor and other dependencies: open on a failure threshold to fail fast, half-open to test recovery, so a struggling downstream gets room instead of being retried to death → graceful degradation: load-shed excess early, serve stale-but-cached non-critical data, disable optional features, and prioritize the core charge path; decide in advance what's droppable → error contract: RFC 9457 problem+json with stable codes (e.g. card_declined) clients can branch on, correct status mapping, zero leakage of internal detail → observability: correlation id propagated across every hop, structured logs that never log PANs/secrets/PII (redact at the boundary), RED metrics, OpenTelemetry traces to see where time/failures land, and paging tied to SLO error-budget burn against the user-facing SLI rather than raw error counts → name the trade-off: idempotency adds storage + a write-path lookup and forces you to define exactly-once windows, but it's the only honest way to make money-moving retries safe.

Was this lesson helpful? Thanks — noted.

These notes reflect my current understanding and are updated as I learn, build, and discover better explanations.