REST API Design — Resources, Versioning, Idempotency
Design resources, domain actions, errors and compatible evolution; Implement atomic operation replay and bounded total-order pagination; Describe OpenAPI, correct deprecation dates and patch semantics.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Design resources, domain actions, errors and compatible evolution
- Implement atomic operation replay and bounded total-order pagination
- Describe OpenAPI, correct deprecation dates and patch semantics
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 28 sections
Review boundary — 10 October 2026: The exact order/idempotency/keyset program executes on local SQLite. FastAPI transport and OpenAPI tooling are references, not run servers, generated SDKs or verified deployments. HTTP semantics use RFC 9110; deprecation uses RFC 9745, not the obsolete Boolean draft form.
Learning goal: Design orders/payments as resources and domain operations; choose compatible evolution; specify replay, pagination and errors; and make the contract inspectable through OpenAPI.
Why this session exists
An API must remain understandable when clients and servers deploy independently. Consistent names help, but durable identity, permissions, failure semantics and change policy do more work than URL aesthetics. This lesson's street-grid analogy is about predictability, not a claim that RPC works only at small scale or that noun-shaped URLs prove REST compliance.
You will be able to model resources/actions, compare URL/header/date/evolution strategies, design bounded total-order pagination, make retryable POSTs durable and inspect an OpenAPI contract used by documentation/client/mock tools.
Prerequisites
Know HTTP methods/statuses, JSON, SQL transactions and basic resource identity. A database row is not automatically the right public resource: expose the domain lifecycle, not internal storage layout.
Optional pre-reading — original resource pointers. Video labels below identify the earlier suggestions, not verified recordings or viewing times. Search-result links do not establish authorship; no video content is endorsed here. Choose what helps before the intuition section; these are not required to finish the exercises.
- Video search: REST in 100 Seconds.
- Video search: API Design Patterns — Idempotency, Pagination, Versioning.
- Video search: OpenAPI + Swagger End-to-End.
- Reading: Google API Design Guide — compare resource and method conventions with the lesson's domain-operation choices, rather than treating one style guide as the definition of REST.
- Reading: Stripe API Reference — look for errors, idempotent requests and pagination; keep provider-specific replay rules separate from a universal HTTP contract.
- Reading: Microsoft · Web API Design Best Practices — compare versioning, hypermedia and error design. OpenAPI can feed compatible docs/client/mock tools; the create-only example here is not proof those tools ran or that the API is production-complete.
(a) Intuition · 5 min
A resource is a named concept such as an order, payment or cancellation request. Representations describe it; methods act under a documented contract. DELETE /orders/42 does not inherently mean “refund and cancel”: deleting an association and cancelling a fulfilled business order are different operations. A cancellation can be a subordinate resource or a custom method such as POST /orders/42:cancel with an explicit state machine.
The design decisions that make or break an API
Name stable identities and grains; choose representations and media types; apply authorisation at every read/mutation; specify errors and conditional requests; bound lists; define retry identity; and publish an evolution/deprecation policy. An OpenAPI document describes an interface but does not prove those policies are implemented.
How the industry converged
Fielding's REST style constrains client/server separation, stateless interaction, caching, uniform interface and layered systems, with optional code-on-demand. The uniform interface includes resource identification, representation-based manipulation, self-descriptive messages and hypermedia-driven application state. A practical resource-oriented HTTP API may deliberately implement only part of this; call that scope honestly rather than dismissing hypermedia as irrelevant to REST's definition. The original vendor timelines and “all major APIs are level 2” claims are not acceptance evidence.
Resource tree for a real orders API
Figure text equivalent — labels and relationships
- Node “/v1/customers” (square).
- Node “/v1/customers/{cid}” (square).
- Node “/v1/customers/{cid}/orders” (square).
- Node “/v1/orders/{oid}” (square).
- Node “/v1/orders/{oid}/items” (square).
- Node “/v1/orders/{oid}/payments” (square).
- Node “POST /v1/orders/{oid}:cancel” (square).
- “/v1/customers” leads to “/v1/customers/{cid}”. Line: normal; ends: arrow point.
- “/v1/customers/{cid}” leads to “/v1/customers/{cid}/orders”. Line: normal; ends: arrow point.
- “/v1/customers/{cid}/orders” leads to “/v1/orders/{oid}”. Line: normal; ends: arrow point.
- “/v1/orders/{oid}” leads to “/v1/orders/{oid}/items”. Line: normal; ends: arrow point.
- “/v1/orders/{oid}” leads to “/v1/orders/{oid}/payments”. Line: normal; ends: arrow point.
- “/v1/orders/{oid}” leads to “POST /v1/orders/{oid}:cancel”. Line: normal; ends: arrow point.
Customer-scoped listing and an order's canonical identity need not be the same URI. An order belongs to a tenant/customer and may contain items; payments have their own states and failure/reversal rules. The colon custom-method style is a convention, not an HTTP requirement. Gateways still need explicit route/permission configuration; they do not infer business authorisation from a noun.
CRUD → HTTP mapping
Create: POST collection, often 201 plus Location and representation. Read: GET item, 200 or 404 subject to disclosure policy; cache only as allowed for the representation/user. List: GET with bounded filters, sort and pagination. Replace: PUT item, 201 on creation or 200/204 on replacement as appropriate. Partial update: PATCH with a specified patch media type and preconditions. Remove: DELETE, commonly 204 or 200, with a documented repeated-delete response. Async work may return 202 plus a status resource; it is not completed merely because accepted.
If-Match with a strong validator can reject stale writes with 412 rather than overwrite another user's changes. Idempotency and optimistic concurrency solve different problems: one deduplicates a logical operation, the other validates the base state it modifies.
Versioning strategies
- URL: an explicit
/v1/namespace is easy to route and log. It is a convention, not intrinsically forbidden by REST. Supporting two namespaces still requires implementation/testing. - Header/media type: a representation version can keep the URI stable; caches must vary on the relevant request field and tooling must expose the selected version.
- Query parameter: explicit but easy to omit; configure cache keys and defaults. It is not universally invalid for public APIs.
- Date-pinned: requests select an agreed snapshot policy. Stripe uses its version header; the original fabricated example date must not be treated as an actual released version.
- Compatible evolution: retain documented meaning, tolerate unknown fields where promised and make genuinely additive changes. New enum values, stricter validation, changed defaults or a new required request field can still break clients. Google APIs also use versions; evolution is not “Google never versions.”
A version selector and additive evolution can coexist. GitHub's REST API version header is not a /v3/ path merely because of historical naming. Avoid mixing selectors without clear precedence, rather than pretending successful providers always use exactly one mechanism.
Pagination models
Offset is simple and jumpable, but inserts/deletes before an offset can duplicate/skip entries and large offsets can require discarded work. Keyset pagination continues after a stable total order, such as (created_at, id) with a matching predicate and index. A timestamp alone loses rows tied at the boundary. A cursor is an opaque application token that may encode a keyset, filters and snapshot horizon; it is not necessarily keyset pagination and is not authorisation.
The program below uses a monotonic integer ID, a first-page upper bound, tenant scope and limit-plus-one lookahead. New IDs beyond the horizon do not enter the traversal; deletions disappear. This is not a database snapshot: payload changes can still be observed and permission changes must be reapplied. If stable historical membership/content is required, pin a retained database snapshot or export job. Arbitrary “jump to page N” is not inherent in a keyset cursor.
Time windows are useful for events, but boundary ties still need a total order. Validate cursor shape, version, scope, finite/ranged values and filter identity; Base64 is encoding, not secrecy or an integrity signature. A production token may be signed or stored server-side, but neither replaces access checks.
Errors and inspectable contracts
Choose one error envelope: stable machine code, safe human message, relevant field/path and correlation ID. RFC-style problem details or an application envelope can work; copying a Stripe-looking JSON object does not make it Stripe's exact schema. Framework validation, missing routes and internal errors must use the same declared policy. A helper never called by exception handlers does not establish consistency.
A single curl/HEAD request cannot prove REST statelessness or all cache semantics. Persistent resource state is permitted; REST's stateless interaction constraint concerns application session context between requests. A sensitive response can validly be non-cacheable. Absence of ETag is not proof that every REST constraint is absent.
Deriving safe contract evolution
Unknown clients may rely on observable details outside intended documentation. You cannot inspect server code alone to know every break. Define a documented compatibility envelope, test representative consumers and measure usage. For incompatible changes, retain an old contract during a published migration window or negotiate a planned break. This decouples deployments without promising to keep every old behaviour forever. Additive evolution reduces migration frequency only when clients and semantics support it.
Resources are nouns, endpoints are a vocabulary
Use predictable names, but accept legitimate domain operations instead of inventing meaningless pseudo-resources. Collections need bounds appropriate to their growth and use; errors and retry semantics are part of the vocabulary. Idempotent PUT or an explicitly idempotent cancel does not necessarily need an additional key, whereas create/payment operations with ambiguous outcomes need durable operation identity.
Separate resources, embed, or expand
Separate endpoints are easy to cache independently but can impose multiple round trips. Embedding a related object reduces calls for a screen but increases bytes/invalidation scope. Bounded expand/field-selection parameters can fit varied clients but require permission checks, query planning and cache-key variation. GraphQL or a backend-for-frontend is an alternative when that query surface becomes complex. Measure per-screen calls, payload sizes and backend work rather than asserting that every expansion destroys cache hit rate.
(c) Hands-on · 25 min
Durable operation identity and a total-order cursor
This exact fixture keeps the order and replay result in one SQLite transaction. An injected failure before commit rolls both back; after commit a retry retrieves the durable response. The caller supplies a trusted tenant only as a test assumption. External payment effects cannot be made atomic merely by this local transaction; use provider identity/outbox/recovery states for those.
# exact-program: orders_contract
import base64
import json
import sqlite3
def canonical(value):
return json.dumps(value, sort_keys=True, separators=(',', ':'), allow_nan=False)
def setup(db):
db.executescript('''
create table orders(id integer primary key autoincrement, tenant text not null,
payload text not null, state text not null);
create table operations(tenant text, kind text, key text, request text not null,
result text not null, primary key(tenant,kind,key));
create index orders_tenant_id on orders(tenant,id);
''')
def create(db, tenant, key, payload, crash=None):
if not tenant or not isinstance(key, str) or not 1 <= len(key) <= 128:
raise ValueError('tenant/key required')
if (not isinstance(payload, dict) or set(payload) != {'customer_id', 'items'} or
type(payload['customer_id']) is not int or payload['customer_id'] <= 0 or
not isinstance(payload['items'], list) or not 1 <= len(payload['items']) <= 100):
raise ValueError('invalid order')
for item in payload['items']:
if (not isinstance(item, dict) or set(item) != {'sku', 'qty'} or
not isinstance(item['sku'], str) or not 1 <= len(item['sku']) <= 50 or
type(item['qty']) is not int or not 1 <= item['qty'] <= 1000):
raise ValueError('invalid item')
request = canonical(payload)
db.execute('begin immediate')
try:
saved = db.execute('select request,result from operations '
'where tenant=? and kind=? and key=?',
(tenant, 'create-order', key)).fetchone()
if saved:
if saved[0] != request:
raise ValueError('idempotency conflict')
result = json.loads(saved[1])
else:
uid = db.execute('insert into orders(tenant,payload,state) values(?,?,?)',
(tenant, request, 'created')).lastrowid
result = {'status': 201, 'headers': {'Location': f'/v1/orders/{uid}'},
'body': {'id': uid, **payload, 'status': 'created'}}
db.execute('insert into operations values(?,?,?,?,?)',
(tenant, 'create-order', key, request, canonical(result)))
if crash == 'before':
raise RuntimeError('before commit')
db.commit()
except BaseException:
db.rollback()
raise
if crash == 'after':
raise RuntimeError('committed but response lost')
return result
def page(db, tenant, limit=20, cursor=None):
if type(limit) is not int or not 1 <= limit <= 100:
raise ValueError('invalid limit')
if cursor:
try:
value = json.loads(base64.b64decode(cursor, altchars=b'-_', validate=True))
if (not isinstance(value, list) or len(value) != 4 or value[0] != 1 or
value[1] != tenant or type(value[2]) is not int or type(value[3]) is not int or
not 0 <= value[2] <= value[3]):
raise ValueError('invalid cursor')
after, horizon = value[2:]
except Exception as error:
raise ValueError('invalid cursor') from error
else:
after = 0
horizon = db.execute('select coalesce(max(id),0) from orders where tenant=?',
(tenant,)).fetchone()[0]
rows = db.execute('select id,payload,state from orders '
'where tenant=? and id>? and id<=? order by id limit ?',
(tenant, after, horizon, limit + 1)).fetchall()
more = len(rows) > limit; selected = rows[:limit]
token = (base64.urlsafe_b64encode(canonical([1,tenant,selected[-1][0],horizon]).encode()).decode()
if more else None)
return {'data': [{'id': uid, **json.loads(payload), 'status': state}
for uid, payload, state in selected],
'next_cursor': token, 'has_more': more}
def cancel(db, tenant, uid):
with db:
row = db.execute('select state from orders where tenant=? and id=?', (tenant,uid)).fetchone()
if row is None:
raise KeyError('not found in tenant')
if row[0] not in ('created', 'cancelled'):
raise ValueError('cancellation not allowed')
db.execute("update orders set state='cancelled' where tenant=? and id=?", (tenant,uid))
return {'id': uid, 'status': 'cancelled'}
if __name__ == '__main__':
db = sqlite3.connect(':memory:'); setup(db)
payload = {'customer_id': 42, 'items': [{'sku': 'A1', 'qty': 2}]}
try:
create(db, 't1', 'operation-1', payload, crash='after')
except RuntimeError:
pass
result = create(db, 't1', 'operation-1', payload)
assert result['status'] == 201 and result['body']['id'] == 1
assert db.execute('select count(*) from orders').fetchone()[0] == 1
for k in range(2, 5):
create(db, 't1', f'operation-{k}', payload)
first = page(db, 't1', 2)
create(db, 't1', 'late', payload)
second = page(db, 't1', 2, first['next_cursor'])
assert [x['id'] for x in second['data']] == [3, 4] and not second['has_more']
assert cancel(db, 't1', 1) == cancel(db, 't1', 1)
print(result, first, second)
db.close()The independent checks reconstruct expected state, exercise before/after-commit crashes, same-key different-payload rejection, tenant separation, pagination after insert/delete and exact final-page detection. The example retains operation records for its entire fixture lifetime; production must publish a replay horizon and prevent expired-key reuse from silently duplicating an unsettled operation. The page length is bounded, but the core does not bound token size or every integer; the companion below supplies those input checks. The token is unsigned and not secret. The accepting API must derive tenant/permissions from authentication.
A bounded cursor and consistent error boundary
The core's page validates the basic tuple but is not the whole public-input boundary: Python regards True == 1, an unbounded integer can overflow SQLite's signed 64-bit bind, and arbitrarily long Base64 input consumes memory before rejection. The companion below validates those cases before calling the exact core. Keep the 2,048-character token bound aligned with a bounded tenant ID (here 64 characters). The cursor remains unsigned: a caller can forge an in-tenant horizon, so it is never proof of snapshot identity or permission. If the API promises a server-chosen horizon, sign or store that state.
# exact-function: bounded_page (after orders_contract)
def bounded_page(db, tenant, limit=20, cursor=None):
if not isinstance(tenant, str) or not 1 <= len(tenant) <= 64:
raise ValueError('invalid tenant')
if cursor is not None:
if not isinstance(cursor, str) or not 1 <= len(cursor) <= 2048:
raise ValueError('invalid cursor')
try:
value = json.loads(base64.b64decode(cursor, altchars=b'-_', validate=True))
except (ValueError, UnicodeError, RecursionError) as error:
raise ValueError('invalid cursor') from error
if (not isinstance(value, list) or len(value) != 4 or
type(value[0]) is not int or value[0] != 1 or value[1] != tenant or
type(value[2]) is not int or type(value[3]) is not int or
not 0 <= value[2] <= value[3] <= 2**63 - 1):
raise ValueError('invalid cursor')
return page(db, tenant, limit, cursor)For IDs 1–4 and limit 2, page one returns 1,2 and a horizon of 4. Insert 5 and delete 3 before page two: it returns 4 with has_more=False. Update order 4's state before that read and the new state is visible. The horizon stabilises the upper insertion boundary, not content or historical membership. Reapplying tenant permission on each page prevents a valid old token from preserving access after revocation. When adding filters/sort orders, bind their canonical identity and a cursor version as well; this core has only a tenant filter and ascending ID order.
The following in-memory list adapter demonstrates a single error envelope on one route. identity is a trusted test object supplied by an upstream authenticator, not a JSON field accepted from the caller. Authentication precedes authorisation and cursor decoding. The request ID below represents a server-generated safe correlation ID; production middleware must generate/validate it, not trust arbitrary log text from a header.
# exact-function: list_response (after bounded_page)
def list_response(db, identity, limit=20, cursor=None, request_id='fixture-1'):
def failure(status, code, message, field=None, extra=None):
return {'status': status,
'headers': {'Cache-Control': 'no-store', **(extra or {})},
'body': {'error': {'code': code, 'message': message,
'field': field, 'request_id': request_id}}}
if identity is None:
return failure(401, 'unauthenticated', 'Authentication required',
extra={'WWW-Authenticate': 'Bearer realm="orders"'})
if (not isinstance(identity, dict) or
not isinstance(identity.get('tenant'), str) or
not 1 <= len(identity['tenant']) <= 64 or
identity.get('can_list') is not True):
return failure(403, 'forbidden', 'Access denied')
try:
result = bounded_page(db, identity['tenant'], limit, cursor)
except ValueError:
return failure(400, 'invalid_page', 'Invalid pagination arguments', 'pagination')
except sqlite3.Error:
return failure(503, 'temporarily_unavailable', 'Try again within your retry budget')
return {'status': 200, 'headers': {'Cache-Control': 'no-store'}, 'body': result}A missing identity with a malformed cursor gets 401 and a challenge; an identity without list permission gets 403; an authorised caller with invalid pagination gets 400 under the same envelope; a database failure gets a safe 503 rather than SQL details. Actual authentication, customer ownership on create, cancellation permission, replay-result disclosure checks, framework routing/validation handlers and unexpected-error middleware are not implemented here. Enforce them before returning even a previously stored result. Tenant isolation alone is not per-user authorisation. The earlier core and later FastAPI create fragment stay deliberately partial; this companion does not turn either into a secured application or extend the create-only OpenAPI document to cover list errors.
HTTP/FastAPI reference boundary
A real adapter must validate input, resolve identity, allocate a database connection per request/unit of work, map domain exceptions and close resources. Do not share an unprotected dictionary for orders and replay results across concurrent workers. The original FastAPI example replayed keys without tenant/payload checks, used a timestamp-only cursor, produced a false has_more on an exact last page, and declared an unused error helper. Those are corrected in the executable core and explicit mapping below.
# service-reference: adapter outline, not a running FastAPI application
# db is a request-scoped connection; tenant comes from verified identity.
from fastapi.responses import JSONResponse
def create_response(db, tenant, key, payload):
try:
result = create(db, tenant, key, payload) # import from orders_contract
except ValueError as error:
code = 'idempotency_conflict' if str(error) == 'idempotency conflict' else 'invalid_order'
return JSONResponse(status_code=409 if code.endswith('conflict') else 422,
content={'error': {'code': code, 'message': str(error)}})
return JSONResponse(status_code=result['status'], content=result['body'],
headers=result['headers'])This is a clearly labelled adapter fragment, not a complete authentication/error middleware implementation. A production app must also normalise malformed JSON, validation, routing, auth and internal errors; add request IDs and safe diagnostics; bound request bodies; and document 400/401/403/404/409/412/413/415/422/429/5xx as applicable. FastAPI/Pydantic generation can reduce schema duplication, but it cannot automatically describe every handcrafted JSONResponse, custom error or durable replay guarantee.
OpenAPI as a contract, not a proof
This complete small OpenAPI 3.1.1 document describes the create request/response surface, not every route in the domain. It can be input to compatible documentation, client-generation and mock tools; those tools were not run here. Add response examples and consumer tests to verify implementation parity. Code-first and spec-first are both viable if generation and review are enforced.
openapi: 3.1.1
info:
title: Orders teaching contract
version: 1.0.0
paths:
/v1/orders:
post:
operationId: createOrder
parameters:
- in: header
name: Idempotency-Key
required: true
schema: {type: string, minLength: 1, maxLength: 128}
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
required: [customer_id, items]
properties:
customer_id: {type: integer, minimum: 1}
items:
type: array
minItems: 1
maxItems: 100
items:
type: object
additionalProperties: false
required: [sku, qty]
properties:
sku: {type: string, minLength: 1, maxLength: 50}
qty: {type: integer, minimum: 1, maximum: 1000}
responses:
'201':
description: Created or replay of the same accepted operation
headers:
Location:
schema: {type: string}
content:
application/json:
schema:
type: object
required: [id, customer_id, items, status]
properties:
id: {type: integer}
customer_id: {type: integer}
items: {type: array, items: {type: object}}
status: {type: string, enum: [created]}
'409': {description: Same operation key with a different request}
'422': {description: Invalid order or item fields}The brief response item schema is deliberately less restrictive than the request; a production contract should reuse a full shared item schema and describe error bodies/security. Documenting that omission is not claiming a complete SDK/contract-test system. The local check parses YAML and validates exact fixture fields independently; it is not full OpenAPI conformance certification.
Deprecation and Sunset exercise
RFC 9745 defines Deprecation as a Structured Field date, e.g. Deprecation: @1735689600 for 2025-01-01 UTC. RFC 8594 defines Sunset as an HTTP date. Use a fixed announced retirement schedule, not now + 90 days recomputed forever. A deprecated field/path can carry a Link to migration guidance; headers do not force clients to migrate.
# exact-function: deprecation_headers
from datetime import datetime, timezone
from email.utils import format_datetime
def deprecation_headers():
deprecated = datetime(2025, 1, 1, tzinfo=timezone.utc)
sunset = datetime(2027, 1, 1, tzinfo=timezone.utc)
return {'Deprecation': '@' + str(int(deprecated.timestamp())),
'Sunset': format_datetime(sunset, usegmt=True),
'Link': '</docs/migration>; rel="deprecation"'}Attach these to the actually deprecated resource/representation. If a request header selects a legacy representation, define precedence and Vary/cache semantics; adding a duplicate route function is not a migration mechanism.
Patch documents and bulk operations
JSON Merge Patch (RFC 7396) replaces members, removes members set to null, and replaces arrays wholesale. Reapplying the same merge patch is idempotent under its defined transformation; arrays do not inherently make it non-idempotent. JSON Patch (RFC 6902) is an ordered operation list: add/remove/replace/move/copy/test. Repeated append or remove can differ/fail; If-Match/test can protect the expected base.
This exact local transformation follows the RFC 7396 object/nonobject rules and verifies array replacement and repeat application. It is not a full HTTP PATCH endpoint or JSON Patch implementation.
# exact-program: merge_patch
from copy import deepcopy
def merge_patch(target, patch):
if not isinstance(patch, dict):
return deepcopy(patch)
result = deepcopy(target) if isinstance(target, dict) else {}
for name, value in patch.items():
if value is None:
result.pop(name, None)
else:
result[name] = merge_patch(result.get(name), value)
return result
if __name__ == '__main__':
original = {'items': [1, 2], 'profile': {'name': 'A', 'old': True}}
patch = {'items': [3], 'profile': {'old': None}}
expected = {'items': [3], 'profile': {'name': 'A'}}
assert merge_patch(original, patch) == expected
assert merge_patch(expected, patch) == expected
assert original['items'] == [1, 2]
print(expected)For POST /v1/orders:batchCancel, cap inputs at 100, authorise every item, define duplicate-ID handling and preserve one result per input position. A 200 envelope can contain item successes/errors if documented; a rejected request fails at the request level. An async variant returns 202 with job identity, progress, cancellation/retry rules and later results. 207 has WebDAV-defined semantics and is usually not the best default for a custom JSON API, but “clients can never handle it” is false. Whole-batch idempotency must preserve partial results and avoid reapplying already completed item effects.
(d) Production reality · 15 min
Ambiguous payment, coexistence and abandoned versions
These are failure mechanisms, not claimed Stripe/GitHub latency or migration statistics. Payment response loss requires durable identity through the provider boundary. Stripe documents replay of the first result, including errors, parameter comparison and key pruning after at least 24 hours; that is not a universal fixed horizon or proof of exactly-once effects in every downstream system.
Maintaining REST and GraphQL side by side can serve different clients without a forced global migration. A large breaking v2 can stall if clients lack a migration path; publish examples, usage evidence, notices and an agreed retirement policy. Date pinning still requires compatibility implementations and client testing. It does not make migration “bump one string and nothing can break.”
Where this shows up in the rest of the plan
HTTP supplies semantics; GraphQL changes the query shape; gRPC provides generated RPC contracts. Identity, quotas, gateway routing and observability are cross-cutting. A capstone must specify its domain and failure contract rather than selecting a protocol by fashion.
(e) Recall + stretch · 10 min
Why use an order-scoped cancel path?
It preserves order identity and a predictable route convention while expressing a domain transition. It does not automatically implement permissions, refunds or REST hypermedia constraints.
What does an idempotency key do and how long is it stored?
It identifies one logical operation within tenant/type scope, bound to a canonical request and durable result. Retain it for the published recovery/retry horizon and settle in-progress operations. Twenty-four hours is provider-specific guidance, not a protocol constant.
Offset versus cursor?
Offset supports jumping but can shift under mutations. A keyset cursor over a stable total order avoids offset shifts and can use indexes; it does not guarantee a historical snapshot or arbitrary page jumps. Reapply permissions and filters.
Which headers announce deprecation?
RFC 9745 Deprecation uses an @-prefixed epoch Structured Field date, not true. RFC 8594 Sunset uses HTTP-date. Link can point to migration guidance; dates are fixed policy, not a sliding ninety-day deadline.
What versioning approaches can you defend?
Path namespaces, representation/request headers, date-pinned contracts and compatible evolution; query parameters are another choice. They can coexist under explicit rules. Stripe and GitHub examples require their actual documented header semantics, not guessed v3 paths or dates.
Does every 201 require Location?
No: RFC 9110 uses Location when present, otherwise the request target identifies the primary created resource. This orders API deliberately returns Location and replays it consistently.
What belongs in a validation error?
A stable safe code, message, relevant field/path and correlation identity under one documented envelope, with an appropriate 4xx status. Wire every error source into the policy; an unused helper is not enforcement. The bounded list adapter demonstrates 401/challenge, 403, pagination 400 and safe database 503 with one shape; the create fragment and OpenAPI example remain explicitly narrower, not evidence of application-wide middleware.
Spec-first or code-first?
Spec-first reviews consumer contracts before implementation; code-first derives schemas from code. Either can drift in behaviour unless generation, examples and contract tests are maintained. Docs are not a mock server or SDK by themselves.
Merge Patch versus JSON Patch?
Merge Patch replaces arrays and treats null as member removal; repeating it is idempotent. JSON Patch applies ordered operations and is not generally idempotent. Use media types and preconditions, and test null/array behaviour.
Stretch: cancel up to 100 orders with partial failures.
Use a documented batch domain operation, per-item authorisation/results, stable input-order correlation, duplicate handling, whole-operation identity and recovery of partially completed effects. Reject invalid envelopes; report item failures inside an accepted response; use a job resource for async completion.
Failure exercise and worked answer
The server commits an order but loses its replay record: a dictionary populated after the order write leaves a duplicate window. The exact fixture commits order and replay result together. For an external payment effect, model recoverable states and provider identity instead of assuming a local transaction spans the network.
Explain-out-loud test
Model order versus cancellation, trace response-loss retry without a new key, and show why a timestamp-only cursor skips ties while a total-order cursor still is not a full snapshot.
What comes next
Next: S057 · GraphQL — schema, resolvers, N+1, when to pick it. GraphQL trades fixed endpoint shapes for typed client selections. Hub: The 6-Month Learning Plan.
Study route: intuition → visual walkthrough → hands-on → production failure analysis → recall. Use the section times as planning cues, not a completion guarantee; pause to work through the checks. Optional pre-reading is separate.
Sources and review boundary
Fielding REST, RFC 9110, RFC 9745, RFC 8594, RFC 7396, RFC 6902, OpenAPI 3.1.1, Stripe idempotency. Exact excerpts support the contract; local SQLite tests do not establish a running FastAPI/auth/payment/SDK integration.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.