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. 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..b63fa700 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 @@ -69,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. */ @@ -77,13 +96,31 @@ 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, + * one entry per request parameter that failed validation. + */ + get validationErrors(): SeamValidationError[] { + return Object.entries(this.#validationErrors) + .filter(([parameterName]) => parameterName !== '_errors') + .map(([parameterName, { _errors }]) => ({ + parameterName, + errorMessages: _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..7366c072 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,100 @@ test('SeamHttp: throws SeamHttpInvalidInputError on invalid input', async (t) => t.deepEqual(err?.getValidationErrorMessages('device_ids'), [ 'Expected array, received number', ]) + t.deepEqual(err?.validationErrors, [ + { + parameterName: 'device_ids', + errorMessages: ['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') })