Skip to content

Commit 0034ec1

Browse files
committed
chore: add error handler
1 parent eec7c4e commit 0034ec1

File tree

3 files changed

+208
-0
lines changed

3 files changed

+208
-0
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import '@/apps'
2+
3+
import type { IGlobalVariable } from '@plumber/types'
4+
5+
import {
6+
AxiosError,
7+
type AxiosPromise,
8+
type AxiosResponse,
9+
type InternalAxiosRequestConfig,
10+
} from 'axios'
11+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12+
13+
import HttpError from '@/errors/http'
14+
import RetriableError, { DEFAULT_DELAY_MS } from '@/errors/retriable-error'
15+
import createHttpClient, { type IHttpClient } from '@/helpers/http-client'
16+
17+
import aisayApp from '../..'
18+
19+
function mockAxiosAdapterToThrowOnce(
20+
status: AxiosResponse['status'],
21+
headers?: AxiosResponse['headers'],
22+
): void {
23+
mocks.axiosAdapter.mockImplementationOnce((config) => {
24+
throw new AxiosError(
25+
'Request failed',
26+
AxiosError.ERR_BAD_RESPONSE,
27+
config,
28+
null,
29+
{
30+
status,
31+
headers,
32+
config,
33+
} as unknown as AxiosResponse,
34+
)
35+
})
36+
}
37+
38+
const mocks = vi.hoisted(() => ({
39+
axiosAdapter: vi.fn(
40+
async (config: InternalAxiosRequestConfig): AxiosPromise => ({
41+
data: 'test-data',
42+
status: 200,
43+
statusText: 'OK',
44+
headers: {},
45+
config,
46+
}),
47+
),
48+
logWarning: vi.fn(),
49+
logError: vi.fn(),
50+
}))
51+
52+
vi.mock('axios', async (importOriginal) => {
53+
const actualAxios = await importOriginal<typeof import('axios')>()
54+
const mockCreate: typeof actualAxios.default.create = (createConfig) =>
55+
actualAxios.default.create({
56+
...createConfig,
57+
adapter: mocks.axiosAdapter,
58+
})
59+
60+
return {
61+
...actualAxios,
62+
default: {
63+
...actualAxios.default,
64+
create: mockCreate,
65+
},
66+
}
67+
})
68+
69+
vi.mock('@/helpers/logger', () => ({
70+
default: {
71+
warn: mocks.logWarning,
72+
error: mocks.logError,
73+
},
74+
}))
75+
76+
describe('AISAY request error handlers', () => {
77+
let http: IHttpClient
78+
79+
beforeEach(() => {
80+
const $ = {
81+
auth: {
82+
data: {
83+
clientId: 'some-client-key',
84+
clientSecret: 'some-client-secret',
85+
},
86+
},
87+
} as unknown as IGlobalVariable
88+
http = createHttpClient({
89+
$,
90+
baseURL: 'http://localhost/mock-aisay-api',
91+
beforeRequest: [],
92+
requestErrorHandler: aisayApp.requestErrorHandler,
93+
})
94+
})
95+
96+
afterEach(() => {
97+
vi.restoreAllMocks()
98+
})
99+
100+
it('logs a warning and throws a RetriableError with default step delay on 503', async () => {
101+
mockAxiosAdapterToThrowOnce(503)
102+
await http
103+
.get('/test-url')
104+
.then(() => {
105+
expect.unreachable()
106+
})
107+
.catch((error): void => {
108+
expect(error).toBeInstanceOf(RetriableError)
109+
expect(error.delayType).toEqual('step')
110+
expect(error.delayInMs).toEqual(DEFAULT_DELAY_MS)
111+
})
112+
expect(mocks.logWarning).toHaveBeenCalledWith(
113+
expect.stringContaining(`HTTP 503`),
114+
expect.objectContaining({ event: `aisay-http-503` }),
115+
)
116+
})
117+
118+
it('logs a warning and throws a RetriableError with step delay set to retry-after, on receiving 503', async () => {
119+
mockAxiosAdapterToThrowOnce(503, { 'retry-after': 234 })
120+
await http
121+
.get('/test-url')
122+
.then(() => {
123+
expect.unreachable()
124+
})
125+
.catch((error): void => {
126+
expect(error).toBeInstanceOf(RetriableError)
127+
expect(error.delayType).toEqual('step')
128+
expect(error.delayInMs).toEqual(234000)
129+
})
130+
expect(mocks.logWarning).toHaveBeenCalledWith(
131+
expect.stringContaining(`HTTP 503`),
132+
expect.objectContaining({ event: `aisay-http-503` }),
133+
)
134+
})
135+
136+
it('logs a warning and still throws a RetriableError with default step delay on 503, if response has invalid retry-after', async () => {
137+
mockAxiosAdapterToThrowOnce(503, { 'retry-after': 'corrupted' })
138+
await http
139+
.get('/test-url')
140+
.then(() => {
141+
expect.unreachable()
142+
})
143+
.catch((error): void => {
144+
expect(error).toBeInstanceOf(RetriableError)
145+
expect(error.delayType).toEqual('step')
146+
expect(error.delayInMs).toEqual(DEFAULT_DELAY_MS)
147+
})
148+
expect(mocks.logWarning).toHaveBeenCalledWith(
149+
expect.stringContaining('HTTP 503'),
150+
expect.objectContaining({ event: 'aisay-http-503' }),
151+
)
152+
})
153+
154+
it('throws HTTP error on other non-successful codes', async () => {
155+
mockAxiosAdapterToThrowOnce(501, { 'retry-after': 123 })
156+
await expect(http.get('/test-url')).rejects.toThrow(HttpError)
157+
})
158+
159+
it('does not throw error if response is success', async () => {
160+
await expect(http.get('/test-url')).resolves.toEqual(
161+
expect.objectContaining({ data: 'test-data', status: 200 }),
162+
)
163+
})
164+
})
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { IApp } from '@plumber/types'
2+
3+
import RetriableError from '@/errors/retriable-error'
4+
import logger from '@/helpers/logger'
5+
import { parseRetryAfterToMs } from '@/helpers/parse-retry-after-to-ms'
6+
7+
type ThrowingHandler = (
8+
...args: Parameters<IApp['requestErrorHandler']>
9+
) => never
10+
11+
// Retry failures
12+
const handle503: ThrowingHandler = function ($, error) {
13+
const status = error.response.status
14+
logger.warn('Received HTTP 503 from AISAY', {
15+
event: 'aisay-http-503',
16+
clientId: $.auth.data.clientId,
17+
baseUrl: error.response.config.baseURL,
18+
url: error.response.config.url,
19+
flowId: $.flow?.id,
20+
stepId: $.step?.id,
21+
executionId: $.execution?.id,
22+
retryAfterMs: error.response?.headers?.['retry-after'],
23+
})
24+
25+
// AISAY will specify a Retry-After header when it returns 503.
26+
const retryAfterMs =
27+
parseRetryAfterToMs(error.response?.headers?.['retry-after']) ?? 'default'
28+
throw new RetriableError({
29+
error: `Encountered HTTP ${status} from AISAY`,
30+
delayInMs: retryAfterMs,
31+
delayType: 'step',
32+
})
33+
}
34+
35+
const errorHandler: IApp['requestErrorHandler'] = async function ($, error) {
36+
switch (error.response.status) {
37+
case 503:
38+
return handle503($, error)
39+
}
40+
}
41+
42+
export default errorHandler

packages/backend/src/apps/aisay/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { IApp } from '@plumber/types'
22

3+
import requestErrorHandler from './common/interceptors/request-error-handler'
34
import actions from './actions'
45
import auth from './auth'
56
import queue from './queue'
@@ -16,6 +17,7 @@ const app: IApp = {
1617
apiBaseUrl: '',
1718
primaryColor: '0059F7',
1819
actions,
20+
requestErrorHandler,
1921
substepLabels: {
2022
connectionStepLabel: 'Connect your AISAY account',
2123
settingsStepLabel: 'Set up step',

0 commit comments

Comments
 (0)