19.1 HTTP API: Resources, Semantics, Concurrency, and Recoverable Requests
A stable HTTP API is not simply a mapping of internal methods to URLs. It must provide callers with persistent, understandable resource identifiers, well-defined method semantics, a clear failure model, and explicit concurrency rules.
URIs Identify Resources, Methods Express Intent
GET /tournaments/{id}
POST /tournaments
PUT /tournaments/{id}
PATCH /tournaments/{id}
DELETE /tournaments/{id}The HTTP blocks show only relevant methods, paths, and headers. Protocol versions, Host, authentication, and bodies are omitted; these are not complete wire messages. URIs should use business nouns rather than exposing database tables or controller classes. The hierarchy between collections and individual resources should remain consistent.
Not all business operations are suitable for being disguised as CRUD. For actions like "settle a match" (which have independent rules, permissions, and audit requirements) a dedicated action resource can be created:
POST /matches/8472/settlements
Idempotency-Key: "7c62bb24-0658-4fa7-9ec1-e532f6e7e921"The response returns the newly created action resource, making it easier to query its status, deduplicate, and audit compared to POST /matches/8472?action=settle.
Safe vs. Idempotent
- safe: The client does not request a change to the server's state; typical examples are GET and HEAD methods.
- idempotent: The intended effect of repeating a request is the same as that of executing it once; typical examples include PUT, DELETE, and safe methods.
- POST is not inherently idempotent by method semantics, but a specific API can achieve business-level idempotency through the use of an idempotency key.
Idempotency does not mean that the response is identical each time, nor does it imply that the server cannot log activity. It defines the expected outcome of a request when executed multiple times.
GET requests should not result in side effects such as charges or deletions, because pre-fetchers, crawlers, and caches may automatically execute GET operations.
Bind POST Idempotency Keys to Request Identity
When processing idempotency keys on the server, the system should store:
- Tenant, caller, and business operation scope;
- The idempotency key;
- A normalized request digest;
- The processing state and final response;
- An expiration timestamp.
This API chooses 409 when the same key has a different request digest within one tenant, caller, and operation scope. It neither reuses the first response nor runs a second operation. Status codes and retention periods are part of the API contract; the header name alone does not define them. The digest should cover method, target resource, and normalized payload. Authorize again before returning a saved result.
(tenant, caller, operation, key) first occurrence → atomically register in-progress, then execute and save
same key + same request digest → return previously stored result
same key + different request digest → 409 ConflictClaim execution with a unique constraint or conditional write; checking for absence and then inserting does not handle concurrency. For local database operations, commit deduplication state and business changes together. External side effects need a stable operation ID and result lookup. While the first request is running or its result is unknown, expose queryable status instead of executing a second copy. Once retention expires, reusing the key may create a new request, so align the client retry window with retention.
Status Codes Express the Client's Next Step
| Scenario | Common Status Code |
|---|---|
| Creation complete | 201 + Location |
| Asynchronous acceptance in progress | 202 + status resource |
| Success with no response body | 204 |
| Request syntax or structural error | 400 |
| Missing valid authentication credentials | 401 + WWW-Authenticate |
| Authenticated but lacks permissions | 403 |
| Resource not found | 404 |
| Conflict with current state | 409 |
| Conditional request failed | 412 |
| Required request condition was omitted | 428 |
| Semantic validation failure | 422 |
| Rate limiting | 429 + retry hint |
The same error may map to different status codes depending on context. Keep semantics stable and document them so clients need not parse natural-language messages to choose their next action.
202 means accepted, not guaranteed success; the status resource must later report completion or failure. 429 may include Retry-After, which still needs to fit the client's total budget. A consistent policy may also return 404 to avoid disclosing whether a protected resource exists.
Error responses can follow Problem Details. This example uses HTTP status 409 and media type application/problem+json:
{
"type": "https://api.example/problems/tournament-full",
"title": "Tournament is full",
"status": 409,
"detail": "No confirmed slots remain.",
"instance": "/requests/req-123",
"code": "TOURNAMENT_FULL"
}type identifies the problem type, instance identifies this occurrence, and code is this API's extension field. The body's status should match the actual HTTP status. Clients must not branch by parsing detail, which must not expose SQL, stack traces, internal hosts, or credentials.
Use Conditional Requests to Prevent Lost Updates
When reading a resource, return a version validator:
ETag: "tournament-8472-v9"When updating, require the caller to submit the version they currently see:
PATCH /tournaments/8472
If-Match: "tournament-8472-v9"This API returns 428 for an omitted required condition and 412 when the supplied tag does not match current version v10. If-Match uses strong comparison; a weak W/"..." tag cannot satisfy this match. Compare and write atomically, for example with a database conditional update. Comparing in application code and then writing unconditionally still permits lost updates.
The example assumes every representation change advances the version. If language, media type, or related data also changes the representation, include that in the validator. Do not assign one strong ETag to byte-different representations. Explicit business-version fields with 409 are another option, provided the API uses a consistent contract.
Pagination Requires Stable Ordering
Large collections should not default to returning all items at once. Offset-based pagination is easy to understand, but it becomes expensive at deep pages and suffers from data duplication or omission under concurrent inserts. Cursor-based pagination encodes a stable sort key into an opaque cursor:
GET /matches?limit=50&after=opaque-server-issued-cursorThe sort key must be unique, for example (settled_at DESC, id DESC). The cursor should bind filtering conditions, sort version, and necessary tenant scopes, and include signing or integrity protection to prevent clients from arbitrarily modifying internal positions.
If the previous page ends at (10:00, 8472), the next descending page selects keys strictly smaller than that pair. A record at the same time with ID 8471 still qualifies. Sorting keys need to be non-null and stable, with a matching index; a cursor alone does not guarantee low query cost.
Return a next link or nextCursor value and define snapshot behavior. Keyset pagination alone is not a snapshot: new records before the cursor may never appear, and changed sort keys can cause omissions or repeats. A fixed result set needs an additional snapshot or version boundary. Even a signed cursor does not remove the need for resource authorization on every request.
API Boundary Checks
- Does the URI represent a business resource rather than an internal implementation detail?
- Do the methods adhere to HTTP safe/idempotent semantics?
- After a write request times out, how does the client query the state and safely retry?
- Are errors machine-determinable and do they not expose internal system details?
- Do concurrent updates include conditional requests or business versioning controls?
- Does pagination provide a stable, unique sort order and clear consistency guarantees?
Next lesson, we’ll turn these semantics into machine-readable contracts and explore the evolution of compatibility across HTTP, gRPC, and message-based APIs.