TRT
Sign in
Networking

Designing APIs Around Explicit Semantics

An API contract defines the meaning of operations, inputs, outputs, errors, and side effects.

The Runtime Theory Team5 min read#http#api#versioning

An API contract is a set of promises about behavior, not only about shapes. A schema tells a client which fields exist; the contract tells it what happens when it sends the same request twice, what a 409 means versus a 422, and whether an order of results is guaranteed. Clients do not read your documentation at 3 a.m. — they read your status codes and your retry behavior. Everything they infer from those becomes the real contract, whether or not you wrote it down.

What the contract actually specifies

HTTP gives you coarse intent for free:

  • Safe methods (GET, HEAD, OPTIONS) promise no server-side state change. A client can prefetch them, replay them, and send them through a proxy without thinking.
  • Idempotent methods (GET, PUT, DELETE, HEAD, OPTIONS) promise that applying the request twice has the same effect as applying it once. POST is neither.

Everything resource-specific is yours to define: which fields are required, what preconditions you honor (If-Match for optimistic concurrency), what ordering a list guarantees, what the error taxonomy looks like, and what the side effects of an operation are beyond the resource it names.

Two implementations of the same schema can have wildly different semantics:

text
PUT /invoices/inv_42   replace the entire resource with the body; repeat = same state
PATCH /invoices/inv_42 merge the fields you sent; repeat = same state
POST /invoices         create a NEW resource every time; repeat = N resources

The trap is that clients learn semantics from observation. If GET /orders returns newest-first today and someone "optimizes" it to sort by primary key, every client with offset pagination silently starts duplicating and skipping rows. Undocumented behavior becomes de-facto contract the moment the first client depends on it.

Idempotency: making POST safe to retry

The classic failure: the client sends POST /invoices, your server creates the invoice, and the response is lost — the client's 500 ms timeout fired 200 ms after the work committed. The client retries. You now have two invoices for one purchase.

The fix is an idempotency key: the client generates a unique identifier once per logical operation and sends it on every attempt of that operation.

http
POST /v1/invoices HTTP/1.1
Host: api.example.com
Idempotency-Key: 3f6c1d0e-8a41-4c3f-9d2b-7f1a5e0b9c11
Content-Type: application/json
 
{"customer":"cus_812","amount_cents":4999}
go
key := r.Header.Get("Idempotency-Key")
prev, err := store.Get(ctx, key)
if err == nil && prev.RequestFingerprint == fingerprint(r.Body) {
    // Replay the stored result instead of doing the work twice.
    w.WriteHeader(prev.Status)
    w.Write(prev.ResponseBody)
    return
}
if err == nil && prev.InFlight {
    w.WriteHeader(409) // same key, concurrent attempt: tell the client to retry
    return
}
// Claim the key, run the work, store status + body, then release.

Four details decide whether this actually works:

  1. Persist the response, not only a "seen" flag — a client that lost the response needs the same body back, including the created resource's ID.
  2. Fingerprint the request body. Reusing one key with a different body is a client bug; answer it with 422 rather than silently returning the first request's result.
  3. Make claiming atomic — a unique constraint or a short-lived lock, so two concurrent retries collapse into one execution instead of both passing the check.
  4. Retain keys at least as long as clients retry. 24 hours covers essentially every retry loop that exists; shorter retention re-opens the duplicate window.

See idempotency keys in depth and the idempotency trace for the full flow.

Errors are part of the contract too

A client's retry policy is a function of two things: the status class and whether the operation is idempotent. If your API collapses everything into 400 Bad Request or 500 Internal Server Error, clients either retry forever or never retry — both wrong.

StatusMeaningClient should
400 / 422request will never succeed as writtenfix and resend, no retry
401not authenticatedrefresh credentials, then retry
403authenticated, not authorizeddo not retry
404resource unknown (or hidden)stop, surface to user
409conflict with current statere-read state, then retry
429rate limitedretry after Retry-After seconds
503 / 504transient, try laterretry with backoff and jitter

Retry storms are what happen when this is vague. Ten thousand clients that all wait a flat 500 ms after an outage ends deliver roughly 20,000 requests inside one second — enough to knock the recovering service back down. The standard mitigation is full jitter, which spreads the herd instead of synchronizing it:

text
delay = random_between(0, min(cap, base * 2^attempt))
# base 100ms, cap 30s, attempt counting from 0

Your side matters too: return Retry-After on 429 and 503, and put a request ID in every error body so a support ticket can be tied to one server log line.

What you can change without a new version

Backward compatibility is about behavior as much as field names. The usual breakers:

  • Renaming or removing a response field, or changing its type (42 to "42").
  • Adding a response field — safe for parsers that ignore unknown keys, fatal for strict decoders (Go's DisallowUnknownFields, strict TypeScript schemas).
  • Adding or removing an enum value. Strict clients reject values they don't recognize, so adding one can break them too.
  • Changing a default sort order, a default page size, or a default filter.
  • Tightening validation: requests that used to succeed now return 400, and old clients have no way to discover why.

Error code strings belong to the contract. Clients switch on "INSUFFICIENT_FUNDS"; if you rename it, their branch silently stops matching and falls through to generic handling.

Versioning only when policy says so

Version the surface only when a break is unavoidable, and pick the mechanism knowing how caching works:

  • URL versioning (/v2/users) — easy to spot in a log, cacheable by URL, simple to route and deprecate; the cost is two code paths living side by side.
  • Header negotiation (Accept: application/vnd.example.v2+json) — clean URLs, but every cache between you and the client must send Vary: Accept. Miss that once and a CDN serves v1 bodies to v2 clients, which produces a genuinely confusing incident.
  • Query parameter (?version=2) — trivial to implement, easy to lose in links and logs.

Whatever you choose, publish a deprecation policy with dates: send the Sunset header (RFC 8594) with a real date, keep the old version serving for 6–12 months, and measure traffic before removing anything. A good rule for retirement is old-version traffic below 1% of total for 30 consecutive days.

Where this bites in production

  • Retries without idempotency keys duplicate money movements; retries with keys and no body fingerprint hide client bugs. Both show up as support tickets, not as errors.
  • "We only changed the sort order" is the most common silent break in offset-paginated APIs.
  • A missing Vary: Accept turns header versioning into a cache-poisoning bug across every CDN you use.
  • Every undocumented default is a contract you signed without reading it. Write down the semantics that clients will otherwise infer — ordering, retries, errors, side effects — because they will infer them anyway.

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save