Your SDK throws one error type for everything, and the status lives in the message. A caller who
wants to retry on 429 and give up on 404 writes err.message.includes('404'), which breaks the day
you reword the message. The request id the server sent back was in a header nobody kept, so support
cannot find the call either.
What you get
You will end up with one small error hierarchy in Node, Python, and Go, carrying status, a stable code, the request id, and the raw body, with a table test per language. This is for you if your SDK’s callers parse messages.
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 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 | 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 | 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 | 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 | 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
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.
// 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.
node demo.mjs
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
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:
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.
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"
python3 demo_errors.py
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
python3 test_errors.py
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
// 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)
}
go test -v
=== 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 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
node --test errors.test.mjs
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.
node pitfall.mjs
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
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
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.
Footnotes
-
RFC 9457 makes the
typemember the problem type’s primary identifier, and gives it a default: when the member is absent its value is assumed to beabout:blank. A server that sends no type has therefore sent one, and thetypefield on the errors here carries whichever it was. ↩︎ Back to text -
The Python tutorial 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. ↩︎ Back to text
-
errors.Asfinds the first error in the tree oferrthat matchestargetand, if one is found, setstargetto that error value and returns true. It arrived with Go 1.13, besideerrors.Isand the%wverb, which the Go blog 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. ↩︎ Back to text