← AI + Backend
Core Backend Fundamentals

API Design (REST vs RPC vs gRPC vs GraphQL)

Watch: "Good APIs Vs Bad APIs: 7 Tips for API Design" — ByteByteGo

An API is the contract between a service and everything that calls it: mobile apps, other microservices, partners, and now LLM agents that read your schema and generate calls to it. Interviewers probe API design because it is where small decisions become permanent. A field name, a pagination style, or a status code that you ship on day one is copied into client code you do not control, and changing it later means a breaking change and a migration measured in quarters. For a Backend or AI engineer the surface is wider than classic CRUD: model endpoints take seconds to minutes instead of milliseconds, stream partial results, cost money per call, and are retried by clients that cannot tell whether the first attempt already spent the tokens.

This page covers the fundamentals (style choice, resource modelling, pagination, idempotency, errors, versioning, async and streaming) and then applies them to an LLM endpoint. For the wire-level basics see HTTP fundamentals, and for the protocol trade-off see REST vs gRPC between microservices.

What Is API Design?

An API (Application Programming Interface) is the set of rules that lets one piece of software ask another piece of software to do something —€” like asking a server to fetch a user's order history or process a payment. API design is the practice of deciding how those requests and responses should look: what to name things, how to structure the data, what happens when something goes wrong, and how the contract can change over time without breaking every app that depends on it.

Where This Is Used

A Day-to-Day Example

Think of a restaurant menu. The menu does not show you how the kitchen cooks each dish —€” it just tells you what you can order, what it costs, and what you will get back. You place an order (a request), the kitchen does the work out of sight, and a waiter brings you a plate (the response) in a predictable, described format. A well-designed API is like a clear, well-organized menu: it tells any client exactly what it can ask for and what it will get back, without exposing how the kitchen actually works.

Deep Dive: REST vs RPC vs gRPC vs GraphQL, and Resource Modelling

Modelling orders as resources/v1/orders/{id}POST · GET ?status=opencreate · list + filterPOST /{id}/cancelPOST /{id}/refundsa resource with id + stateGET · PATCHbad: POST /v1/getOrderAndItemsAndUser (verb in path, no reuse, uncacheable)
Nouns in the path, verbs as HTTP methods. Actions that aren't CRUD become sub-resources; a refund is better as its own resource with an id and state than as a verb. The comparison of API styles is below.
resourcesactionanti-pattern
Style     Unit of design        Transport / format            Strong at                    Painful at
--------  --------------------  ----------------------------  ---------------------------  ----------------------------
REST      resource (noun)       HTTP + JSON (usually)         caching, tooling, public     chatty for aggregated UIs,
          GET /orders/42                                        APIs, debuggability          actions that are not CRUD
RPC/JSON  action (verb)         HTTP POST + JSON              "do X" operations            no uniform semantics, no
          POST /rerank                                                                         built-in cacheability
gRPC      service + method      HTTP/2 + protobuf             internal calls, typed        browsers need grpc-web,
          Rerank(req) -> resp   (binary), streaming            stubs, streaming, small      hard to curl and to cache
                                                              payloads                     at the HTTP layer
GraphQL   typed query graph     usually one POST endpoint     UIs that need many           N+1 resolvers, cost control,
          { user { orders } }   JSON                          entities in one round trip   HTTP caching is harder

Resource modelling, order service:
  GOOD   POST   /v1/orders                 create
         GET    /v1/orders/{id}            read one
         GET    /v1/orders?status=open     list + filter (with pagination)
         PATCH  /v1/orders/{id}            partial update
         POST   /v1/orders/{id}/cancel     action that is not CRUD: model as a sub-resource verb, or
         POST   /v1/orders/{id}/refunds    ... better, create a "refund" resource so it has an id + state
  BAD    POST   /v1/getOrderAndItemsAndUser   (RPC verb in the path, no reuse, uncacheable)

REST is not "JSON over HTTP"; it is a way to model the domain as addressable resources and use the uniform HTTP verbs so that intermediaries (CDNs, proxies, browsers) can reason about the traffic without knowing your domain. The verb semantics are the real value: GET is safe and cacheable, PUT and DELETE are idempotent by definition, POST is neither, and PATCH is not guaranteed idempotent. When an operation does not fit CRUD (cancel, retry, rerank, run inference), do not contort it. Either model the result as a resource (a refund, a job, a completion with an id) or accept an RPC-shaped POST honestly. Most model-serving endpoints are RPC-shaped because "run this model on this input" is an action, not a noun.

Choose by who the caller is. Public or partner APIs favour REST because every language and tool speaks it, and you cannot make strangers install your protobuf toolchain. Internal service-to-service traffic favours gRPC when you want generated typed clients, small binary payloads, deadlines and bidirectional streaming (many model-serving frameworks expose gRPC for exactly this reason). GraphQL earns its cost when one screen aggregates many entities and mobile bandwidth matters, but it moves control to the client: you now need query depth and cost limits, dataloader batching against N+1 queries, and often persisted queries, because one HTTP endpoint gives you little to rate-limit or cache on. Real systems mix them: gRPC inside, REST at the edge, sometimes GraphQL as a backend-for-frontend.

Deep Dive: Pagination, Offset vs Cursor

Offset versus cursor paginationpage 2, limit 4, newest firstOFFSET 41211109876543reads + discards 4 rows first: O(offset + limit)after id 91211109876543WHERE id < 9: index seek, O(log n + limit), stable under insertsa new row at the top shifts every offset page (duplicates / skips);a cursor is a value, not a count, so it stays put
Offset pagination walks and throws away every earlier row, and inserts shift pages under the reader. A cursor remembers the last id seen and seeks straight to it. The SQL and a tie-breaker for non-unique sort keys are below.
offset pageskipped workcursor page
Feed sorted newest-first. Rows change while the client pages.

OFFSET / LIMIT                                   CURSOR (keyset)
GET /posts?offset=4&limit=4                      GET /posts?limit=4&after=eyJpZCI6N30   (opaque)
SQL: ORDER BY id DESC LIMIT 4 OFFSET 4           SQL: WHERE id < 7 ORDER BY id DESC LIMIT 4
  engine reads and discards 4 rows first           engine seeks the index to id=7, reads 4 rows
  cost grows with the offset: O(offset+limit)      cost ~ O(log n + limit), independent of depth
  new rows shift every later page: duplicates      position is a VALUE, not a count: stable
  or skips; deletes cause skips                    under inserts and deletes
  can jump to "page 50", cheap total count         no jump-to-page; total count is a separate query

Illustrative cost (assume no better index tricks): OFFSET 900000 LIMIT 20 walks about
900,020 index entries to return 20; the cursor form walks about 20.
Sort on a non-unique column (created_at)? Add a unique tie-breaker:
  ORDER BY created_at DESC, id DESC   and   WHERE (created_at, id) < (:ts, :id)

Offset pagination is a promise about position ("skip the first N rows"), and position is exactly what changes when the data is live. If two posts arrive between page 1 and page 2, every row shifts down by two and the client sees the last two rows of page 1 again. If rows are deleted, the client silently skips some. It is also slower by construction: the database generally cannot jump to row N, it has to step over the N rows it then throws away, so the cost of a deep page grows with the offset. Keyset (cursor) pagination replaces "skip N" with "give me rows after this key", which is stable under writes and lets the database use an index seek. The price is real: no random access to page 50, no cheap total count, and the sort key must be a total order, which is why a non-unique sort column needs an id tie-breaker.

Return the cursor as an opaque token (for example base64 of the sort key values) plus next_cursor: null when done, so you can change its internals later without breaking clients; never let clients construct cursors. Offset is still fine for small, mostly static lists and admin tables where users really do jump to page N. Cap limit on the server, and for search or ranked results, where the order is a relevance score that can shift between requests, say plainly that you either snapshot the result set behind a server-side cursor or accept some drift. The demo below runs against an in-memory SQLite database.

# Offset vs cursor pagination while new rows arrive (newest-first feed)
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
db.executemany("INSERT INTO posts VALUES (?, ?)", [(i, f"post-{i}") for i in range(1, 11)])  # ids 1..10

def page_offset(offset, limit=4):
    q = "SELECT id FROM posts ORDER BY id DESC LIMIT ? OFFSET ?"
    return [r[0] for r in db.execute(q, (limit, offset))]

def page_cursor(after_id, limit=4):
    if after_id is None:
        q, args = "SELECT id FROM posts ORDER BY id DESC LIMIT ?", (limit,)
    else:
        q, args = "SELECT id FROM posts WHERE id < ? ORDER BY id DESC LIMIT ?", (after_id, limit)
    rows = [r[0] for r in db.execute(q, args)]
    return rows, (rows[-1] if rows else None)

print("offset page 1:", page_offset(0))
db.executemany("INSERT INTO posts VALUES (?, ?)", [(11, "x"), (12, "y")])   # 2 new posts arrive
print("offset page 2:", page_offset(4), " <- 8 and 7 are repeated")

db.execute("DELETE FROM posts WHERE id IN (11, 12)")                          # reset
rows, cur = page_cursor(None)
print("cursor page 1:", rows, "next_cursor =", cur)
db.executemany("INSERT INTO posts VALUES (?, ?)", [(11, "x"), (12, "y")])
rows, cur = page_cursor(cur)
print("cursor page 2:", rows, "next_cursor =", cur, " <- no repeats, no gaps")
# Real output from python3:
# offset page 1: [10, 9, 8, 7]
# offset page 2: [8, 7, 6, 5]  <- 8 and 7 are repeated
# cursor page 1: [10, 9, 8, 7] next_cursor = 7
# cursor page 2: [6, 5, 4, 3] next_cursor = 3  <- no repeats, no gaps

Deep Dive: Idempotency, Errors and Safe Retries

Idempotency key makes a retry safeClientServerPOST /v1/charges, Idempotency-Key: k-1charge created, response saved under k-1✕201 lostretry: same key k-1saved response replayed, no 2nd charge
The client can't tell whether a timed-out request ran. With an idempotency key, the retry returns the saved response instead of charging twice. A different body with the same key gets 422; a concurrent duplicate gets 409. The status-code map is below.
clientserverlost
Client                    Network                    Server
  POST /v1/charges  --------------------------->  charge created (side effect happens)
  Idempotency-Key: k-1
                     <---- response lost / timeout ----
  (client cannot know whether it ran)
  POST /v1/charges  --------------------------->  key k-1 seen -> return the SAVED response,
  Idempotency-Key: k-1                            do NOT charge again

Server-side record per key:  (key, request fingerprint, state: in_progress | done, saved status+body, expiry)
  same key + same body        -> replay saved response
  same key + different body   -> 422 (client bug)
  same key while first still running -> 409, or wait (two concurrent requests must not both execute)

Status code map an interviewer expects:
  400 malformed      401 not authenticated   403 authenticated but not allowed   404 not found
  409 conflict       422 semantically invalid  429 rate/quota limited (+ Retry-After)
  5xx server fault: 500 bug, 502/503/504 upstream or overload  -> retryable with backoff + jitter
  4xx (except 408/429) generally NOT retryable: the same request will fail the same way

Networks make "did it run?" unanswerable: a timeout means the client does not know if the server acted, so a well-behaved client retries, and without protection the retry charges the customer twice or generates the completion twice (and pays for the tokens twice). HTTP already gives you idempotent methods (GET, PUT, DELETE), but the important operations are POSTs, so the widely used convention is a client-generated Idempotency-Key header (Stripe popularised it; an IETF draft describes it). The server stores the result under that key and replays it. Two details separate a good answer from a naive one: the key must be bound to a fingerprint of the request body so reuse with different content is rejected, and the "check then execute" step must be atomic (a unique constraint or an insert-if-absent), otherwise two concurrent retries both pass the check. This gives at-least-once delivery from the client plus deduplication at the server, which is effectively-once behaviour; it is not exactly-once delivery. See Idempotency and exactly-once semantics.

The error model matters as much as the success model, because clients write control flow against it. Use the HTTP status for the class of failure, and put a stable, machine-readable code in a consistent body (for example {"error": {"type": "rate_limit", "code": "tokens_per_minute", "message": "...", "request_id": "req_123", "retry_after_s": 12}}; RFC 9457 application/problem+json is the standard shape). Never make clients parse the human message. Include a request id you also log, so a customer can quote it and you can find the trace. Distinguish retryable from non-retryable explicitly, or clients will retry validation errors forever and turn a small incident into a retry storm. See rate limiting for the 429 side.

Deep Dive: Long-Running Work and Streaming (202 + Polling, Webhooks, SSE)

Async job versus streamingA: async job + polling (or webhook)ClientAPIPOST /v1/jobs202, Location: /v1/jobs/j_9GET /v1/jobs/j_9runningGET /v1/jobs/j_9succeeded + result_urlClientAPIPOST /v1/chat stream200 text/event-streamtoken "Hel"token "lo"done + usageB: streaming (SSE)Little's law: 50 req/s × 20 s each = 1,000 open connections
For long work the API either accepts a job (202) that the client polls or gets a webhook for, or streams tokens back on the same request. Holding synchronous connections open for 20-second generations quickly adds up to thousands of them. The full messages are below.
clientserver
Pattern A: async job (202 Accepted)                Pattern B: streaming (SSE) on the same request
  POST /v1/jobs   (+ Idempotency-Key)                POST /v1/chat  {"stream": true}
  <- 202 Accepted                                    <- 200, Content-Type: text/event-stream
     Location: /v1/jobs/j_9                             id: 0  event: token  data: {"text":"Hel"}
     Retry-After: 2                                     id: 1  event: token  data: {"text":"lo"}
  GET /v1/jobs/j_9 -> 200 {"state":"running"}          ...
  GET /v1/jobs/j_9 -> 200 {"state":"succeeded",        event: done  data: {"finish_reason":"stop","usage":{...}}
                          "result_url": "..."}
  or webhook: POST customer.example/hook  (signed, retried, at-least-once)

Little's law, ILLUSTRATIVE (assume 50 requests/s, each generation runs 20 s):
  concurrent open requests = arrival rate x duration = 50 x 20 = 1,000 held connections
  A sync request/response API with a 30 s gateway timeout is already close to the edge.

Anything that can outlast a normal HTTP timeout (batch inference, fine-tuning, document processing, video generation) should not be a synchronous call: the load balancer, the client, or a proxy in between will cut the connection, and a retry then starts the work again. The async pattern returns 202 Accepted immediately with a job resource, and the client either polls (simple, works everywhere, wastes some requests, so honour a Retry-After hint) or receives a webhook (efficient, but you now own delivery: sign the payload with an HMAC and a timestamp, retry with backoff, include an event id, and document that delivery is at-least-once so receivers must deduplicate). Make job creation itself idempotent, give the job a small explicit state machine (queued, running, succeeded, failed, cancelled), and offer a cancel endpoint, because an abandoned GPU job is real money. See synchronous vs asynchronous processing and message queues for the machinery behind the job.

For interactive LLM responses the user cares about time to first token, so the server streams tokens as they are decoded. Server-Sent Events (SSE) is the common choice: a normal HTTP response with Content-Type: text/event-stream, one-directional, text framed as field: value lines with a blank line between events, automatic reconnect and a Last-Event-ID mechanism in the browser EventSource (which itself supports only GET, so many LLM APIs use POST with fetch and read an SSE-formatted body). WebSockets are needed only when the client must also send a live stream, such as voice. Streaming has sharp edges you should name: the HTTP status is committed as 200 before generation finishes, so a mid-stream failure must be signalled with an in-band error event; reverse proxies may buffer the stream unless configured not to (for nginx, proxy_buffering off or an X-Accel-Buffering: no header); HTTP/1.1 browsers cap connections per host, which HTTP/2 multiplexing relieves; and when the client disconnects the server should notice and cancel decoding so the GPU stops working for nobody. The final event should carry finish_reason and token usage so billing and clients can reconcile. See streaming responses.

Deep Dive: Versioning, Compatibility, and Shaping an LLM Endpoint

Non-breaking (additive)                        Breaking
  new OPTIONAL request field                     remove or rename a field
  new response field                             change a field's type or units
  new endpoint                                   make an optional field required, tighten validation
  new error code in an existing class            change a default (temperature, max_tokens, sort order)
  new enum value*  (*breaks strict clients)      change semantics behind the same name

Versioning styles: URL (/v1/...)  |  header (Accept: application/vnd.x.v2+json)  |
                   date-pinned version per account (e.g. 2026-01-15)  |  schema evolution (protobuf field numbers)

LLM chat endpoint, the parts worth designing on purpose:
  request : model (pinned snapshot vs moving alias), messages, max_tokens, stream, stop, temperature/top_p,
            tools, user/tenant id, metadata          + Idempotency-Key header
  response: id, model (what actually served it), output, finish_reason (stop | length | tool_call | filter),
            usage {input_tokens, output_tokens}      + request id header
  limits  : per-tenant requests/min AND tokens/min, remaining/reset headers, 429 + Retry-After
  lifecycle: Deprecation and Sunset headers, a published model deprecation schedule

Compatibility is a discipline more than a mechanism. Design for the tolerant reader: clients ignore unknown fields, servers accept unknown optional ones, and you evolve only by adding. Once a contract is public, the cheapest correct move is almost always to add a new field or endpoint and deprecate the old one on a published schedule (the Deprecation and Sunset response headers exist for this) rather than to cut a new major version, because each live version is a codebase you must keep testing and securing. URL versioning is the most visible and easiest to route; date-pinned versions per account let a provider change behaviour without moving every customer at once; protobuf and similar schema formats build evolution rules into field numbers.

LLM endpoints add a category of change that no schema diff catches: the same request can return different text after a model update, and prompts tuned against one model snapshot can degrade on the next. So expose the model as an explicit parameter, prefer pinned snapshots for production callers with aliases only for experiments, echo back which model actually answered, and treat a model retirement as a breaking change with a notice period. Express limits in tokens as well as requests, since one request can be 100 tokens or 100,000, and return the remaining budget in headers so well-behaved clients can pace themselves. Per-tenant quotas belong at the gateway (see designing an LLM API gateway and authentication and authorization for tying keys to tenants).

Key Pointers

How It Works

  1. Identify the callers (public developers, internal services, a browser UI, agents) and pick the protocol accordingly, accepting that you may expose two styles over one domain model.
  2. List the domain nouns, give each a stable id and URL, and map operations to verbs; for actions that are not CRUD, create a resource that represents the action's outcome.
  3. For every collection endpoint define filtering, a server-enforced maximum page size, and a cursor-based pagination contract with an opaque token and a total order.
  4. For every side-effecting POST, accept an idempotency key; store (key, request fingerprint, status, response) atomically and replay on a repeat, reject on a mismatch.
  5. Classify latency: sub-second stays synchronous; seconds with a human waiting stream over SSE; minutes or more return 202 with a job and a poll or webhook completion path; always define cancel.
  6. Publish the error schema, rate-limit headers, versioning and deprecation policy, and contract tests, then only add to the contract; for model endpoints also pin the model version and expose usage.

Try It Yourself

Idempotency-key handling with a dict standing in for the database (a real one needs an atomic insert or unique constraint):

import hashlib, json
store = {}   # Idempotency-Key -> (request_fingerprint, status, body)
charges = []

def fingerprint(body): return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()[:8]

def post_charge(key, body):
    fp = fingerprint(body)
    if key in store:
        old_fp, status, resp = store[key]
        if old_fp != fp:
            return 422, {"error": "idempotency_key_reused_with_different_body"}
        return status, {**resp, "replayed": True}
    charges.append(body["amount"])                 # the side effect
    resp = {"charge_id": f"ch_{len(charges)}", "amount": body["amount"]}
    store[key] = (fp, 201, resp)
    return 201, resp

print(post_charge("k-1", {"amount": 500}))
print(post_charge("k-1", {"amount": 500}))     # client retry after a timeout
print(post_charge("k-1", {"amount": 900}))     # same key, different body
print(post_charge("k-2", {"amount": 500}))     # genuinely new request
print("side effects executed:", charges)
# Real output from python3:
# (201, {'charge_id': 'ch_1', 'amount': 500})
# (201, {'charge_id': 'ch_1', 'amount': 500, 'replayed': True})
# (422, {'error': 'idempotency_key_reused_with_different_body'})
# (201, {'charge_id': 'ch_2', 'amount': 500})
# side effects executed: [500, 500]

Server-Sent Events framing for a token stream, plus a minimal client-side parse:

import json
def sse_frames(tokens, request_id):
    for i, t in enumerate(tokens):
        yield f"id: {i}\nevent: token\ndata: {json.dumps({'text': t})}\n\n"
    yield f"event: done\ndata: {json.dumps({'request_id': request_id, 'finish_reason': 'stop', 'output_tokens': len(tokens)})}\n\n"

for frame in sse_frames(["Hel", "lo", "!"], "req_42"):
    print(repr(frame))
# Client-side parse: split on blank line, read "field: value" lines
buf = "".join(sse_frames(["Hel", "lo", "!"], "req_42"))
events = []
for block in buf.split("\n\n"):
    if not block: continue
    ev = {}
    for line in block.split("\n"):
        k, v = line.split(": ", 1); ev[k] = v
    events.append(ev)
text = "".join(json.loads(e["data"])["text"] for e in events if e["event"] == "token")
print("assembled:", text, "| last event:", events[-1]["event"])
# Real output from python3:
# 'id: 0\nevent: token\ndata: {"text": "Hel"}\n\n'
# 'id: 1\nevent: token\ndata: {"text": "lo"}\n\n'
# 'id: 2\nevent: token\ndata: {"text": "!"}\n\n'
# 'event: done\ndata: {"request_id": "req_42", "finish_reason": "stop", "output_tokens": 3}\n\n'
# assembled: Hello! | last event: done

The offset-versus-cursor demo above is also runnable as-is (it uses only sqlite3 from the standard library). To test real streaming against a server you would run one locally with a framework, which is not shown here because it needs a third-party package.

Interview Q&A

Q: REST, gRPC or GraphQL for a new API?

A: I decide by caller and traffic shape. A public or partner API defaults to REST because it is universally supported, debuggable with curl, and cacheable by CDNs; internal service-to-service calls with strict latency budgets or streaming get gRPC for typed stubs, deadlines and compact protobuf; a UI that stitches many entities on one screen may justify GraphQL, but I then own query-cost limits, N+1 batching and weaker HTTP caching. I usually end up with gRPC inside and REST at the edge rather than one style everywhere.

Q: Offset or cursor pagination, and why?

A: For anything live or large I use cursor (keyset) pagination: it is stable while rows are inserted and deleted, and the database seeks an index instead of scanning and discarding N rows, so page 10,000 costs about the same as page 1. The cost is that clients cannot jump to an arbitrary page and a total count needs a separate query. Offset remains reasonable for small static lists such as an admin table, where random page access matters more than stability.

Q: What does idempotent mean, and which HTTP methods are?

A: An operation is idempotent if performing it once or many times leaves the server in the same state; by HTTP semantics GET, HEAD, PUT, DELETE and OPTIONS are, while POST is not and PATCH is not guaranteed. That is a contract the server must actually honour, and it says nothing about responses being identical (a second DELETE may return 404). To make a POST retry-safe you add an idempotency key with server-side deduplication.

Q: How would you design the API for an LLM generation that takes up to 60 seconds?

A: If a person is watching, stream tokens over SSE so time to first token is short and the connection is doing visible work; set gateway and proxy timeouts to cover the worst case and disable proxy buffering for that route. If the caller is a batch job or the work can exceed the gateway timeout, return 202 with a job id, let the client poll with Retry-After or register a signed webhook, and support cancel. In both cases require an idempotency key so a retry does not pay for the generation twice.

Q: SSE or WebSockets for streaming model output?

A: SSE, in the common case, because generation is one-directional server-to-client, it is plain HTTP that passes through proxies, load balancers and auth middleware, and it has framing and reconnect semantics built in. WebSockets are worth their extra operational weight (stateful connections, harder load balancing and auth) when the client also streams continuously, for example live audio. SSE's costs are text-only framing and, in browsers, EventSource limited to GET without custom headers, so POST-based APIs read the stream with fetch.

Q: How do you version an API, and what counts as a breaking change?

A: I keep one version as long as possible by making only additive changes (new optional fields, new endpoints) and requiring clients to ignore unknown fields. Removing or renaming a field, changing a type or unit, tightening validation, or changing a default is breaking and needs a new version or a long deprecation with Deprecation/Sunset headers and usage telemetry showing who still calls the old path. Each supported major version is a maintenance cost, so I prefer fewer versions and longer additive evolution.

Q: How should errors be modelled?

A: Use the HTTP status for the class (401 vs 403, 404, 409, 422, 429, 5xx) and a JSON body with a stable machine-readable code, a human message, the offending parameter if any, and a request id that ties to logs and traces. Make retryability explicit: 429 and 5xx retry with exponential backoff and jitter, most other 4xx do not, because the same request will fail again and blind retries amplify incidents. Never require clients to match on message text.

Q: The client disconnects halfway through a streamed completion. What should the server do?

A: Detect the closed connection and cancel generation so the GPU frees its batch slot and KV cache instead of decoding tokens nobody reads, which matters when a single stream can hold accelerator memory for tens of seconds. Decide and document billing for the partial output (generally you have already spent the compute, so many providers count tokens produced), and log usage. The trap is that some proxies keep the upstream connection open after the client leaves, so cancellation has to be verified end to end.

Follow-up: Two retries with the same idempotency key arrive at the same time. What happens?

A: Both must not execute. The server inserts the key row with state in_progress using a unique constraint or an atomic set-if-absent; the loser sees the existing row and either returns 409 (the client retries shortly) or waits for the first to finish and replays its result. You also need an expiry on the key and a way to recover if the first worker crashed while in_progress, typically a lease timeout, and downstream side effects still need to be idempotent on their own.

Follow-up: You already sent a 200 and started streaming, then the model server fails. How do you report it?

A: You cannot change the status code, so the failure must be in-band: emit an error event with a code and request id, then close the stream, and make sure clients treat a stream that ends without a terminal done event as incomplete. Validate what you can before the first byte (auth, quota, input size, model availability) so most failures still surface as a proper 4xx or 503. For resumability, event ids let a client reconnect with Last-Event-ID, but replaying generation requires you to have buffered or persisted the tokens, which is extra state you must justify.

Follow-up: How do you paginate search results ordered by relevance score?

A: Scores are computed per query and can shift as the index updates, so a plain keyset on score is not stable. Options are a keyset on (score, id) if scores are deterministic for the request, a server-side cursor or snapshot with a short TTL for a consistent result set, or search_after-style token support in the search engine. Because deep pages of ranked results are rarely useful and expensive, many APIs cap the total depth and document that results can drift between pages.

System Design Tie-in

In most system designs the API section is where you show you have thought about failure and growth: name the style per boundary (REST at the public edge, gRPC between services), give every list endpoint cursor pagination with a maximum page size, and give every money-spending POST an idempotency key with a stated retention window. For an LLM-serving API also state the latency model: with generation of a few hundred tokens taking seconds, use streaming for interactive traffic and 202 plus polling or webhooks for batch, and size concurrency with Little's law. As an illustrative estimate, assume 1,000 requests/s at peak, each streaming for 10 s on average: that is about 10,000 concurrent open connections, which drives the choice of an event-loop or async gateway, connection limits, and idle timeouts, and it means the gateway rather than the model server often enforces per-tenant quotas in requests and tokens. Note the API versioning and model-pinning policy, the deprecation process, the error and rate-limit headers, and how request ids flow into distributed tracing. Mention that queue-backed job endpoints are at-least-once, so consumers and webhooks must deduplicate by id.

Trade-offs

Every API decision spends flexibility to buy predictability. REST buys cacheability and universal tooling and pays with chatty aggregation and awkward non-CRUD actions; GraphQL and gRPC fix different halves of that and move complexity to the server (query cost control) or to the client toolchain (protobuf, grpc-web). Cursor pagination buys stability and constant-time deep pages and pays with lost random access and no cheap total. Idempotency keys buy safe retries and pay with storage, atomicity requirements and retention policy, and they only cover the API boundary, not side effects beyond it. Async jobs and webhooks buy robustness for long work and pay with a state machine, at-least-once delivery, and clients that must now handle polling or callbacks; streaming buys perceived speed and pays with proxy configuration, in-band errors and cancellation handling. Versioning buys freedom to break things and pays with permanent maintenance of old versions. The wrong choice is usually over-engineering: GraphQL for a three-endpoint internal tool, webhooks for a job that finishes in 200 ms, or cursors for a 50-row admin list. Match the mechanism to the actual caller, latency and failure profile, and revisit it when those change.