Documentation/Under the hood/A 409 should tell you what to do next
A 409 should tell you what to do next
An error body that names the current state, whether it is terminal, the states the operation was allowed from, and what you can do instead. And why enforcing it centrally is harder than it looks.
- api-design
- state-machines
- error-handling
- transactions
- workflows
Overview
The caller already knows the request failed. Tell them something they do not know.
---
Here is a `409` from a typical API.
json
{ "error": "Conflict" }The caller now knows two things. Something conflicted, and they have to go and read the documentation to find out what.
If they are lucky it is documented. If they are unlucky the answer is an enum in somebody's source file, reachable only by someone with a checkout and an afternoon. That is a strange amount of friction to put between a developer and the sentence "this order has already shipped".
Now consider what the server knew at the moment it produced that body. It knew the record's current state. It knew which states the operation was legal from. It knew whether that state is terminal. That is the difference between "try again later" and "this will never work". And by looking at the same table it had just rejected you against, it knew which operations *are* legal right now.
All of that was in memory at the moment the rejection was produced. None of it made it into the response.
What we put in the body instead
A rejected transition returns a `409` carrying `error_code`, the `current_state` the record is actually in, a `current_state_terminal` boolean, the `attempted_operation`, the `allowed_from_states` for that operation, and its `target_state`.
Then a `remediation` block. That is the part that earns its keep. It differs depending on *why* you were rejected.
If the record is in a terminal state, remediation says so. No amount of retrying will move a `cancelled` order to `shipped` and the caller should stop writing retry logic.
If the record is in a live state but the wrong one, remediation lists `available_operations_from_current_state`. Nobody maintains that list by hand.
It is computed on the spot, by scanning the state machine for every operation whose allowed-from set contains the current state. It cannot go stale when someone adds a transition. There is no second copy of it to forget to update.
And a `trace` block with a request id and a timestamp, so the log line and the response can be joined without guessing.
An engineer reading that error knows what to do without opening a documentation page. That is the entire design goal. It is worth more than it costs. The alternative is a support conversation.
This is not a new idea and we should say so
RFC 9457, still known to most people by its old number 7807, standardises exactly this shape: a problem document with `type`, `title`, `status` and `detail`, served as `application/problem+json`, with extension members for whatever your domain needs.
Everything above could be expressed as that media type with `current_state` and `allowed_from_states` as extensions. We did not, and on reflection we should have. A standard envelope means a generic client parses our errors without a branch written specifically for us. If you are designing this from scratch today, start from the RFC and put your domain fields in extensions. Do not invent the wrapper, as we did.
The part worth taking from us is not the envelope. It is what we chose to put inside it.
Three outcomes, not two
The other thing worth stealing is that a state operation has **three** answers, and most implementations only have two.
Three answers.
**Rejected.** The record is in a state this operation cannot run from. That is the `409` above.
**Allowed.** The record is in a legal state. Run it.
**Idempotent.** The record is already in the state this operation would produce. Cancelling a cancelled order is not an error and it is not work either. It returns success, and it must not restamp the timestamps, because a caller reading `cancelled_at` to find out when something was cancelled will get the time of the most recent duplicate request instead of the truth. The write-side half of the same problem is in Idempotency without an idempotency table.
That third case is where the bugs live.
Treat it as an error and every retry after a lost response becomes a failure that a customer sees. Treat it as ordinary work and your audit timestamps drift a little further every time a flaky connection makes a client try again, until the column that was supposed to record when something happened records when someone last refreshed a page.
A record with an empty state field is treated as being in the machine's initial state, which handles a record created before the machine existed. An unknown operation is rejected with an empty allowed-from list, which reads correctly. A zero there would look like a machine with no transitions at all.
The part where I stop selling
Everything above is real and ships today. The enforcement behind it is less tidy.
There is a decorator in our source designed to make this a genuine chokepoint, so no operation could reach a handler without passing the check.
Nothing uses it.
Its own docstring says so.
Enforcement is therefore **per-handler discipline**.
Each handler loads its own machine and calls the validator itself. 21 of our 23 service manifests declare a state machine. The two without one are notifications and the workflow engine itself, neither of which has a lifecycle to declare, and the count is 23 rather than the 22 quoted elsewhere because that figure merges two services the filesystem keeps apart. None of that is structurally enforced. That is the caveat, and it belongs in the same breath, not three paragraphs later.
There is a second wrinkle. Because the shared idempotent response is generic, it drops fields specific to a given service, so handlers keep a hand-written block for that path. The validator currently owns the **reject** path cleanly and shares the idempotent path with legacy code. The file carrying that migration is marked as mid-migration in our source, and it has been mid-migration for a while.
A few operations sit outside the validator on purpose. They do not fit the shape.
One mutates a quantity instead of moving a state. Another has two legal outcomes depending on a flag, and a table that maps one operation to one target state cannot express an operation that lands in either of two places depending on how it was called.
**So "centrally enforced" needs an asterisk, and this paragraph is the asterisk.** The 409 contract is consistent because the machines are declared in one place per service. The guarantee that every operation consults them is a convention that a new handler can break without failing a build.
The test detail that is better engineering than the feature
Proving the idempotent case is harder than it looks, and how we prove it says something about API design.
Our execution wrapper filters a handler's response down to the fields declared in the operation's output schema. That is correct behaviour and it has a consequence: a handler that returns a helpful marker like `reason: "already_cancelled"` has it stripped before the caller sees it. The marker is not in the declared schema. The marker is unreachable from a test.
So the test asserts on the **timestamp** instead. If the operation was genuinely idempotent, `cancelled_at` is byte-identical to what it was before. If something restamped it, the value moves.
That comparison then hits a real-world detail worth knowing. A handler emits microsecond precision with a timezone, and the same value after a database round trip comes back at millisecond precision with the timezone stripped. Comparing the strings fails on a correct implementation.
The helper normalises both sides to milliseconds, so the assertion asks "the same moment, at storage precision?" and not "the same characters?" — while still catching a restamp, which differs by at least a millisecond.
The rejection helper accepts **three different shapes** of 409, because three shapes exist in the wild: a raised error carrying the status, a structured body with the error code, and a stringified plugin response where a wrapper serialised the inner 409 into a message field.
A test helper that accepts three shapes documents an inconsistency. It does not fix one.
We would rather it were one shape. It is three.
Read one of your own 409s as a stranger
Take the most state-heavy resource you own. Put it in a terminal state, then call an operation that is illegal from there, and read the response body as though you had never seen the codebase.
Can you tell what state it is in? Can you tell whether waiting would help? Can you tell what you are allowed to do instead?
If the answer to the last one is no, it is usually about twenty lines to add.
The server already knows.
*(Disclosure: I work on Supero. The error contract above is ours and ships today. The enforcement caveat is also ours, and it is here because a piece about rigorous state handling that quietly omitted it would not be worth your time.)*
On this page