Skip to content
Merged
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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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
49 changes: 43 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 @@ -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.
*/
Expand All @@ -77,13 +96,31 @@ 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,
* 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.
Expand Down
99 changes: 98 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,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')
})
Loading