Skip to content

Commit e4a3f93

Browse files
authored
Add custom errors (#18)
* Add axios-better-stacktrace * Add SeamHttpApiError * Add SeamHttpRequestValidationError * Update SeamHttpApiError format * Export api error * Extend InvalidInputError from SeamHttpApiError * Export invalid input error * Add noop errorInterceptor * Add error interceptor * Add tests for error errorInterceptor * Do not export client * Throw original error if parsing fails * Validate request id value * Add SeamHttpUnauthorizedError * Move errors into single file * Make error response paring safe * Add error type guards * Move and export errorInterceptor * Simplify errorInterceptor * Simplify SeamHttpUnauthorizedError * Fix check
1 parent 4f3bab8 commit e4a3f93

File tree

9 files changed

+263
-17
lines changed

9 files changed

+263
-17
lines changed

package-lock.json

Lines changed: 13 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@
8787
},
8888
"dependencies": {
8989
"axios": "^1.5.0",
90+
"axios-better-stacktrace": "^2.1.5",
9091
"axios-retry": "^3.8.0"
9192
},
9293
"devDependencies": {
93-
"@seamapi/fake-seam-connect": "^1.41.4",
94+
"@seamapi/fake-seam-connect": "^1.43.0",
9495
"@seamapi/types": "^1.24.0",
9596
"@types/eslint": "^8.44.2",
9697
"@types/node": "^18.11.18",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// UPSTREAM: These types should be provided by @seamapi/types/connect.
2+
3+
export interface ApiErrorResponse {
4+
error: ApiError
5+
}
6+
7+
export interface ApiError {
8+
type: string
9+
message: string
10+
data?: unknown
11+
}

src/lib/seam/connect/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
2+
// @ts-expect-error https://github.com/svsool/axios-better-stacktrace/issues/12
3+
import axiosBetterStacktrace from 'axios-better-stacktrace'
24
import axiosRetry, { type AxiosRetry, exponentialDelay } from 'axios-retry'
35

46
import { paramsSerializer } from 'lib/params-serializer.js'
57

8+
import { errorInterceptor } from './error-interceptor.js'
9+
610
export type Client = AxiosInstance
711

812
export interface ClientOptions {
@@ -21,12 +25,16 @@ export const createClient = (options: ClientOptions): AxiosInstance => {
2125
...options.axiosOptions,
2226
})
2327

28+
axiosBetterStacktrace(axios)
29+
2430
// @ts-expect-error https://github.com/softonic/axios-retry/issues/159
2531
axiosRetry(client, {
2632
retries: 2,
2733
retryDelay: exponentialDelay,
2834
...options.axiosRetryOptions,
2935
})
3036

37+
client.interceptors.response.use(undefined, errorInterceptor)
38+
3139
return client
3240
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { type AxiosError, isAxiosError } from 'axios'
2+
3+
import type { ApiErrorResponse } from './api-error-type.js'
4+
import {
5+
SeamHttpApiError,
6+
SeamHttpInvalidInputError,
7+
SeamHttpUnauthorizedError,
8+
} from './seam-http-error.js'
9+
10+
export const errorInterceptor = async (err: unknown): Promise<void> => {
11+
if (!isAxiosError(err)) throw err
12+
13+
const { response } = err
14+
const status = response?.status
15+
const headers = response?.headers
16+
const requestId = headers?.['seam-request-id'] ?? ''
17+
18+
if (status == null) throw err
19+
20+
if (status === 401) {
21+
throw new SeamHttpUnauthorizedError(requestId)
22+
}
23+
24+
if (!isApiErrorResponse(response)) throw err
25+
26+
const { type } = response.data.error
27+
28+
const args = [response.data.error, status, requestId] as const
29+
30+
if (type === 'invalid_input') throw new SeamHttpInvalidInputError(...args)
31+
throw new SeamHttpApiError(...args)
32+
}
33+
34+
const isApiErrorResponse = (
35+
response: AxiosError['response'],
36+
): response is NonNullable<AxiosError<ApiErrorResponse>['response']> => {
37+
if (response == null) return false
38+
const { headers, data } = response
39+
40+
if (headers == null) return false
41+
42+
const contentType = headers['content-type']
43+
if (
44+
typeof contentType === 'string' &&
45+
!contentType.startsWith('application/json')
46+
) {
47+
return false
48+
}
49+
50+
if (typeof data === 'object' && data != null) {
51+
return (
52+
'error' in data &&
53+
typeof data.error === 'object' &&
54+
data.error != null &&
55+
'type' in data.error &&
56+
typeof data.error.type === 'string'
57+
)
58+
}
59+
60+
return false
61+
}

src/lib/seam/connect/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
export * from './error-interceptor.js'
12
export * from './options.js'
2-
export * from './routes//index.js'
3+
export * from './routes/index.js'
34
export * from './seam-http.js'
5+
export * from './seam-http-error.js'
46
export * from 'lib/params-serializer.js'
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { ApiError } from './api-error-type.js'
2+
3+
export class SeamHttpApiError extends Error {
4+
code: string
5+
statusCode: number
6+
requestId: string
7+
data?: unknown
8+
9+
constructor(error: ApiError, statusCode: number, requestId: string) {
10+
const { type, message, data } = error
11+
super(message)
12+
this.name = this.constructor.name
13+
Error.captureStackTrace(this, this.constructor)
14+
this.code = type
15+
this.statusCode = statusCode
16+
this.requestId = requestId
17+
if (data != null) this.data = data
18+
}
19+
}
20+
21+
export const isSeamHttpApiError = (
22+
error: unknown,
23+
): error is SeamHttpApiError => {
24+
return error instanceof SeamHttpApiError
25+
}
26+
27+
export class SeamHttpUnauthorizedError extends SeamHttpApiError {
28+
override code: 'unauthorized'
29+
override statusCode: 401
30+
31+
constructor(requestId: string) {
32+
const type = 'unauthorized'
33+
const status = 401
34+
super({ type, message: 'Unauthorized' }, status, requestId)
35+
this.name = this.constructor.name
36+
Error.captureStackTrace(this, this.constructor)
37+
this.code = type
38+
this.statusCode = status
39+
this.requestId = requestId
40+
}
41+
}
42+
43+
export const isSeamHttpUnauthorizedError = (
44+
error: unknown,
45+
): error is SeamHttpUnauthorizedError => {
46+
return error instanceof SeamHttpUnauthorizedError
47+
}
48+
49+
export class SeamHttpInvalidInputError extends SeamHttpApiError {
50+
override code: 'invalid_input'
51+
52+
constructor(error: ApiError, statusCode: number, requestId: string) {
53+
super(error, statusCode, requestId)
54+
this.name = this.constructor.name
55+
Error.captureStackTrace(this, this.constructor)
56+
this.code = 'invalid_input'
57+
}
58+
}
59+
60+
export const isSeamHttpInvalidInputError = (
61+
error: unknown,
62+
): error is SeamHttpInvalidInputError => {
63+
return error instanceof SeamHttpInvalidInputError
64+
}

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import test from 'ava'
2+
import { AxiosError } from 'axios'
3+
import { getTestServer } from 'fixtures/seam/connect/api.js'
4+
5+
import {
6+
SeamHttp,
7+
SeamHttpApiError,
8+
SeamHttpInvalidInputError,
9+
SeamHttpUnauthorizedError,
10+
} from '@seamapi/http/connect'
11+
12+
test('SeamHttp: throws AxiosError on non-standard response', async (t) => {
13+
const { seed, endpoint, db } = await getTestServer(t)
14+
15+
db.simulateWorkspaceOutage(seed.seed_workspace_1, {
16+
routes: ['/devices/list'],
17+
})
18+
19+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
20+
endpoint,
21+
axiosRetryOptions: {
22+
retries: 0,
23+
},
24+
})
25+
26+
const err = await t.throwsAsync(async () => await seam.devices.list(), {
27+
instanceOf: AxiosError,
28+
})
29+
30+
t.is(err?.response?.status, 503)
31+
})
32+
33+
test('SeamHttp: throws SeamHttpUnauthorizedError if unauthorized', async (t) => {
34+
const { endpoint } = await getTestServer(t)
35+
36+
const seam = SeamHttp.fromApiKey('seam_invalid_api_key', {
37+
endpoint,
38+
axiosRetryOptions: {
39+
retries: 0,
40+
},
41+
})
42+
43+
const err = await t.throwsAsync(async () => await seam.devices.list(), {
44+
instanceOf: SeamHttpUnauthorizedError,
45+
})
46+
47+
t.is(err?.statusCode, 401)
48+
t.is(err?.code, 'unauthorized')
49+
t.true(err?.requestId?.startsWith('request'))
50+
})
51+
52+
test('SeamHttp: throws SeamHttpApiError on standard error response', async (t) => {
53+
const { seed, endpoint } = await getTestServer(t)
54+
55+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
56+
endpoint,
57+
axiosRetryOptions: {
58+
retries: 0,
59+
},
60+
})
61+
62+
const err = await t.throwsAsync(
63+
async () => await seam.devices.get({ device_id: 'unknown-device' }),
64+
{
65+
instanceOf: SeamHttpApiError,
66+
},
67+
)
68+
69+
t.is(err?.statusCode, 404)
70+
t.is(err?.code, 'device_not_found')
71+
t.true(err?.requestId?.startsWith('request'))
72+
})
73+
74+
test('SeamHttp: throws SeamHttpInvalidInputError on invalid input', async (t) => {
75+
const { seed, endpoint } = await getTestServer(t)
76+
77+
const seam = SeamHttp.fromApiKey(seed.seam_apikey1_token, {
78+
endpoint,
79+
axiosRetryOptions: {
80+
retries: 0,
81+
},
82+
})
83+
84+
const err = await t.throwsAsync(
85+
async () =>
86+
await seam.devices.client.post('/devices/list', { device_ids: 4242 }),
87+
{
88+
instanceOf: SeamHttpInvalidInputError,
89+
},
90+
)
91+
92+
t.is(err?.statusCode, 400)
93+
t.is(err?.code, 'invalid_input')
94+
t.true(err?.requestId?.startsWith('request'))
95+
})

test/seam/connect/retry.test.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ test('SeamHttp: retries 503 status errors twice by default ', async (t) => {
99
const expectedRetryCount = 2
1010

1111
db.simulateWorkspaceOutage(seed.seed_workspace_1, {
12-
routes: ['/devices/get'],
12+
routes: ['/devices/list'],
1313
})
1414

1515
t.plan(expectedRetryCount + 2)
@@ -24,16 +24,11 @@ test('SeamHttp: retries 503 status errors twice by default ', async (t) => {
2424
})
2525

2626
const err = await t.throwsAsync(
27-
async () =>
28-
// UPSTREAM: This test should use seam.devices.get({ device_id: '...' }).
29-
// Only idempotent methods, e.g., GET not POST, are retried by default.
30-
// The SDK should use GET over POST once that method is supported upstream.
31-
// https://github.com/seamapi/nextlove/issues/117
32-
await seam.client.get('/devices/get', {
33-
params: {
34-
device_id: seed.august_device_1,
35-
},
36-
}),
27+
// UPSTREAM: This test should use seam.devices.list().
28+
// Only idempotent methods, e.g., GET not POST, are retried by default.
29+
// The SDK should use GET over POST once that method is supported upstream.
30+
// https://github.com/seamapi/nextlove/issues/117
31+
async () => await seam.client.get('/devices/list'),
3732
{ instanceOf: AxiosError },
3833
)
3934

0 commit comments

Comments
 (0)