Reliable API Clients: Pagination, Conditional Requests, and Retry Budgets
Keep list traversal stable, validate cached representations, and bound retry work when networks fail at ambiguous points.
On this page
Pagination needs a stable ordering
A list response must declare its ordering before pagination can be meaningful. Ordering only by a timestamp is ambiguous when several rows share the same timestamp. Add a unique tie-breaker, for example (created_at, id), and make the cursor represent both values. A cursor should be opaque to clients, but opacity is not a security boundary: validate it and apply authorization independently.
Offset pagination is simple for small, mostly static datasets. On a changing dataset, inserts and deletes before the current offset can duplicate or skip items. Keyset pagination asks for rows after the last ordering tuple and avoids that particular shift. It still does not create a snapshot: updates to ordering fields can move rows between pages. If an export needs one consistent view, define snapshot semantics explicitly rather than assuming a cursor guarantees them.
Bound page size and response bytes. Return a next-page token only when more results are available, and allow a client to stop after a budget of pages or records. A malicious or buggy API returning the same cursor forever should not cause an infinite client loop.
Cache representations, not permissions
An ETag identifies a representation for validation. A client can send If-None-Match; a 304 response says its cached representation remains usable and does not carry a new representation body. Cache-Control determines freshness and reuse policy. no-cache requires validation before reuse; it does not prohibit storage. no-store requests that caches not store the response.
Personalized responses require deliberate cache policy. Do not put user-specific account data in a shared public cache. Vary makes the nominated request headers part of cache selection, but arbitrary high-cardinality headers can destroy cache efficiency. Prefer a clear boundary between public resources and authenticated responses, and test cache behavior with two distinct principals.
Retry only within a deadline
A timeout means the client did not observe completion. It does not prove that the server rolled back. An order creation may have committed before the response was lost. Retrying that POST without an idempotency contract can create a second order.
For retryable operations, use exponential backoff with jitter, a small attempt cap, and one overall deadline. Respect a valid Retry-After value when it fits that deadline. Propagate cancellation to underlying I/O so abandoned work does not continue consuming resources. Avoid stacking independent retry loops in a client, proxy, and service: three attempts at each of three layers can become 27 backend attempts.
Test ambiguous failures
Create a fake transport that commits an operation and then drops the response. Verify that a retry with the same idempotency key returns the original outcome. Test a 429, a transient 503, an invalid cursor, repeated cursor tokens, and a 304 without a body. Track attempts and elapsed time in tests; a final success is insufficient if the client silently exceeded its budget.
Keep errors observable without logging secrets. Request identifiers, method, route template, status, and latency usually explain a failure better than dumping authorization headers or full response bodies.