HTTP API¶
Interactive documentation is served by the running service at /docs, and
the machine-readable OpenAPI document at /openapi.json. This page covers
what those two cannot express.
Every response carries an X-Request-ID header — see
correlation identifiers.
Health endpoints¶
Three endpoints, three genuinely different questions. Wiring them to the same answer is a real way to turn a small problem into a large one.
| Path | Question | Checks dependencies? |
|---|---|---|
GET /healthz |
Is this process alive? | Never |
GET /readyz |
Can this instance serve traffic right now? | Yes, with short timeouts |
GET /startupz |
Has startup finished? | No |
GET /healthz — liveness¶
{"status": "ok", "version": "0.1.0"}
Always 200 while the process runs.
This endpoint must never check a database. An orchestrator restarts a container whose liveness probe fails. If liveness checked the database, then a database hiccup would fail the probe on every instance at once, and the orchestrator would restart the entire service — converting a brief dependency problem into a full outage, and removing the capacity that might have recovered.
GET /readyz — readiness¶
{"status": "ok", "checks": {"database": "ok"}, "dependencies": {"cache": "ok", "storage": "ok"}}
curl -sf http://localhost:8000/readyz | grep -q '"status":"ok"'
Two tiers, and which tier a dependency belongs in is a judgement about blast radius, not about importance.
checks is gating: it decides the status code. Returns 200 when every
gating check passes, and 503 when any fails. A failing readiness probe
removes the instance from load balancing but does not restart it, which is
the correct response to "my database is unreachable". The database is the
only entry in checks — a failure looks like this:
{"status": "unavailable", "checks": {"database": "error: TimeoutError"}, "dependencies": {}}
dependencies is informational: it is reported and never changes the
status code, whatever it says. The cache and the object store live here, and
both stay "ok" or degrade independently of checks — losing either never
turns into a 503.
{"status": "ok", "checks": {"database": "ok"}, "dependencies": {"cache": "error: TimeoutError", "storage": "ok"}}
Why gate on the database but not the other two. This is worth remembering, because it is not the obvious choice.
The cache is informational because it fails open: CachedOrderRepository
swallows every Redis error and falls through to PostgreSQL, so a Redis outage
never produces a wrong answer, only a slower one. Redis is also shared
across every pod — gating on it would make every pod fail the check in the
same second, taking the whole service out of the load balancer over a
degradation it was specifically built to survive. A cache that can take the
service down is worse than no cache at all.
Object storage is informational for a different reason with the same answer.
Losing it breaks exactly one endpoint, GET /orders/{id}/receipt — see below
— so pulling 100% of traffic off a pod to protect that one slice costs far
more than it saves.
checks is empty and dependencies is empty when no database, cache, or
storage is configured — a service can run with none of the three, on the
in-memory repository, no cache decorator, and the in-memory receipt store.
Two further details are deliberate.
Checks run concurrently, not one after another. Run in sequence, the worst case would be the number of dependencies multiplied by the timeout — three checks at two seconds each is a six-second response, which the orchestrator's own probe timeout kills first, marking the instance unready for entirely the wrong reason. Concurrent, the worst case is one timeout no matter how many dependencies exist.
The exception message is not in the response. You get the exception's
type name (TimeoutError), never its message. /readyz is reachable from
inside a cluster, and a database driver's exception message routinely carries
hostnames, connection strings, or credentials. The full exception and its
traceback go to the log, where an operator can still read them.
GET /startupz — startup¶
{"status": "ok", "version": "0.1.0"}
200 once the application's startup has finished, 503 ("status": "starting")
before that. This covers slow first starts — a service that needs 40 seconds
to warm caches should not be declared dead at second 10.
Orders¶
An example slice, included so the pattern is copied rather than reinvented.
POST /api/v1/orders¶
Request:
{
"customer_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lines": [
{"sku": "WIDGET-1", "quantity": 2, "unit_amount": "9.99", "currency": "EUR"}
]
}
| Field | Rule |
|---|---|
customer_id |
A UUID. |
lines |
At least one line. Every line must use the same currency. |
lines[].sku |
1 to 64 characters. |
lines[].quantity |
An integer greater than 0. |
lines[].unit_amount |
0 or more, at most 2 decimal places, at most 14 digits. |
lines[].currency |
Exactly three uppercase letters, such as EUR. |
Responds 201 Created with a Location header pointing at the new order,
and the order as the body. subtotal and total are computed by the service.
Placing an order authorises payment first, so it can also answer:
- 402 Payment Required — the payment provider declined the card. A successful call with a negative answer, not a failure on this service's part; it is never retried automatically. See Outbound HTTP calls.
- 503 Service Unavailable — the payment provider could not be reached,
or its circuit breaker is open. Carries a
Retry-Afterheader, whose value is the configured breaker cool-down (APP_PAYMENT__BREAKER_RESET_AFTER_SECONDS, 30 seconds by default, rounded up to a whole number of seconds); waiting that long and retrying is the correct client behaviour, not treating this as a permanent failure. See Outbound HTTP calls.
Both use the same Problem Details body shape as every other
error. GET /api/v1/orders/{order_id} cannot produce either — it never
talks to the payment provider.
GET /api/v1/orders/{order_id}¶
Responds 200 with the order, or 404 when no order has that id.
order_id=$(curl -sf -X POST http://localhost:8000/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"customer_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","lines":[{"sku":"WIDGET-1","quantity":2,"unit_amount":"9.99","currency":"EUR"}]}' \
| grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
curl -sf "http://localhost:8000/api/v1/orders/$order_id" | grep -q '"amount":"19.98"'
GET /api/v1/orders/{order_id}/receipt¶
Responds 200 with the receipt document, Content-Type: application/json:
{
"schema_version": 1,
"order_id": "cac0acf8-ea5c-4936-97b4-3b0c113f8a8f",
"customer_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"authorisation_id": "auth_9f2c1e",
"currency": "EUR",
"total": "19.98",
"lines": [
{"sku": "WIDGET-1", "quantity": 2, "unit_price": "9.99", "subtotal": "19.98"}
]
}
order_id=$(curl -sf -X POST http://localhost:8000/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{"customer_id":"3fa85f64-5717-4562-b3fc-2c963f66afa6","lines":[{"sku":"WIDGET-1","quantity":2,"unit_amount":"9.99","currency":"EUR"}]}' \
| grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
curl -sf "http://localhost:8000/api/v1/orders/$order_id/receipt" | grep -q '"schema_version":1'
Also 404 when no order has that id — checked first, so an
unknown id never touches object storage at all — and 503 Service
Unavailable when the receipt store cannot be reached. Carries a
Retry-After header too, like the payment provider's 503 above, but here
the value is a fixed 30 seconds rather than derived from a setting — there
is no circuit breaker in front of object storage for it to read a cool-down
from.
Rendered on demand, not written when the order is placed. The first
request for a receipt renders it from the order and stores the result; every
request after that serves the stored bytes unchanged. This is deliberate:
PlaceOrder never touches object storage, so a storage outage can degrade
one read endpoint but can never fail a payment. The cost is one slower first
request per order — rendering is pure and cheap, so in practice that cost is
small.
Every amount in the document is a JSON string, for the same reason as the
order response below. The document carries no timestamp — a receipt is
stored once and served unchanged afterwards, and a generated_at field
would make a re-rendering after a cache eviction produce different bytes for
"the same" receipt. internal_note is absent for the same reason it is
absent from the order response: the renderer names every field it emits
rather than dumping the entity.
Response shape¶
{
"id": "cac0acf8-ea5c-4936-97b4-3b0c113f8a8f",
"customer_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"lines": [
{
"sku": "WIDGET-1",
"quantity": 2,
"unit_price": {"amount": "9.99", "currency": "EUR"},
"subtotal": {"amount": "19.98", "currency": "EUR"}
}
],
"total": {"amount": "19.98", "currency": "EUR"}
}
Amounts are JSON strings. "19.98", not 19.98. They are Decimal
values; emitting one as a JSON number would push it through binary floating
point, where 0.1 + 0.2 is not exactly 0.3. A string crosses the wire exactly.
Parse it into your own decimal type, not into a float.
The response omits internal_note. That field exists on the stored
entity. It never reaches a client, because the response is assembled by a
mapper function that names each field that goes out — so a field added to the
entity tomorrow is not published by accident.
Two constraints checked in more than one place¶
The same rules appear in the HTTP schema, in the service command object, and in the domain model. That is intentional, not an oversight.
Each layer must be correct on its own. The HTTP schema catches bad input at the edge and returns a clean 422. The command object must also stand on its own, because a caller that is not HTTP — a scheduled job, a message consumer — reaches the service layer without passing through the schema at all. The domain model enforces the rule last, because it is the layer that must never hold an invalid value.
Two rules in particular exist as whole-request checks rather than per-field ones:
- All lines share one currency. This is a relationship between lines,
so no single-field constraint can express it. Without it, two individually
valid lines in different currencies reach the money arithmetic, which raises
a plain
ValueError— neither a domain error nor a validation error — and falls through to the catch-all handler as a 500 for input that was merely wrong, not exceptional. - The total matches the sum of the lines. Enforced by the
Orderentity itself, so an order whose total disagrees with its lines cannot be constructed at all.