From c69ade8d537a08cbe34c3ec208b61d44f145aacc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:44:17 +0000 Subject: [PATCH 1/5] fix: Keep the failed request reachable from Seam API errors SeamHttpApiError discarded the underlying AxiosError, so the response headers, request config, and raw body were unrecoverable from a caught error. The 401 branch threw before parsing the response body, replacing the server diagnostic with a generic Unauthorized message. Validation errors were readable only by guessing a parameter name against a private field. Pass the AxiosError as the standard error cause on every Seam API error, parse the 401 response envelope when present and keep its message and data, and expose validationErrors and validationErrorParamNames on SeamHttpInvalidInputError so the failing parameters are enumerable, including nested validation errors. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/error-interceptor.ts | 8 ++- src/lib/seam-http-error.ts | 45 +++++++++++-- test/seam/connect/http-error.test.ts | 97 +++++++++++++++++++++++++++- 3 files changed, 141 insertions(+), 9 deletions(-) diff --git a/src/lib/error-interceptor.ts b/src/lib/error-interceptor.ts index ec297709..b0f05a06 100644 --- a/src/lib/error-interceptor.ts +++ b/src/lib/error-interceptor.ts @@ -18,14 +18,18 @@ export const errorInterceptor = async (err: unknown): Promise => { if (status == null) throw err if (status === 401) { - throw new SeamHttpUnauthorizedError(requestId) + throw new SeamHttpUnauthorizedError( + requestId, + isApiErrorResponse(response) ? response.data.error : undefined, + { cause: err }, + ) } if (!isApiErrorResponse(response)) throw err const { type } = response.data.error - const args = [response.data.error, status, requestId] as const + const args = [response.data.error, status, requestId, { cause: err }] as const if (type === 'invalid_input') throw new SeamHttpInvalidInputError(...args) throw new SeamHttpApiError(...args) diff --git a/src/lib/seam-http-error.ts b/src/lib/seam-http-error.ts index e16f3c2e..89461b1e 100644 --- a/src/lib/seam-http-error.ts +++ b/src/lib/seam-http-error.ts @@ -22,9 +22,14 @@ export class SeamHttpApiError extends Error { */ data?: unknown - constructor(error: ApiError, statusCode: number, requestId: string) { + constructor( + error: ApiError, + statusCode: number, + requestId: string, + options: ErrorOptions = {}, + ) { const { type, message, data } = error - super(message) + super(message, options) this.name = this.constructor.name this.code = type this.statusCode = statusCode @@ -49,10 +54,15 @@ export class SeamHttpUnauthorizedError extends SeamHttpApiError { override code: 'unauthorized' override statusCode: 401 - constructor(requestId: string) { + constructor(requestId: string, error?: ApiError, options: ErrorOptions = {}) { const type = 'unauthorized' const status = 401 - super({ type, message: 'Unauthorized' }, status, requestId) + super( + error ?? { type, message: 'Unauthorized' }, + status, + requestId, + options, + ) this.name = this.constructor.name this.code = type this.statusCode = status @@ -77,13 +87,36 @@ export class SeamHttpInvalidInputError extends SeamHttpApiError { readonly #validationErrors: NonNullable - constructor(error: ApiError, statusCode: number, requestId: string) { - super(error, statusCode, requestId) + constructor( + error: ApiError, + statusCode: number, + requestId: string, + options: ErrorOptions = {}, + ) { + super(error, statusCode, requestId, options) this.name = this.constructor.name this.code = 'invalid_input' this.#validationErrors = error.validation_errors ?? {} } + /** + * Validation errors returned by the Seam API, keyed by parameter name. + * Use this to enumerate the parameters that failed validation + * or to read nested validation errors. + */ + get validationErrors(): NonNullable { + return this.#validationErrors + } + + /** + * Names of the request parameters that failed validation. + */ + get validationErrorParamNames(): string[] { + return Object.keys(this.#validationErrors).filter( + (name) => name !== '_errors', + ) + } + /** * Returns the validation error messages for the request parameter, * or an empty array if the parameter had no validation errors. diff --git a/test/seam/connect/http-error.test.ts b/test/seam/connect/http-error.test.ts index be435869..ed0cf7b3 100644 --- a/test/seam/connect/http-error.test.ts +++ b/test/seam/connect/http-error.test.ts @@ -1,6 +1,7 @@ import test from 'ava' -import { AxiosError, AxiosHeaders } from 'axios' +import { AxiosError, AxiosHeaders, isAxiosError } from 'axios' import { getTestServer } from 'fixtures/seam/connect/api.js' +import nock from 'nock' import { errorInterceptor, @@ -126,4 +127,98 @@ test('SeamHttp: throws SeamHttpInvalidInputError on invalid input', async (t) => t.deepEqual(err?.getValidationErrorMessages('device_ids'), [ 'Expected array, received number', ]) + t.deepEqual(err?.validationErrorParamNames, ['device_ids']) + t.deepEqual(err?.validationErrors['device_ids']?._errors, [ + 'Expected array, received number', + ]) +}) + +test('SeamHttp: errors retain the AxiosError as cause', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + axiosRetryOptions: { + retries: 0, + }, + }) + + const err = await t.throwsAsync( + async () => await seam.devices.get({ device_id: 'unknown-device' }), + { + instanceOf: SeamHttpApiError, + }, + ) + + if (!isAxiosError(err?.cause)) { + t.fail('Expected cause to be the AxiosError') + return + } + + t.is(err.cause.response?.status, 404) + t.truthy(err.cause.config) +}) + +test('SeamHttp: unauthorized error surfaces the API error message', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + axiosRetryOptions: { + retries: 0, + }, + }) + + nock(endpoint) + .get('/devices/get') + .query(true) + .reply( + 401, + { + error: { + type: 'unauthorized', + message: 'Custom unauthorized message from the server', + }, + }, + { 'Content-Type': 'application/json', 'seam-request-id': 'request-1' }, + ) + + const err = await t.throwsAsync( + async () => await seam.devices.get({ device_id: 'unknown-device' }), + { + instanceOf: SeamHttpUnauthorizedError, + message: 'Custom unauthorized message from the server', + }, + ) + + t.is(err?.code, 'unauthorized') + t.is(err?.statusCode, 401) + t.is(err?.requestId, 'request-1') + t.true(isAxiosError(err?.cause)) +}) + +test('SeamHttp: unauthorized error falls back without an API error body', async (t) => { + const { seed, endpoint } = await getTestServer(t) + + const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, { + endpoint, + axiosRetryOptions: { + retries: 0, + }, + }) + + nock(endpoint) + .get('/devices/get') + .query(true) + .reply(401, 'Unauthorized', { 'Content-Type': 'text/plain' }) + + const err = await t.throwsAsync( + async () => await seam.devices.get({ device_id: 'unknown-device' }), + { + instanceOf: SeamHttpUnauthorizedError, + message: 'Unauthorized', + }, + ) + + t.is(err?.code, 'unauthorized') }) From ca4139f211f8234609127025b5da71ccb8a086ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 23:29:43 +0000 Subject: [PATCH 2/5] docs: Trim the validationErrors doc Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/seam-http-error.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/seam-http-error.ts b/src/lib/seam-http-error.ts index cd3f6c23..a0a73a85 100644 --- a/src/lib/seam-http-error.ts +++ b/src/lib/seam-http-error.ts @@ -101,8 +101,6 @@ export class SeamHttpInvalidInputError extends SeamHttpApiError { /** * Validation errors returned by the Seam API, keyed by parameter name. - * Use this to enumerate the parameters that failed validation - * or to read nested validation errors. */ get validationErrors(): NonNullable { return this.#validationErrors From a576aee73c681057ba114016dc269b6b41f864a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:32:35 +0000 Subject: [PATCH 3/5] refactor: Drop the redundant validationErrorParamNames getter Object.keys(validationErrors) gives the same list, so the getter only filtered the _errors key. Document that key on validationErrors instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/seam-http-error.ts | 10 +--------- test/seam/connect/http-error.test.ts | 1 - 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/lib/seam-http-error.ts b/src/lib/seam-http-error.ts index a0a73a85..970fc4cf 100644 --- a/src/lib/seam-http-error.ts +++ b/src/lib/seam-http-error.ts @@ -101,20 +101,12 @@ export class SeamHttpInvalidInputError extends SeamHttpApiError { /** * Validation errors returned by the Seam API, keyed by parameter name. + * The `_errors` key holds errors that apply to the request as a whole. */ get validationErrors(): NonNullable { return this.#validationErrors } - /** - * Names of the request parameters that failed validation. - */ - get validationErrorParamNames(): string[] { - return Object.keys(this.#validationErrors).filter( - (name) => name !== '_errors', - ) - } - /** * Returns the validation error messages for the request parameter, * or an empty array if the parameter had no validation errors. diff --git a/test/seam/connect/http-error.test.ts b/test/seam/connect/http-error.test.ts index ed0cf7b3..30357cc2 100644 --- a/test/seam/connect/http-error.test.ts +++ b/test/seam/connect/http-error.test.ts @@ -127,7 +127,6 @@ test('SeamHttp: throws SeamHttpInvalidInputError on invalid input', async (t) => t.deepEqual(err?.getValidationErrorMessages('device_ids'), [ 'Expected array, received number', ]) - t.deepEqual(err?.validationErrorParamNames, ['device_ids']) t.deepEqual(err?.validationErrors['device_ids']?._errors, [ 'Expected array, received number', ]) From 87df0cda928762136cf8885fefaeef9c3518e0b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:38:34 +0000 Subject: [PATCH 4/5] refactor: Return validation errors as a list of parameter errors Object.entries over a record whose values wrap a _errors array leaked the wire format into the public API. Return SeamValidationError entries of parameterName and errorMessages instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- src/lib/seam-http-error.ts | 22 ++++++++++++++++++---- test/seam/connect/http-error.test.ts | 7 +++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/lib/seam-http-error.ts b/src/lib/seam-http-error.ts index 970fc4cf..b63fa700 100644 --- a/src/lib/seam-http-error.ts +++ b/src/lib/seam-http-error.ts @@ -79,6 +79,15 @@ export const isSeamHttpUnauthorizedError = ( return error instanceof SeamHttpUnauthorizedError } +/** + * A request parameter that failed validation, + * along with the messages explaining why. + */ +export interface SeamValidationError { + parameterName: string + errorMessages: string[] +} + /** * Error thrown when the Seam API returns an `invalid_input` error response. */ @@ -100,11 +109,16 @@ export class SeamHttpInvalidInputError extends SeamHttpApiError { } /** - * Validation errors returned by the Seam API, keyed by parameter name. - * The `_errors` key holds errors that apply to the request as a whole. + * Validation errors returned by the Seam API, + * one entry per request parameter that failed validation. */ - get validationErrors(): NonNullable { - return this.#validationErrors + get validationErrors(): SeamValidationError[] { + return Object.entries(this.#validationErrors) + .filter(([parameterName]) => parameterName !== '_errors') + .map(([parameterName, { _errors }]) => ({ + parameterName, + errorMessages: _errors, + })) } /** diff --git a/test/seam/connect/http-error.test.ts b/test/seam/connect/http-error.test.ts index 30357cc2..7366c072 100644 --- a/test/seam/connect/http-error.test.ts +++ b/test/seam/connect/http-error.test.ts @@ -127,8 +127,11 @@ test('SeamHttp: throws SeamHttpInvalidInputError on invalid input', async (t) => t.deepEqual(err?.getValidationErrorMessages('device_ids'), [ 'Expected array, received number', ]) - t.deepEqual(err?.validationErrors['device_ids']?._errors, [ - 'Expected array, received number', + t.deepEqual(err?.validationErrors, [ + { + parameterName: 'device_ids', + errorMessages: ['Expected array, received number'], + }, ]) }) From 93f0a81ca0ffb42f43efcb4b0f57cb44dad56418 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:48:49 +0000 Subject: [PATCH 5/5] docs: Document the error and validation error API Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index 853ebcc7..7a2c1372 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,43 @@ const pages = seam.createPaginator( const devices = await pages.flattenToArray() ``` +### Error Handling + +Requests rejected by the Seam API throw a `SeamHttpApiError` subclass +carrying the `statusCode`, the API error `code`, and the `requestId`. +The originating Axios error is retained as the standard `cause`. + +#### Validation errors + +When the API rejects a request because a parameter is invalid, +it throws a `SeamHttpInvalidInputError`. + +Look up the messages for a parameter you are already rendering, +for example a field in a form: + +```ts +import { isSeamHttpInvalidInputError } from '@seamapi/http' + +try { + await seam.devices.list({ device_ids: ['not-a-uuid'] }) +} catch (err) { + if (isSeamHttpInvalidInputError(err)) { + console.log(err.getValidationErrorMessages('device_ids')) + } +} +``` + +Or read every parameter that failed validation, +for example to show a summary of what went wrong: + +```ts +if (isSeamHttpInvalidInputError(err)) { + for (const { parameterName, errorMessages } of err.validationErrors) { + console.log(`${parameterName}: ${errorMessages.join(', ')}`) + } +} +``` + ### Requests without a Workspace in scope Some Seam API endpoints do not require a workspace in scope.