Engineering5 min read

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.

Modelling API errors in OpenAPI so your SDK can throw something useful

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.

yaml
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.

yaml
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:

yaml
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.