From c69ade8d537a08cbe34c3ec208b61d44f145aacc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Aug 2026 19:44:17 +0000 Subject: [PATCH 1/2] 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/2] 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