API StudioSDK StudioMonitoringMCP ServerSpec AuditFeatures
ComparePricingBlogDocs
Log inStart for free
API StudioSDK StudioMonitoringMCP ServerSpec AuditFeatures
ComparePricingBlogDocs
Log inStart for free
API StudioSDK StudioMonitoringMCP ServerSpec AuditFeatures
ComparePricingBlogDocs
Log inStart for free
Blog/Engineering
Engineering·September 5, 2026·5 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.

#openapi#api-design#error-handling#sdks
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
12345678910111213141516
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
123456789101112131415
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
12345678
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.

← PreviousThe Octri CLI: Your Whole Project From a Terminal
Next →MCP vs OpenAPI: they answer different questions

Related articles

What counts as a breaking change when your SDK is generated
Engineering·5 min read

What counts as a breaking change when your SDK is generated

A one-line spec edit can break every call site your users wrote. Here is the table of what is additive, what is breaking, and which ones your spec diff will not warn you about.

September 11, 2026
Your operationIds are your public method names, and unique is not enough
Engineering·5 min read

Your operationIds are your public method names, and unique is not enough

An operationId is not documentation metadata. It is the name your users type. Here is why uniqueness does not save you, and what actually collides.

September 9, 2026
Should you hand-write your SDKs or generate them?
Engineering·5 min read

Should you hand-write your SDKs or generate them?

The honest answer depends on how many languages you ship and how often your API changes. For one language and a stable API, hand-writing wins.

September 9, 2026
41% of APIs drift within 30 days, and most of it is invisible
Engineering·5 min read

41% of APIs drift within 30 days, and most of it is invisible

Schema drift is not usually a breaking change. It is a field nobody told you about, found by a test that failed in CI two weeks later.

September 7, 2026

A letter when something ships

New SDK languages, changes in the generator, and now and then a longer piece on keeping docs from rotting. Roughly one a month.

Join developers keeping tabs on Octri.

Octri

Upload an OpenAPI spec. Get complete docs and production-ready SDKs in 10 languages, live in minutes.

Contact support

Product

  • API Studio
  • SDK Studio
  • Monitoring
  • MCP Server
  • Pricing
  • Compare
  • Blog
  • Changelog
  • Press Kit

From your spec

  • Spec Audit
  • TypeScript SDK
  • Python SDK
  • Go SDK
  • Java SDK
  • MCP Server

Developers

  • Documentation
  • API Reference
  • SDK Libraries
  • MCP Server
  • Monitoring
  • CLI
  • Support

Legal

  • Terms of Service
  • Privacy Policy
  • Fair Use Policy
  • Data Processing (DPA)
  • Cookie Policy
  • Security
  • Subprocessors

© 2026 Octri, LLC. All rights reserved.

Made by devs who got tired of hand-writing SDKs.