API design & HTTP semantics
Questions in this set 10
- 01Design a REST API for a resource. What makes it "good"?
- 02PUT vs PATCH vs POST, and what do "safe" and "idempotent" mean?
- 03How do you make a non-idempotent endpoint safe to retry?
- 04Which status codes do you actually use, and what is the difference between 401 and 403?
- 05What does a good error response look like?
- 06REST vs GraphQL vs gRPC — pick one and defend it.
- 07How do you paginate? Why is OFFSET a problem?
- 08How do you version an API?
- 09How do you rate limit an API?
- 10How do you handle long-running work in an HTTP API?
Design a REST API for a resource. What makes it "good"?
Nouns for resources, verbs from HTTP, and consistency everywhere:
GET /v1/orders?status=paid&limit=50&cursor=… list (filter, paginate)
POST /v1/orders create -> 201 + Location
GET /v1/orders/{id} read
PATCH /v1/orders/{id} partial update
DELETE /v1/orders/{id} delete -> 204
POST /v1/orders/{id}/refunds an action modelled as a sub-resourceGood means: plural nouns, no verbs in paths, filtering/sorting/pagination in the query string, consistent envelope and error shape, versioning in the path, and every write idempotent or protected by an idempotency key. Actions that are not CRUD (/cancel, /refund) are modelled as sub-resources or a POST /orders/{id}/actions — do not contort them into PUT.
PUT vs PATCH vs POST, and what do "safe" and "idempotent" mean?
Safe = no side effects (GET, HEAD, OPTIONS). Idempotent = doing it N times leaves the same state as doing it once (GET, PUT, DELETE, and by convention nothing else).
POST— create or "do something"; not idempotent.PUT— replace the whole resource at a known URL; idempotent.PATCH— partial update; idempotent only if you make it so (a JSON Merge Patch usually is;{"op": "increment"}is not).
Why it matters: proxies, load balancers and client libraries retry idempotent methods automatically. If your POST /charges is retried on a timeout, you double-charge someone.
How do you make a non-idempotent endpoint safe to retry?
Idempotency keys. The client generates a UUID per logical operation and sends it; the server stores the key with the result and returns the stored result on a repeat.
POST /v1/charges
Idempotency-Key: 9f1e… -- the key is the concurrency control: a unique index, not a SELECT-then-INSERT
INSERT INTO idempotency (key, request_hash, status)
VALUES ($1, $2, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING id;
-- no row returned -> someone else owns it: return 409 if still running, or the stored responseDetails that separate a senior answer: store a hash of the request body and return 422 if the same key arrives with different parameters; give keys a TTL (24h is typical); scope the key to the API user; and make sure the write and the key record commit in the same transaction.
Which status codes do you actually use, and what is the difference between 401 and 403?
200 OK · 201 Created (+Location) · 202 Accepted (async work queued) · 204 No Content · 301/308 moved · 304 Not Modified · 400 malformed · 401 unauthenticated — no or bad credentials, and you must send WWW-Authenticate · 403 authenticated but not allowed · 404 not found (also used to hide existence from unauthorised users) · 409 conflict (version/state) · 410 gone · 422 semantically invalid · 429 rate limited (+Retry-After) · 500 our bug · 502/503/504 upstream/overloaded/timeout (+Retry-After).
The rule people get wrong: 5xx means we failed and the client may retry; 4xx means the client must change something before retrying. Returning 200 {"error": …} breaks every retry, monitoring and caching layer between you and the caller.
What does a good error response look like?
{
"type": "https://api.example.com/errors/insufficient-funds",
"title": "Insufficient funds",
"status": 422,
"detail": "Balance is 10.00 USD, charge is 25.00 USD.",
"instance": "/v1/charges/ch_123",
"code": "insufficient_funds",
"request_id": "req_01H…",
"errors": [{ "field": "amount", "message": "exceeds available balance" }]
}That is RFC 9457 (application/problem+json) plus a stable machine code and a request_id the user can quote to support. Rules: a stable code clients can branch on (never parse the human message), field-level detail for validation, never leak stack traces or SQL, and log the full detail server-side against the same request id.
REST vs GraphQL vs gRPC — pick one and defend it.
- REST/JSON: universal, cacheable by HTTP infrastructure, easy to debug. Costs: over/under-fetching, many round trips for nested data.
- GraphQL: one endpoint, the client selects exactly what it needs — excellent for many varied clients. Costs: HTTP caching is lost (everything is a POST to
/graphql), N+1 resolvers unless you use DataLoader, query-cost analysis is mandatory or a nested query becomes a DoS, and authorisation must be per-field. - gRPC: binary protobuf over HTTP/2, generated clients, streaming, low latency — the default for internal service-to-service. Costs: not browser-native (needs grpc-web/Connect), harder to inspect, schema deployment coupling.
A good answer picks by consumer: public API → REST; mobile + web + partner clients with divergent needs → GraphQL; internal microservices in the hot path → gRPC. Many real systems use all three at different layers.
How do you paginate? Why is OFFSET a problem?
LIMIT 20 OFFSET 100000 makes the database scan and discard 100,000 rows — cost grows with page number — and rows shift under the user as data changes, so items are skipped or duplicated.
Keyset (cursor) pagination:
SELECT * FROM orders
WHERE (created_at, id) < ($last_created_at, $last_id) -- tuple compare = stable tiebreak
ORDER BY created_at DESC, id DESC
LIMIT 20;Constant cost per page, stable under concurrent writes, and the cursor is opaque (base64 the tuple) so you can change the implementation. Offset is acceptable only for small, bounded, admin-facing lists that need arbitrary page jumps.
How do you version an API?
URL path versioning (/v1/) is the pragmatic default: visible, cacheable, trivially routable. Header/media-type versioning is purer but harder to debug and easy for clients to get wrong. Whatever you choose, the discipline matters more: additive changes only within a version (new optional fields, new endpoints), never remove or retype a field, never change the meaning of a value. Deprecate with Sunset/Deprecation headers, publish a timeline, and measure per-client usage so you know who still needs migrating. Consider date-based versions per client (Stripe's model) if you need to evolve fast without breaking anyone.
How do you rate limit an API?
Algorithms: fixed window (simple, but allows a 2x burst at the boundary), sliding window log/counter (accurate, more memory), token bucket (allows controlled bursts — the usual choice), leaky bucket (smooths output).
-- token bucket in Redis, atomic via Lua: read tokens+timestamp, refill by elapsed*rate,
-- allow if >= 1, decrement, write back with a TTL.Answer the design questions too: limit per API key and per IP (a shared key should not be starved by one tenant); return 429 with Retry-After and RateLimit-Limit/Remaining/Reset headers; apply limits at the edge (CDN/gateway) for cheap rejection and in the app for correctness; and have separate, tighter limits for expensive endpoints (search, export, login).
How do you handle long-running work in an HTTP API?
Return 202 Accepted with a job resource immediately, do the work in a queue, and let the client poll GET /jobs/{id} (with Retry-After) or receive a webhook/SSE when it completes. Never hold an HTTP connection open for minutes — proxies time out at 30-60 seconds, retries duplicate the work, and a deploy kills every in-flight request. For streaming results (an LLM response, a log tail), SSE or chunked responses are appropriate; for genuinely long jobs, the job resource pattern is the answer.