Modelling API errors in OpenAPI so your SDK can throw something useful
Most specs describe the happy path in detail and the failures not at all. That asymmetry lands on every consumer as one untyped catch block.

Open almost any OpenAPI document and count the responses. The 200 has a schema, an example and a description. The 4xx, if it is there at all, says "description": "Bad Request".
This is not laziness. Success is the thing you designed, and failure is the thing that happens to you. But the asymmetry has a cost that lands entirely on your consumers, and it is bigger than it looks, because everything downstream of your spec inherits it. Your reference page cannot document errors you did not declare. Your generated SDK cannot type them. Your consumers write one catch block that stringifies whatever arrived and logs it.
Declare the failures, not just the successes
The minimum is that every operation declares the failures it can actually produce.
responses:
'200':
description: The invoice.
content:
application/json:
schema: { $ref: '#/components/schemas/Invoice' }
'404':
description: No invoice with that id.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'422':
description: The invoice cannot be voided once paid.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }Two things happen the moment you do this. The reference page gains a section that answers what a caller should expect, and the generated client gains a typed error per declared failure instead of one generic exception for everything.
The second one changes what a consumer's code can look like. Without declarations, every failure is the same object and every handler is a string comparison. With them, a caller can branch on the failure your API actually documented.
One error schema, referenced everywhere
The instinct is to inline a small error object per operation because each one is three fields. Do not.
Inlined shapes generate anonymous per-operation types. The same error shape inlined in forty operations produces forty near-identical generated types, which is unpleasant in every language and genuinely bad in the typed ones, where a consumer cannot write a single function that handles your errors because there is no single type to handle.
components:
schemas:
Error:
type: object
required: [code, message]
properties:
code:
type: string
description: Stable, machine-readable. Safe to switch on.
message:
type: string
description: Human-readable. Not stable; do not parse.
details:
type: object
description: Field-level context, when the error is a validation failure.One schema, $ref'd from every failure response. One generated type. One handler.
The code field is the contract
Status codes are too coarse to act on. A 400 can mean the request was malformed, a field failed validation, or the operation is not valid in the resource's current state. Those want different handling and they share a number.
So carry a stable string code, and treat it as a public API. Which means:
Do not change one once it ships. A renamed code breaks every consumer that switched on it, silently, at runtime, with no compile error anywhere.
Do not put anything variable in it. invoice_not_found is a code. invoice_a1b9_not_found is a code your consumers cannot match on.
Do not make the human message the identifier. Messages get reworded for clarity, and every rewording breaks anyone who parsed it. That is exactly why the two fields are separate.
Use default for the long tail
Declaring every possible failure on every operation is not realistic. default is the escape hatch, and it is under-used:
responses:
'200': { ... }
'404': { ... }
default:
description: An unexpected error.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }A default response tells a generator that anything unlisted still has a known shape. That single addition takes an operation from "failures are untyped" to "failures are typed", which is most of the benefit for one block of YAML per operation, or one block at the top if your errors are uniform.
If you do nothing else after reading this, add default to every operation.
problem+json if you have no opinion
RFC 9457, previously 7807, defines application/problem+json: a media type with type, title, status, detail and instance, extensible with your own fields.
It is worth adopting for one reason above the others: it is a decision you do not have to defend. Your consumers may already have a handler for it, tooling recognises it, and it saves the internal argument about whether the field is called code or error_code or errorCode.
The caveat is that type is a URI and title is human-readable, so if you want a short stable machine code you are adding an extension member for it. That is allowed and common. Do it deliberately rather than overloading type with something that is not a URI.
If you already have an error format in production, keep it. Consistency with yourself beats conformance with a spec nobody has asked you for.
What your consumers get once this is right
A generated client can only offer the failure types your spec admits to. Once the declarations exist, the client can distinguish the categories that matter, and they are more than one: a request that failed validation before any network call, an HTTP failure carrying your declared error body, a connection that never completed, and a timeout. Those are four different things a caller does four different things about, and they collapse into one unusable catch block when the spec is silent.
That is the actual return on the YAML. Not tidier documentation, though you get that. It is that every consumer stops writing the same defensive wrapper around your client, and the ones who would have written it badly stop having to.
Check what your spec currently declares
Undeclared failure responses is one of the fourteen rules our spec audit scores, and it fires on any operation with no 4xx, no 5xx and no default. In most specs it fires on nearly everything, which makes it one of the highest-leverage fixes available: a default response per operation, one shared schema, and every generated client in every language gains typed errors at once.
For the neighbouring problem, what belongs in API docs that OpenAPI cannot generate covers the errors guide that sits beside the reference and explains which of your failures are worth retrying.



