Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/lib/error-interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ export const errorInterceptor = async (err: unknown): Promise<void> => {
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)
Expand Down
43 changes: 37 additions & 6 deletions src/lib/seam-http-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -77,13 +87,34 @@ export class SeamHttpInvalidInputError extends SeamHttpApiError {

readonly #validationErrors: NonNullable<ApiError['validation_errors']>

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<ApiError['validation_errors']> {
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.
Expand Down
97 changes: 96 additions & 1 deletion test/seam/connect/http-error.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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')
})
Loading