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 e6450e3a..a0a73a85 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,34 @@ 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. + */ + 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') })