How-to › Ship an SDK

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.

Audience
API producer
Level
intermediate
Topic
Design SDK ergonomics per language
Languages
TypeScript, JavaScript, Python and Go
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Go errors.As on a wrapped *APIErrorGo, where one struct with fields does the work of a class per statusA %w at every wrapping layer, and callers who must reach for errors.As rather than a type assertionCallers want the compiler to enumerate the cases, which a status field cannot give them
OpenAPI Generator ApiExceptionYou generate the SDK and take the generator’s tree as it shipsA tree you do not control: seven subclasses in Python, one ResponseError in TypeScript, and messages set by a templateYour error body carries a code callers should branch on
Stainless APIError subclassesYou want one class per status, the shape the OpenAI SDKs shipEight status classes and a connection class to keep stable, with their names decided by a vendor’s generatorA hand-written SDK, or failures better told apart by a body code than by status
Stripe exception hierarchyYou hand-write the SDK and want classes named for causes rather than statusesA class per cause that you define, document, and map from the body’s codes by handA 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.

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

  1. RFC 9457 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. ↩︎ Back to text

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

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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.