Software architecture guide

Reliable API Design: Timeouts, Idempotency, Caching, and Scale

Reliable APIs make their time, consistency, capacity, and failure behavior explicit so callers can succeed without overwhelming the systems behind them.

Published
Updated
Reading time
19 min read
On this page

An API contract is more than a path, method, and JSON schema. It also defines how long a caller should wait, whether an operation can be repeated, which responses may be cached, how capacity is shared, and what a client should do when a dependency is unavailable. These choices determine whether an API remains useful when traffic or failures are real rather than ideal. Reliable API design is what turns a schema into a system other teams can depend on.

This guide focuses on provider-neutral controls that work with a gateway, a managed edge, a service mesh, or a direct service deployment. The product names can change; the architectural requirements should not. That neutrality matters most at the scale a managed orchestration layer operates: when the same API is fronted by more than one gateway, CDN, or region, timeout, idempotency, cache, and rate-limit behavior has to stay consistent everywhere the request might land, not just at the origin the team happens to be looking at.

A successful HTTP response is not the only success condition

An API can return quickly and still fail its contract if it exposes another tenant's data, accepts work it cannot complete, repeats a charge, or serves a stale representation where freshness is required. Design response time, correctness, and safety together. If more than one provider or region can accept the same request, verify that idempotency, rate-limit, and cache decisions agree across all of them — a single observability plane such as Optimi's MYO makes that cross-provider drift visible before it reaches a customer as a duplicate order or an inconsistent 429.

Overview

Outcome and prerequisites

Outcome: Acme Shop can accept an order exactly once from a retrying client, expose truthful asynchronous progress, and reject overload before it exhausts the order service. Prerequisites: authenticated tenant identity, a durable idempotency record with a uniqueness constraint, an operation-status store, bounded worker capacity, and trace or request IDs that cross the gateway and queue.

Running scenario: Acme Shop order submission

At a sale launch, a mobile client may lose its response after POST /v1/orders was accepted. Acme Shop must not create a second order when the client retries, and it must not claim that payment or fulfillment has completed before its asynchronous workflow has reached that state. The API therefore records the request identity and returns a status resource for accepted work.

Acme Shop idempotent order lifecycle
  1. Client request

    Authenticated client sends a stable Idempotency-Key.

  2. Gateway admission

    Validation and tenant-aware rate limits reject unsafe work early.

  3. Durable order boundary

    Order, idempotency record, and outbox are committed together.

  4. 202 status resource

    The client receives an authenticated operation URL.

  5. Worker completion

    Queued work updates the operation to completed or failed.

The gateway validates and rate-limits before a durable idempotency record and order transaction are committed. The client polls the status resource while workers progress the order.

Define contracts clients can act on

Document each operation's purpose, authentication scope, input schema, response representation, pagination model, error format, rate limit, timeout expectation, and retry guidance. Version intentionally: additive fields are usually easier to evolve than semantic changes to a field that clients already interpret.

Use HTTP semantics accurately. GET and HEAD are safe retrieval operations; PUT and DELETE are intended to be idempotent, although an implementation still needs to make repeated effects safe. POST often creates or initiates work and needs an idempotency strategy when clients may retry. Return status codes that let callers distinguish invalid input, missing authorization, conflict, rate limiting, and temporary unavailability.

For long-running operations, avoid holding a client connection open without a bounded outcome. Validate and durably accept the request, return an operation identifier and clear status location, then complete work asynchronously. State the completion objective, retention period, cancellation semantics, and whether a duplicate request returns the existing operation.

Budget time from caller to dependency

Choose a user-facing latency objective first, then work backwards through the critical path. A 500 ms API target cannot safely include 400 ms of gateway work, an unbounded database query, and two retrying third parties. Give every request a deadline and propagate remaining time to downstream calls.

Set shorter timeouts lower in the stack so the caller has time to fall back, return an error, or cancel work. A timeout is not proof that work stopped; a server may still be writing after the client disconnects. Support cancellation where the runtime and dependency allow it, and make business writes idempotent so an uncertain result can be reconciled safely.

Retries should be exceptional, bounded, and informed by the operation:

  • Retry only transient failures and only while time remains in the caller's deadline.
  • Use exponential backoff with jitter to avoid synchronized retry waves.
  • Do not retry validation or authorization errors.
  • Require an idempotency key or durable request identity for retried create, payment, reservation, and mutation operations.
  • Publish Retry-After on rate-limit or overload responses when a meaningful retry window exists.

A single client retrying is cheap; a fleet of clients retrying in lockstep is a second incident layered on the first. AWS's Builders' Library work on timeouts, retries, and backoff with jitter frames this precisely: backoff spaces out one client's own retries, but only jitter prevents many clients from synchronizing on the same retry instants and re-creating the load spike that caused the failure. Treat retry volume as a budget, not an unlimited fallback: cap the fraction of total request volume that may be retries (a common starting point is 10 percent of the primary request rate), and stop issuing new retries once that budget is spent for the window. Without a budget, a dependency that degrades to a 20 percent error rate can see retry traffic double or triple the effective load on it, which turns a partial failure into a full one.

Circuit breakers and bulkheads can contain a failing dependency, but they need observable, tested behavior. A breaker that opens too eagerly can create its own outage; one that never opens merely reports a slow failure. Separate concurrency pools for critical and optional traffic so an expensive report cannot consume the resources needed for login or checkout.

Design API idempotency into state changes

An idempotency key maps a client intent to one durable result. Store the key, authenticated principal or tenant, request fingerprint, current state, and final response for a documented retention period. If the same key arrives with the same intent, return the original or current result. If it arrives with a different payload, return a conflict rather than applying an ambiguous mutation. The IETF HTTPAPI working group's draft Idempotency-Key header field standardizes exactly this shape — clients send a single, unique key per intended operation, and servers must not apply the same key twice with a different payload. It is still an Internet-Draft rather than a published RFC, so treat it as a naming and semantics convention to converge on rather than a dependency to bind to.

API idempotency depends on how the key is generated as much as on how it is stored. A key generated in memory right before the request is sent is lost if the client crashes between generating it and receiving a response — the retry then uses a new key and creates a second result. Prefer keys that are derived from durable client-side state, such as order_id plus a monotonic attempt counter, or persisted to local storage before the request is sent. Stripe's implementation is a useful reference point: it caches the status code and body of the first request for a given key — success or failure — and replays that exact result for any retry, rather than re-evaluating the request each time. Design your own store the same way: capture the terminal response once, and serve it verbatim afterward, so a retry cannot observe a different outcome than the original caller would have. Set the retention window comfortably longer than the slowest realistic client retry delay; a window that is too short silently turns a safe retry into a duplicate.

Do not use a short-lived in-memory cache as the only idempotency store. Processes restart, requests can land on different instances, and delayed retries can arrive after a cache entry expires. A unique database constraint, transactional outbox, or purpose-built durable store makes the boundary survivable.

For events emitted after a write, use an outbox or equivalent atomic handoff so the state change and the message cannot silently diverge. Consumers must also be idempotent because at-least-once delivery can produce duplicates. Ordering should be a declared guarantee per key, not an assumption based on arrival time.

Keep one writer per idempotency key across regions and providers

A single-region idempotency store is straightforward: one database, one unique constraint, one clear answer to "has this key been seen." The failure mode appears once the API is fronted by more than one entry point — a second gateway, a secondary region, or a failover provider — and each one can accept writes. If two entry points each check a local or eventually-consistent replica before the other's write has propagated, both can conclude the key is new and create two orders. The constraint still fires eventually, but only after the damage is done downstream (two payment captures, two fulfillment events).

There is no shortcut around this: idempotency is fundamentally a consensus problem once more than one writer can accept the same key. Acme Shop's options are the same three available to any API with multiple entry points: partition keys to a single owning region or shard so only one writer can ever accept a given key (routing must be deterministic, for example a hash of the tenant and idempotency key); require a strongly-consistent, quorum-backed write for the idempotency record itself even if the rest of the order transaction is eventually consistent; or accept a narrow window of duplicate risk and detect and reconcile it after the fact using the same uniqueness constraint plus a compensating cancellation. Most teams choose the first option because it keeps the failure mode local and testable rather than probabilistic.

One owner resolves every idempotency key

Deterministic ownership prevents two regions or providers from independently accepting the same new key before replication catches up.

Download:PNGSVG

Contract: POST /v1/orders and its status resource

The client supplies an idempotency key that is scoped to its authenticated tenant. The server stores a normalized request fingerprint with the key before responding. Reusing the same key with a changed request is a conflict; a retry with the same intent returns the existing operation rather than starting another order.

POST /v1/orders HTTP/1.1
Authorization: Bearer <redacted>
Idempotency-Key: 01JQ7W5E5K0X8A3NV6JH0M9QRT
Content-Type: application/json

{
  "cart_id": "cart_01JQ7W3EYQ8R8J1RHQF7P6N4W9",
  "shipping_address_id": "addr_01JQ7VZ9K2H84T3K1M2V",
  "payment_method_id": "pm_saved_82"
}
HTTP/1.1 202 Accepted
Location: /v1/operations/op_01JQ7W6ESQ9PVHQ7H5YBX5Q3FN
Retry-After: 2
Content-Type: application/json

{
  "operation_id": "op_01JQ7W6ESQ9PVHQ7H5YBX5Q3FN",
  "order_id": "ord_01JQ7W6EC61CR7VCE1JZMPK6TQ",
  "status": "accepted",
  "status_url": "/v1/operations/op_01JQ7W6ESQ9PVHQ7H5YBX5Q3FN"
}
GET /v1/operations/op_01JQ7W6ESQ9PVHQ7H5YBX5Q3FN HTTP/1.1
Authorization: Bearer <redacted>

HTTP/1.1 200 OK
Cache-Control: no-store
{
  "operation_id": "op_01JQ7W6ESQ9PVHQ7H5YBX5Q3FN",
  "status": "processing",
  "order_id": "ord_01JQ7W6EC61CR7VCE1JZMPK6TQ",
  "updated_at": "2026-07-14T10:12:08Z"
}

The status resource requires the same authenticated tenant context as the order. A missing or invalid Authorization header returns 401. Acme Shop returns 404 to an authenticated caller from a different tenant so an operation identifier does not reveal whether another tenant's order exists. accepted, processing, completed, and failed are distinct states. Publish retention and cancellation behavior; a 202 is not a guarantee of eventual payment or fulfillment.

Representative output: duplicate and conflicting requests
POST /v1/orders Idempotency-Key: 01JQ...QRT  -> 202 accepted, operation=op_01JQ...3FN
retry with identical authenticated request         -> 202 accepted, operation=op_01JQ...3FN, idempotency_replay=true
reuse key with different cart_id                  -> 409 conflict, code=idempotency_key_reused
GET /v1/operations/op_01JQ...3FN owning tenant   -> 200 processing, retry_after_seconds=2
GET /v1/operations/op_01JQ...3FN no/invalid auth -> 401 unauthorized
GET /v1/operations/op_01JQ...3FN other tenant    -> 404 not_found

Use a transaction or equivalent durable boundary for the order and outbox record. The following database constraint illustrates the minimum identity boundary; its retention period must exceed the documented client retry window.

create unique index order_idempotency_tenant_key
  on order_idempotency (tenant_id, idempotency_key);

Cache reads safely at the edge and service layer

Caching can remove origin latency and protect upstream capacity, but it must not weaken authorization or freshness. Classify a response before assigning a shared-cache policy:

Response typeSafer defaultNotes
Public, versioned assetShared cache with long freshnessUse immutable URLs or validated versioning.
Public catalog or reference dataShared cache with explicit TTL and validationDefine invalidation and stale behavior.
Authenticated, tenant-specific responsePrivate or no-store by defaultNever rely on a shared cache to infer tenant boundaries.
Authorization, account balance, checkout stateNo shared caching unless formally designedCorrectness and privacy outweigh a small latency win.

Define the cache key deliberately. It may include path, selected query parameters, representation headers, locale, and a safe authorization partition. Do not include unbounded headers or cookies indiscriminately, which destroys cache efficiency; do not omit dimensions that can cause one representation to be served to the wrong caller.

The Vary header is where cache-key mistakes usually hide. Per RFC 9111, a cache must fold every header named in Vary into its cache key, so a response that varies by Accept-Language or a tenant-scoped header but omits it from Vary can be served to the wrong locale or, worse, the wrong tenant once a shared cache is involved. Vary: * is legal but forces revalidation on every request, which quietly removes the cache's benefit while looking configured. Treat a missing or incomplete Vary declaration on any response that is not uniform for all callers as a correctness bug, not a performance nit.

stale-while-revalidate and stale-if-error (RFC 5861) let a cache serve a slightly stale response while it revalidates in the background, or serve the last good response when the origin errors, instead of forcing every caller to wait on a synchronous fetch. They are a good fit for public catalog or reference data where a few extra seconds of staleness is an acceptable trade for absorbing an origin blip — they should not be applied to authenticated, tenant-specific, or financial responses, where "possibly stale" is exactly the property the no-store rule above exists to prevent.

Use request coalescing for popular keys to prevent a miss storm. Put an origin shield or controlled aggregation layer behind the edge where it suits the topology. Keep the origin private, authenticate edge-to-origin traffic, validate forwarded headers only from trusted proxies, and limit origin connections and concurrency. A CDN does not protect an origin that attackers can reach directly.

Control load with API rate limiting before it becomes an outage

Rate limits allocate a finite resource. Scope them to the risk: an unauthenticated endpoint may need an IP or network-oriented limit, while an authenticated API normally needs tenant, credential, operation, and cost-aware limits. Publish headers or documentation that explain the quota window and response behavior, but do not reveal internal capacity details that aid abuse.

API rate limiting has historically been signaled through ad hoc, provider-specific headers (X-RateLimit-* variants that differ in units, reset semantics, and casing from one API to the next). The IETF HTTPAPI working group's draft RateLimit and RateLimit-Policy header fields standardize this: RateLimit-Policy advertises the quota shape (limit and window), and RateLimit reports what remains against it, so a well-behaved client can throttle itself before it draws a 429 rather than discovering the limit by exhausting it. The draft is not yet an RFC, but it reflects the direction the ecosystem is converging on, and adopting its field names now costs little even before it is finalized — it is a strictly additive, provider-neutral layer on top of whatever internal limiter actually enforces the quota.

Rate limiting alone does not stop expensive valid requests. Combine it with body-size limits, schema validation, pagination caps, maximum query complexity where relevant, per-route concurrency limits, and work queues for asynchronous operations. Reject work early at the edge or gateway when the decision does not require the origin.

When capacity is constrained, prioritize. Reserve a share for health checks and critical transactions, degrade optional enrichments, and return a fast, documented overload response rather than allowing all callers to wait until the whole pool is exhausted. Test this behavior under realistic concurrency, not only with single-request functional tests.

Overload and cache policy for the order path

Do not shared-cache POST /v1/orders, operation status, account state, authorization decisions, or checkout state. The status endpoint uses Cache-Control: no-store; clients can poll at the server-provided pace or receive an authenticated callback where that contract is available. Public product reads may use explicit cache lifetimes and request coalescing, but their cache key must not cross tenant or price-context boundaries.

At the gateway, Acme Shop uses per-tenant order admission and a small concurrency reservation for checkout. When the durable order transaction or worker backlog reaches its defined boundary, return 503 with Retry-After only if retrying later is safe and likely useful. Do not queue an unbounded number of payment attempts and do not return a successful order response before the order intent is durably recorded.

HTTP/1.1 503 Service Unavailable
Retry-After: 15
Content-Type: application/problem+json

{
  "type": "https://api.acme-shop.example/problems/order-capacity",
  "title": "Order submission is temporarily limited",
  "status": 503,
  "request_id": "req_01JQ7X0FXG9Z0MA1G3CZP7T3KD"
}

Validate, recover, and troubleshoot

In a test tenant or approved canary, submit one order, intentionally drop the client response, and retry the exact request with the same idempotency key. Positive validation is one order and one operation ID. Negative validation is a changed payload with the same key returning 409, with no second order. Failure validation is a worker crash after the outbox write: the status may remain processing, but recovery must publish or reconcile the same order event without a duplicate charge or order.

Where more than one entry point can accept the same order route, add a fourth validation pass: submit the initial request through one region or gateway, then fire the retry with the same idempotency key through a different one before the first write has had time to replicate. A correct implementation still resolves to one order, because the partitioning or quorum write from the earlier idempotency section forces both entry points to agree on the same owning writer. If this test produces two orders, that is evidence the idempotency boundary is per-region rather than global, not evidence of a rare race — treat it as a design defect and fix the routing or consistency guarantee, not as a retry-rate problem to paper over with backoff.

If a contract or rollout defect appears, stop new traffic only through the affected route's documented admission control, roll back the versioned gateway or service deployment, and preserve idempotency and outbox records. Resume workers gradually after the dependency is healthy and reconcile uncertain external effects using provider-side idempotency identifiers. Do not delete idempotency records, bulk-retry payments, or make the status endpoint public to accelerate recovery.

SymptomLikely evidenceSafe actionConfirm recovery
Client reports duplicate orderSame tenant and key map to multiple order IDsDisable the affected create path, investigate the uniqueness boundary, and reconcile before reopeningOne key resolves to one order and operation in a controlled retry test
202 operations never finishQueue age rises or workers show dependency failuresThrottle admission and restore the failed dependency or worker deploymentCompletion age declines without a delivery-error spike
409 occurs for an identical retryFingerprints differ after normalization or client changed a fieldReturn the documented conflict; inspect canonicalization without applying the requestRetried canonical request returns the original operation
Origin saturates despite edge limitsIn-flight order calls or database pool wait reaches its boundTighten route concurrency and shed non-critical reads; investigate the binding resourcePool wait, 503 rate, and order acceptance return to target
Duplicate order only when clients cross regions or providersIdempotency store is scoped per region and replication lags the retryRoute the affected keys to a single owning writer or require a quorum write for the idempotency recordCross-region retry test in the validation pass above resolves to one order
Clients see different quota remaining from different entry pointsEach gateway or edge counts its own local quota instead of a shared counterCentralize the counter per tenant or size local budgets conservatively under one global capRateLimit values agree within a small tolerance across entry points for the same tenant

Observe behavior from edge to data store

Adopt a correlation and tracing model that crosses the edge, gateway, application, queues, and dependencies. OpenTelemetry provides vendor-neutral conventions for HTTP, RPC, database, and messaging telemetry. Record route templates rather than high-cardinality raw paths; include cache status, rate-limit decision, retry attempt, timeout source, queue wait, and dependency outcome.

Create service-level indicators for availability, latency, and correctness. A useful API dashboard shows p50, p95, and p99 duration; response classes; saturation; rejected work; cache hit ratio; queue age; and dependency latency. Segment by operation, region, deployment version, and client type. Redact credentials, authorization headers, personal data, and full request bodies from normal telemetry.

Make multi-region and multi-provider choices deliberately

Global routing can put callers near an edge, but it cannot remove the consistency cost of a write that must reach a distant authority. Keep write ownership clear, locate read replicas according to tolerated staleness, and expose a response version or timestamp where clients need to reason about freshness.

Using multiple gateways, CDNs, or cloud regions can reduce a single operational dependency. It also requires consistent TLS, API policy, cache keys, observability, routing health criteria, and tested failover. Build to portable HTTP, DNS, tracing, and infrastructure interfaces, then document provider-specific limits as deployment constraints rather than embedding them in the API contract.

A reliable API design checklist

Before exposing an API, verify that callers receive timeout and retry guidance; mutations have durable idempotency; cache policies match data sensitivity; origin access is restricted; rate, size, pagination, and concurrency limits are explicit; overload responses are tested; multi-region or multi-provider entry points agree on idempotency and quota state; and telemetry connects an edge decision to application and dependency outcomes. Reliable API design is never finished at launch — treat this checklist as a recurring review as traffic, regions, and providers change.

Authoritative references

Make reliable API design a managed discipline

Talk with Optimi about orchestrating API idempotency, API rate limiting, caching, and origin protection consistently across your providers, with Performance, Security, and Visibility unified in MYO.

Discuss API architecture