Skip to content

Commit 596df12

Browse files
razor-xclaude
andauthored
fix: Keep the failed request reachable from Seam API errors (#1010)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 * docs: Trim the validationErrors doc Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 * refactor: Drop the redundant validationErrorParamNames getter Object.keys(validationErrors) gives the same list, so the getter only filtered the _errors key. Document that key on validationErrors instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 * refactor: Return validation errors as a list of parameter errors Object.entries over a record whose values wrap a _errors array leaked the wire format into the public API. Return SeamValidationError entries of parameterName and errorMessages instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 * docs: Document the error and validation error API Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8xeJm2Hd923k8uo6eoFd2 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf4ca4e commit 596df12

4 files changed

Lines changed: 184 additions & 9 deletions

File tree

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,43 @@ const pages = seam.createPaginator(
371371
const devices = await pages.flattenToArray()
372372
```
373373

374+
### Error Handling
375+
376+
Requests rejected by the Seam API throw a `SeamHttpApiError` subclass
377+
carrying the `statusCode`, the API error `code`, and the `requestId`.
378+
The originating Axios error is retained as the standard `cause`.
379+
380+
#### Validation errors
381+
382+
When the API rejects a request because a parameter is invalid,
383+
it throws a `SeamHttpInvalidInputError`.
384+
385+
Look up the messages for a parameter you are already rendering,
386+
for example a field in a form:
387+
388+
```ts
389+
import { isSeamHttpInvalidInputError } from '@seamapi/http'
390+
391+
try {
392+
await seam.devices.list({ device_ids: ['not-a-uuid'] })
393+
} catch (err) {
394+
if (isSeamHttpInvalidInputError(err)) {
395+
console.log(err.getValidationErrorMessages('device_ids'))
396+
}
397+
}
398+
```
399+
400+
Or read every parameter that failed validation,
401+
for example to show a summary of what went wrong:
402+
403+
```ts
404+
if (isSeamHttpInvalidInputError(err)) {
405+
for (const { parameterName, errorMessages } of err.validationErrors) {
406+
console.log(`${parameterName}: ${errorMessages.join(', ')}`)
407+
}
408+
}
409+
```
410+
374411
### Requests without a Workspace in scope
375412

376413
Some Seam API endpoints do not require a workspace in scope.

src/lib/error-interceptor.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,18 @@ export const errorInterceptor = async (err: unknown): Promise<void> => {
1818
if (status == null) throw err
1919

2020
if (status === 401) {
21-
throw new SeamHttpUnauthorizedError(requestId)
21+
throw new SeamHttpUnauthorizedError(
22+
requestId,
23+
isApiErrorResponse(response) ? response.data.error : undefined,
24+
{ cause: err },
25+
)
2226
}
2327

2428
if (!isApiErrorResponse(response)) throw err
2529

2630
const { type } = response.data.error
2731

28-
const args = [response.data.error, status, requestId] as const
32+
const args = [response.data.error, status, requestId, { cause: err }] as const
2933

3034
if (type === 'invalid_input') throw new SeamHttpInvalidInputError(...args)
3135
throw new SeamHttpApiError(...args)

src/lib/seam-http-error.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ export class SeamHttpApiError extends Error {
2222
*/
2323
data?: unknown
2424

25-
constructor(error: ApiError, statusCode: number, requestId: string) {
25+
constructor(
26+
error: ApiError,
27+
statusCode: number,
28+
requestId: string,
29+
options: ErrorOptions = {},
30+
) {
2631
const { type, message, data } = error
27-
super(message)
32+
super(message, options)
2833
this.name = this.constructor.name
2934
this.code = type
3035
this.statusCode = statusCode
@@ -49,10 +54,15 @@ export class SeamHttpUnauthorizedError extends SeamHttpApiError {
4954
override code: 'unauthorized'
5055
override statusCode: 401
5156

52-
constructor(requestId: string) {
57+
constructor(requestId: string, error?: ApiError, options: ErrorOptions = {}) {
5358
const type = 'unauthorized'
5459
const status = 401
55-
super({ type, message: 'Unauthorized' }, status, requestId)
60+
super(
61+
error ?? { type, message: 'Unauthorized' },
62+
status,
63+
requestId,
64+
options,
65+
)
5666
this.name = this.constructor.name
5767
this.code = type
5868
this.statusCode = status
@@ -69,6 +79,15 @@ export const isSeamHttpUnauthorizedError = (
6979
return error instanceof SeamHttpUnauthorizedError
7080
}
7181

82+
/**
83+
* A request parameter that failed validation,
84+
* along with the messages explaining why.
85+
*/
86+
export interface SeamValidationError {
87+
parameterName: string
88+
errorMessages: string[]
89+
}
90+
7291
/**
7392
* Error thrown when the Seam API returns an `invalid_input` error response.
7493
*/
@@ -77,13 +96,31 @@ export class SeamHttpInvalidInputError extends SeamHttpApiError {
7796

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

80-
constructor(error: ApiError, statusCode: number, requestId: string) {
81-
super(error, statusCode, requestId)
99+
constructor(
100+
error: ApiError,
101+
statusCode: number,
102+
requestId: string,
103+
options: ErrorOptions = {},
104+
) {
105+
super(error, statusCode, requestId, options)
82106
this.name = this.constructor.name
83107
this.code = 'invalid_input'
84108
this.#validationErrors = error.validation_errors ?? {}
85109
}
86110

111+
/**
112+
* Validation errors returned by the Seam API,
113+
* one entry per request parameter that failed validation.
114+
*/
115+
get validationErrors(): SeamValidationError[] {
116+
return Object.entries(this.#validationErrors)
117+
.filter(([parameterName]) => parameterName !== '_errors')
118+
.map(([parameterName, { _errors }]) => ({
119+
parameterName,
120+
errorMessages: _errors,
121+
}))
122+
}
123+
87124
/**
88125
* Returns the validation error messages for the request parameter,
89126
* or an empty array if the parameter had no validation errors.

test/seam/connect/http-error.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import test from 'ava'
2-
import { AxiosError, AxiosHeaders } from 'axios'
2+
import { AxiosError, AxiosHeaders, isAxiosError } from 'axios'
33
import { getTestServer } from 'fixtures/seam/connect/api.js'
4+
import nock from 'nock'
45

56
import {
67
errorInterceptor,
@@ -126,4 +127,100 @@ test('SeamHttp: throws SeamHttpInvalidInputError on invalid input', async (t) =>
126127
t.deepEqual(err?.getValidationErrorMessages('device_ids'), [
127128
'Expected array, received number',
128129
])
130+
t.deepEqual(err?.validationErrors, [
131+
{
132+
parameterName: 'device_ids',
133+
errorMessages: ['Expected array, received number'],
134+
},
135+
])
136+
})
137+
138+
test('SeamHttp: errors retain the AxiosError as cause', async (t) => {
139+
const { seed, endpoint } = await getTestServer(t)
140+
141+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
142+
endpoint,
143+
axiosRetryOptions: {
144+
retries: 0,
145+
},
146+
})
147+
148+
const err = await t.throwsAsync(
149+
async () => await seam.devices.get({ device_id: 'unknown-device' }),
150+
{
151+
instanceOf: SeamHttpApiError,
152+
},
153+
)
154+
155+
if (!isAxiosError(err?.cause)) {
156+
t.fail('Expected cause to be the AxiosError')
157+
return
158+
}
159+
160+
t.is(err.cause.response?.status, 404)
161+
t.truthy(err.cause.config)
162+
})
163+
164+
test('SeamHttp: unauthorized error surfaces the API error message', async (t) => {
165+
const { seed, endpoint } = await getTestServer(t)
166+
167+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
168+
endpoint,
169+
axiosRetryOptions: {
170+
retries: 0,
171+
},
172+
})
173+
174+
nock(endpoint)
175+
.get('/devices/get')
176+
.query(true)
177+
.reply(
178+
401,
179+
{
180+
error: {
181+
type: 'unauthorized',
182+
message: 'Custom unauthorized message from the server',
183+
},
184+
},
185+
{ 'Content-Type': 'application/json', 'seam-request-id': 'request-1' },
186+
)
187+
188+
const err = await t.throwsAsync(
189+
async () => await seam.devices.get({ device_id: 'unknown-device' }),
190+
{
191+
instanceOf: SeamHttpUnauthorizedError,
192+
message: 'Custom unauthorized message from the server',
193+
},
194+
)
195+
196+
t.is(err?.code, 'unauthorized')
197+
t.is(err?.statusCode, 401)
198+
t.is(err?.requestId, 'request-1')
199+
t.true(isAxiosError(err?.cause))
200+
})
201+
202+
test('SeamHttp: unauthorized error falls back without an API error body', async (t) => {
203+
const { seed, endpoint } = await getTestServer(t)
204+
205+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
206+
endpoint,
207+
axiosRetryOptions: {
208+
retries: 0,
209+
},
210+
})
211+
212+
nock(endpoint)
213+
.get('/devices/get')
214+
.query(true)
215+
.reply(401, 'Unauthorized', { 'Content-Type': 'text/plain' })
216+
217+
const err = await t.throwsAsync(
218+
async () => await seam.devices.get({ device_id: 'unknown-device' }),
219+
{
220+
instanceOf: SeamHttpUnauthorizedError,
221+
message: 'Unauthorized',
222+
},
223+
)
224+
225+
t.is(err?.code, 'unauthorized')
129226
})

0 commit comments

Comments
 (0)