Ship a Stable, Machine-Readable Error Code
Your error messages are a secret API. Callers regex them, then you cannot change them.
You wrote "User not found" to help a human debugging in a log. But some client couldn't branch on your errors any other way, so they wrote if "not found" in err.message. Now that sentence is load-bearing. Fix a typo, add context, translate it, and their code breaks in production. You signed a contract you never meant to sign.
Error text is an accidental contract
This is Hyrum's Law again: anything callers can observe, some caller will depend on. Error strings are the most seductive version of it because they look like an internal detail. They are not. If a message is the only machine-distinguishable signal you emit, integrators will parse it, and prose is the worst possible interface — unstable, localized, and untyped.
Ship a code they switch on
Separate the two audiences. Give machines a stable, enumerated code. Give humans a message that stays free to change.
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "You've sent 1001 requests this minute; the limit is 1000.",
"retryable": true,
"retry_after_ms": 4200
}
}
The code is the contract: a closed vocabulary of stable symbols callers can branch on. The message is documentation for humans — reword it, add detail, localize it, and no integration breaks. Never make callers parse text to make a control-flow decision.
Define codes as an enum in one place so they're discoverable and hard to typo:
class ErrorCode(str, Enum):
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"
INVALID_ARGUMENT = "INVALID_ARGUMENT"
NOT_FOUND = "NOT_FOUND"
INTERNAL = "INTERNAL"
Mark what is retry-safe
Callers also need to know whether retrying is safe, and prose won't tell them. Encode it. The HTTP convention is a good default: 4xx means the request itself is wrong — do not retry, fixing it requires a change. 5xx means a server-side failure — a retry may succeed. Make it explicit with a retryable boolean and, when relevant, a retry_after hint so clients back off instead of hammering you.
| Class | Meaning | Retry? |
|---|---|---|
| 4xx (except 408/429) | Client's request is invalid | No — will fail again |
| 429 | Rate limited | Yes, after retry_after |
| 5xx | Server-side failure | Yes, with backoff |
Standards exist so you don't invent this: RFC 9457 "Problem Details for HTTP APIs" defines a type (stable URI), title, and detail, mapping cleanly onto stable-code plus free-message.
Power moves
- Namespace your codes.
BILLING.CARD_DECLINEDbeats a flatCARD_DECLINED— it keeps vocabularies from colliding as the API grows. - Never reuse a retired code for new meaning. Codes are forever, like enum values on the wire. Add new ones; deprecate old ones.
- Include a trace/correlation ID in the payload so support can find the exact failure without callers pasting message text.
- Document the code list as part of the API reference, with retry semantics per code. That's the contract; the messages are not.
Resources
- RFC 9457 — Problem Details for HTTP APIs
- Google API Design Guide — Errors
- gRPC status codes and their retry semantics
- Hyrum's Law
Shipping AI or cloud systems? Yeda AI audits and hardens production LLM and API pipelines. Talk to us · Read the blog