# How to expose typed SDK errors callers match without parsing text

> Turn HTTP failures into a small error hierarchy carrying status, a stable code, the request id, and the raw body, and test that each status maps to one type.

Source: https://voxgig.com/howto/typed-sdk-errors-callers-can-match

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript, python, go
- Verified: 2026-10-21
- Published: 2026-10-18

## Short answer

Define one base error, one class per cause a caller branches on, and a connection error for requests that got no response. Give every class a stable code beside its status. Map the response to a class in one place, at the transport, and keep the request id and the raw body. Callers match by class in TypeScript and Python and by errors.As in Go, and fall back to the code when two copies of the class have loaded.

---
## You will need

Node 22 or later, Python 3.13 or later, Go 1.24 or later, and an API whose failures carry a documented
body, here [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem details beside a request
id header. Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, and Go 1.24.7. The three
samples share one vocabulary of codes and one table of ten statuses, and every test runs against an
in-process server or against no server at all. Nothing here reaches a network.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Go `errors.As` on a wrapped `*APIError`](https://pkg.go.dev/errors#As) | Go, where one struct with fields does the work of a class per status | A `%w` at every wrapping layer, and callers who must reach for `errors.As` rather than a type assertion | Callers want the compiler to enumerate the cases, which a status field cannot give them |
| [OpenAPI Generator ApiException](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/python/exceptions.mustache) | You generate the SDK and take the generator's tree as it ships | A tree you do not control: seven subclasses in Python, one `ResponseError` in TypeScript, and messages set by a template | Your error body carries a code callers should branch on |
| [Stainless APIError subclasses](https://github.com/openai/openai-node) | You want one class per status, the shape the OpenAI SDKs ship | Eight status classes and a connection class to keep stable, with their names decided by a vendor's generator | A hand-written SDK, or failures better told apart by a body code than by status |
| [Stripe exception hierarchy](https://docs.stripe.com/error-handling) | You hand-write the SDK and want classes named for causes rather than statuses | A class per cause that you define, document, and map from the body's codes by hand | A generated SDK, where the generator owns the tree |

Stripe's tree names causes, `CardError` and `RateLimitError` among them, which tells a caller the
most and costs the most to keep current, because every cause you add is a class you add. Stainless
names statuses, one class each, so the tree is finite and generated and says less about why. OpenAPI
Generator ships whichever tree its template has: seven subclasses in Python, and in TypeScript one
[`ResponseError`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/typescript-fetch/runtime.mustache)
holding the raw `Response`.

## Give every class a status and a stable code

Three kinds of failure cover an SDK: the request got no response, the server answered with a
failure, and the SDK itself was misused. The first two are classes here. The third stays a plain
`TypeError`, because it is the caller's bug. Every class carries a `code`, set from the class rather
than typed per instance, for the one case `instanceof` cannot handle.

```js title="errors.mjs"
// Symbol.for returns the same symbol to every copy of this module, so the
// brand below survives the case instanceof cannot handle.
const SDK_ERROR = Symbol.for('example.sdk.error')

export class SdkError extends Error {
  static code = 'sdk_error'

  constructor(message, options = {}) {
    super(message, options.cause === undefined ? undefined : { cause: options.cause })
    this.name = new.target.name
    this.code = new.target.code
  }

  get [SDK_ERROR]() {
    return true
  }
}

/** The request got no response: refused, reset, or timed out. */
export class ConnectionError extends SdkError {
  static code = 'connection_error'
}

/** The server answered with a status of 400 or above. */
export class ApiError extends SdkError {
  static status = null
  static code = 'api_error'

  constructor({ status, body = null, requestId = null }) {
    const title = body?.title ?? 'request failed'
    super(`${status} ${title}${body?.detail ? `: ${body.detail}` : ''}`)
    this.status = status
    this.body = body
    this.requestId = requestId
    // RFC 9457: the problem type is the server's own identifier for what went wrong.
    this.type = body?.type ?? 'about:blank'
  }
}
```

`new.target` makes the base constructor read the static `code` and the `name` of the subclass, so a
subclass is three lines. `errorFor` is the whole mapping: the table of leaves, then the 5xx range, then the
base. A status outside the table is still an `ApiError`, with its status on it and `api_error` as
the code, which is what the base class is for.

```bash
node demo.mjs
```

```text output
status  class                     code
400     BadRequestError           bad_request
401     AuthenticationError       authentication_error
403     PermissionDeniedError     permission_denied
404     NotFoundError             not_found
409     ConflictError             conflict
418     ApiError                  api_error
422     UnprocessableEntityError  unprocessable_entity
429     RateLimitError            rate_limited
500     InternalServerError       internal_server_error
503     InternalServerError       internal_server_error
leaf classes: 8; no status matched more than one leaf: true
```

418 is the row to read. The table has no class for it, and `errorFor` falls through to the base
rather than inventing one, so the type a caller can catch is `ApiError` with `status` 418 on it.

## Map the response once, at the transport

One function sees every response, so one function decides the type. It keeps the request id from
the header, and it parses the body when the media type says JSON and keeps the text when it does
not. The RFC 9457 `type` in the body is the server's own name for the problem.[^1]

```js title="client.mjs"
export async function request(url, { fetchImpl = fetch, init = {} } = {}) {
  // A malformed URL is the caller's bug, so URL's TypeError goes out unwrapped.
  const target = new URL(url)
  let res
  try {
    res = await fetchImpl(target, init)
  } catch (cause) {
    // The caller's own abort is a cancellation, not a lost connection. A
    // signal from AbortSignal.timeout aborts with a TimeoutError, which is one.
    if (init.signal?.aborted && cause?.name !== 'TimeoutError') throw cause
    throw new ConnectionError('the request got no response', { cause })
  }
  if (res.ok) return res.status === 204 ? undefined : res.json()

  const requestId = res.headers.get('x-request-id')
  const text = await res.text()
  let body = text
  if (/json/.test(res.headers.get('content-type') ?? '')) {
    try {
      body = JSON.parse(text)
    } catch {
      body = text
    }
  }
  throw errorFor(res.status, body, requestId)
}
```

The URL is parsed before the `try`, so a malformed one stays the caller's `TypeError`. When the
caller cancels, the `AbortError` goes back out as it came, because a cancellation is not a lost
connection.

The test server answers every request with one status and one problem details body, which is
enough to assert the whole path from header to field:

```js title="errors.test.mjs"
test('a 404 arrives as NotFoundError carrying the request id, the problem type, and the raw body', async () => {
  const problem = { type: 'https://api.example.com/problems/no-such-document', title: 'No such document', detail: 'd9 was deleted' }
  const api = await failingServer(404, problem)
  try {
    await assert.rejects(request(`${api.url}/documents/d9`), (err) => {
      assert.ok(err instanceof NotFoundError)
      assert.equal(err.code, 'not_found')
      assert.equal(err.requestId, 'req_7')
      assert.equal(err.type, problem.type)
      assert.deepEqual(err.body, { status: 404, ...problem })
      assert.equal(err.message, '404 No such document: d9 was deleted')
      return true
    })
  } finally {
    await api.close()
  }
})
```

The run is under Check it worked, after the other two languages.

## Write the same tree in Python

Python's `except` clause is the match. A class in an except clause catches instances of that class
and of its subclasses, so `except APIError` catches every status and `except NotFoundError` catches
one.[^2] The status classes take the names the OpenAI SDKs use, and the network class is
`APIConnectionError`, as in Stripe's tree, because Python already owns the name `ConnectionError`.

```py title="sdk_errors.py"
class APIError(SdkError):
    """The server answered with a status of 400 or above."""

    code = "api_error"
    status = None

    def __init__(self, status, body=None, request_id=None):
        body = body or {}
        # A proxy's HTML page or a plain-text body is kept as text, not read as a problem.
        problem = body if isinstance(body, dict) else {}
        title = problem.get("title") or "request failed"
        detail = problem.get("detail")
        super().__init__(f"{status} {title}" + (f": {detail}" if detail else ""), request_id=request_id)
        self.status = status
        self.body = body
        self.type = problem.get("type", "about:blank")


class BadRequestError(APIError):
    status = 400
    code = "bad_request"
```

```bash
python3 demo_errors.py
```

```text output
status  class                     code
400     BadRequestError           bad_request
401     AuthenticationError       authentication_error
403     PermissionDeniedError     permission_denied
404     NotFoundError             not_found
409     ConflictError             conflict
418     APIError                  api_error
422     UnprocessableEntityError  unprocessable_entity
429     RateLimitError            rate_limited
500     InternalServerError       internal_server_error
503     InternalServerError       internal_server_error
leaf classes: 8; no status matched more than one leaf: True
except NotFoundError: 404 No such document (request req_7)
except APIError: InternalServerError internal_server_error for status 503
```

```bash
python3 test_errors.py
```

```text output
5 tests, 0 failures, 0 errors
```

The same ten statuses map to the same ten codes. The last two lines of the demo are the two
`except` clauses a caller writes: the specific class for a case the code handles, and the base for
everything else.

## Use one struct and `errors.As` in Go

Go has no class hierarchy to lean on, so `*APIError` is one struct with `Status`, `Code`, `Type`,
`RequestID`, and `Body`, and `*ConnectionError` wraps the dial error. The SDK wraps every failure
with context, which is why callers match with `errors.As` rather than a type assertion. `As` walks
the chain of wrapped errors, and a type assertion looks only at the outermost one.[^3]

```go title="errors.go"
// Get is one SDK call. It wraps every failure with the operation, which is
// why a caller matches with errors.As rather than a type assertion.
func Get(client *http.Client, url string) ([]byte, error) {
	res, err := client.Get(url)
	if err != nil {
		return nil, fmt.Errorf("sdk: get document: %w", &ConnectionError{Op: "GET " + url, Err: err})
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		return nil, fmt.Errorf("sdk: get document: %w", FromResponse(res))
	}
	return io.ReadAll(res.Body)
}
```

```bash
go test -v
```

```text output
=== RUN   TestMatch
=== RUN   TestMatch/status_table
400  bad_request            req_7  errors.As found *APIError through the wrap
401  authentication_error   req_7  errors.As found *APIError through the wrap
403  permission_denied      req_7  errors.As found *APIError through the wrap
404  not_found              req_7  errors.As found *APIError through the wrap
409  conflict               req_7  errors.As found *APIError through the wrap
418  api_error              req_7  errors.As found *APIError through the wrap
422  unprocessable_entity   req_7  errors.As found *APIError through the wrap
429  rate_limited           req_7  errors.As found *APIError through the wrap
500  internal_server_error  req_7  errors.As found *APIError through the wrap
503  internal_server_error  req_7  errors.As found *APIError through the wrap
=== RUN   TestMatch/no_response
refused  *ConnectionError matched, *APIError did not, the dial error is kept as Err
```

`errors.As` found the one struct behind the `sdk: get document:` wrap for all ten statuses, and
told the refused connection apart from them.
[stripe-go](https://pkg.go.dev/github.com/stripe/stripe-go/v82#Error) makes the same choice, with
one `stripe.Error` struct whose `Type` field, an `ErrorType`, names the cause. A few causes, such as
a declined card, also carry a typed error in its `Err` field.

## Check it worked

```bash
node --test errors.test.mjs
```

```text output
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 229.400801
```

`# pass 7` covers the table, the 404 that carries `req_7`, a refused connection, and the match by
code. The other three are a malformed URL, a canceled request, and an outside error that carries a
code. Change `errorFor` to return `ApiError` for a 503 and the first test fails on
`InternalServerError`. That is the point of a table test: the mapping is data, and the test is the
same data read back.

## When it goes wrong

The first failure is two copies of the class. A package published as both ESM and CommonJS ships
every class twice, and a dependency that requires the CommonJS build throws instances of a class
the program never imported. `pitfall.mjs` builds that situation in one process: it rewrites
`errors.mjs` as CommonJS, loads both copies, and throws from the one the program did not import.

```bash
node pitfall.mjs
```

```text output
the same class, loaded twice
  cjs.NotFoundError === NotFoundError:  false
  err instanceof NotFoundError:         false
  err instanceof ApiError:              false
  err.name:                             NotFoundError
  err.code === NotFoundError.code:      true
  isSdkError(err, 'not_found'):         true
```

`instanceof` is false against both the leaf and the base. `name` and `code` survive, because they
are strings on the instance rather than identity. `isSdkError` also checks a brand keyed by
`Symbol.for`, which hands both copies the same symbol. So `isSdkError(err, 'not_found')` matches
where `instanceof` cannot, while an error from outside the SDK that carries a `code` does not. The
fix for the package is one build, or one shared module for the classes that both builds import,
which Node's documentation on
[dual CommonJS and ES module packages](https://nodejs.org/api/packages.html#dual-commonjses-module-packages)
covers. The fallback stays, because the dependency that loads the other copy is not yours to fix.

The second failure is a tree that grows by status when the API fails by cause. A declined card is a
402 on one API and a 400 with a body code on another, so a class per status cannot tell the two
apart. Keep
the problem `type` and the raw body on every error, as the samples do, and add a class for a cause
only when callers branch on it. Stripe's `CardError` earned its place that way.

The third is a wrap that drops the type. In Go, `fmt.Errorf("get: %v", err)` formats the error into
text, and `errors.As` finds nothing behind it; `%w` is the one character that keeps the chain. In
JavaScript, `throw new Error(err.message)` has the same effect, and the `cause` option on `Error`
is the equivalent of `%w`.

## When not to do this

Do not give every endpoint its own error class. `DocumentNotFoundError` beside
`FolderNotFoundError` doubles the public surface for a distinction the `type` field and the request
path already make, and every class you publish is one you keep for a major version.

Do not promise anything about the message text. The message is for a log and a person, and the
moment a caller matches on it the wording is frozen. The status, the code, and the problem type are
the contract, and the sentence is a courtesy.

Do not expose a class for a status your API never returns. A tree that mirrors the HTTP status
registry documents HTTP rather than your API, and a caller handling `GoneError` for a service that
never answers 410 has handled nothing.

Do not skip the connection class. A timeout and a 503 call for the same retry and for different
alarms. An SDK that reports both as `ApiError` with no status has told its callers to parse the
message after all.

## Related how-tos

- [Evolve an error contract without breaking clients](/howto/evolve-an-error-contract-without-breaking-clients)

- [Map vendor error responses onto message errors](/howto/map-vendor-errors-onto-message-errors)

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Design error responses an autonomous agent can act on](/howto/error-responses-an-agent-can-act-on)

- [Shape an SDK around entities instead of endpoint wrappers](/howto/entity-clients-instead-of-endpoint-wrappers)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Python 3.13.16, and Go 1.24.7. Every output block is what
the command preceding it printed. The Node tests run against an in-process `node:http` server, the
Go test against `httptest`, and the Python tests against no server. The trees of stripe-python, the
OpenAI SDKs, and OpenAPI Generator's templates were read from their source files and READMEs and
were not run.

[^1]: [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) makes the `type` member the problem
type's primary identifier, and gives it a default: when the member is absent its value is assumed to
be `about:blank`. A server that sends no type has therefore sent one, and the `type` field on the
errors here carries whichever it was.

[^2]: The [Python tutorial](https://docs.python.org/3/tutorial/errors.html) states the matching
rule in one parenthesis. A class in an except clause matches instances of the class itself or of its
derived classes, but not the other way around. An except clause listing a derived class does not
match instances of its base classes. Every exception tree in Python, Stripe's included, is that one
sentence applied.

[^3]: [`errors.As`](https://pkg.go.dev/errors#As) finds the first error in the tree of `err` that
matches `target` and, if one is found, sets `target` to that error value and returns true. It arrived with Go
1.13, beside `errors.Is` and the `%w` verb, which [the Go blog](https://go.dev/blog/go1.13-errors)
introduced as features for working with errors that contain other errors. Before them, a wrapped
error was a string, and matching it meant reading the string.