Skip to content

Commit c2a2fa4

Browse files
authored
PLU-451 - [AISAY-3] - Error handling and retry (#927)
## TL;DR AISAY App Enhancements - Create AISAY per-connection ID queue - Add request error handler with retry logic for 429s and 503s - Refactor error handling with error parser - Fix checks for client ID and secret ## How to test? - [ ] Missing connection - Set up AISAY action without selecting a connection - Test step - Verify error message - [ ] AISAY queue - Login as admin - Verify that AISAY app queue was created - [ ] AISAY queue 2 - Publish a pipe with AISAY action - Submit from to start execution - Verify that AISAY actions were added to queue ## Before & After Screenshots ### Error message when no connection is selected ![Screenshot 2025-04-07 at 10.02.52 AM.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/Wrwhm8Mmhv1Z2GwlSb7V/416201f0-bc41-446e-9858-1b2b5d99fa9d.png) ### AISAY specific queue ![Screenshot 2025-04-07 at 11.45.23 AM.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/Wrwhm8Mmhv1Z2GwlSb7V/aa4dd6af-3812-412e-92c9-7ce550e459d2.png)
1 parent f237468 commit c2a2fa4

File tree

10 files changed

+343
-17
lines changed

10 files changed

+343
-17
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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 an error and jams the entire queue on 429', async () => {
101+
mockAxiosAdapterToThrowOnce(429, { 'retry-after': 123 })
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('queue')
110+
expect(error.message).toEqual('Rate limited by AISAY.')
111+
})
112+
expect(mocks.logError).toHaveBeenCalledWith(
113+
expect.stringContaining('HTTP 429'),
114+
expect.objectContaining({ event: 'aisay-http-429' }),
115+
)
116+
})
117+
118+
it('logs a warning and throws a RetriableError with default step delay on 503', async () => {
119+
mockAxiosAdapterToThrowOnce(503)
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(DEFAULT_DELAY_MS)
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 throws a RetriableError with step delay set to retry-after, on receiving 503', async () => {
137+
mockAxiosAdapterToThrowOnce(503, { 'retry-after': 234 })
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(234000)
147+
})
148+
expect(mocks.logWarning).toHaveBeenCalledWith(
149+
expect.stringContaining(`HTTP 503`),
150+
expect.objectContaining({ event: `aisay-http-503` }),
151+
)
152+
})
153+
154+
it('logs a warning and still throws a RetriableError with default step delay on 503, if response has invalid retry-after', async () => {
155+
mockAxiosAdapterToThrowOnce(503, { 'retry-after': 'corrupted' })
156+
await http
157+
.get('/test-url')
158+
.then(() => {
159+
expect.unreachable()
160+
})
161+
.catch((error): void => {
162+
expect(error).toBeInstanceOf(RetriableError)
163+
expect(error.delayType).toEqual('step')
164+
expect(error.delayInMs).toEqual(DEFAULT_DELAY_MS)
165+
})
166+
expect(mocks.logWarning).toHaveBeenCalledWith(
167+
expect.stringContaining('HTTP 503'),
168+
expect.objectContaining({ event: 'aisay-http-503' }),
169+
)
170+
})
171+
172+
it('throws HTTP error on other non-successful codes', async () => {
173+
mockAxiosAdapterToThrowOnce(501, { 'retry-after': 123 })
174+
await expect(http.get('/test-url')).rejects.toThrow(HttpError)
175+
})
176+
177+
it('does not throw error if response is success', async () => {
178+
await expect(http.get('/test-url')).resolves.toEqual(
179+
expect.objectContaining({ data: 'test-data', status: 200 }),
180+
)
181+
})
182+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import '@/apps'
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
5+
import aisayApp from '..'
6+
7+
const mocks = vi.hoisted(() => ({
8+
stepQueryResult: vi.fn(),
9+
}))
10+
11+
vi.mock('@/models/step', () => ({
12+
default: {
13+
query: vi.fn(() => ({
14+
findById: vi.fn(() => ({
15+
throwIfNotFound: mocks.stepQueryResult,
16+
})),
17+
})),
18+
},
19+
}))
20+
21+
describe('AISAY Queue', () => {
22+
afterEach(() => {
23+
vi.restoreAllMocks()
24+
})
25+
26+
it('sets group ID to the connection ID', async () => {
27+
mocks.stepQueryResult.mockResolvedValueOnce({
28+
connectionId: 'mock-connection-id',
29+
key: 'useGeneralisedModel',
30+
appKey: 'aisay',
31+
})
32+
const groupConfig = await aisayApp.queue.getGroupConfigForJob({
33+
flowId: 'test-flow-id',
34+
stepId: 'test-step-id',
35+
executionId: 'test-step-id',
36+
})
37+
expect(groupConfig).toEqual({
38+
id: 'mock-connection-id',
39+
})
40+
})
41+
42+
it('sets group concurrency to 1', () => {
43+
expect(aisayApp.queue.groupLimits).toEqual({
44+
type: 'concurrency',
45+
concurrency: 1,
46+
})
47+
})
48+
})

packages/backend/src/apps/aisay/actions/use-generalised-model/get-data-out-metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ async function getDataOutMetadata(
2525
return {
2626
quota: {
2727
label: 'Quota',
28+
isHidden: true,
2829
},
2930
fields: fieldsMetadata,
3031
}

packages/backend/src/apps/aisay/actions/use-generalised-model/index.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import StepError from '@/errors/step'
44
import Step from '@/models/step'
55

66
import { getToken } from '../../auth/get-token'
7+
import { parseError } from '../../common/error-parser'
78
import { getAttachmentsFromS3, getValidationError } from '../../common/utils'
89

910
import getDataOutMetadata from './get-data-out-metadata'
@@ -56,7 +57,7 @@ const action: IRawAction = {
5657
infoToExtract: Array<{ infoToExtract: string }>
5758
}
5859

59-
if (!$.auth.data.clientId || !$.auth.data.clientSecret) {
60+
if (!$.auth.data?.clientId || !$.auth.data?.clientSecret) {
6061
throw new StepError(
6162
'Missing client ID or client secret',
6263
'Please check the client ID and client secret',
@@ -116,21 +117,13 @@ const action: IRawAction = {
116117
$.setActionItem({ raw: { ...res.data } })
117118
} catch (err) {
118119
console.error(err)
119-
if (err.response.data.message === `Request Too Long`) {
120-
throw new StepError(
121-
'File too large',
122-
'Please try again with a smaller file.',
123-
$.step.position,
124-
$.app.name,
125-
)
126-
} else {
127-
throw new StepError(
128-
'Failed to call generalised model',
129-
'Please try again.',
130-
$.step.position,
131-
$.app.name,
132-
)
133-
}
120+
const { stepErrorName, stepErrorSolution } = parseError(err)
121+
throw new StepError(
122+
stepErrorName,
123+
stepErrorSolution,
124+
$.step.position,
125+
$.app.name,
126+
)
134127
}
135128
},
136129
} satisfies IRawAction

packages/backend/src/apps/aisay/actions/use-specific-model/get-data-out-metadata.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ async function getDataOutMetadata(
1616
return {
1717
quota: {
1818
label: 'Quota',
19+
isHidden: true,
1920
},
2021
documentType: {
2122
label: 'Document Type',

packages/backend/src/apps/aisay/actions/use-specific-model/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const action: IRawAction = {
5454
documentType: string
5555
}
5656

57-
if (!$.auth.data.clientId || !$.auth.data.clientSecret) {
57+
if (!$.auth.data?.clientId || !$.auth.data?.clientSecret) {
5858
throw new StepError(
5959
'Missing client ID or client secret',
6060
'Please check the client ID and client secret',
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const MAX_FILE_SIZE = 6 * 1024 * 1024 // 6 MB
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
// handle rate limiting
12+
const handle429: ThrowingHandler = function ($, error) {
13+
const retryAfterMs =
14+
parseRetryAfterToMs(error.response?.headers?.['retry-after']) ?? 'default'
15+
16+
logger.error('Received HTTP 429 from AISAY', {
17+
event: 'aisay-http-429',
18+
clientId: $.auth.data.clientId,
19+
baseUrl: error.response.config.baseURL,
20+
url: error.response.config.url,
21+
flowId: $.flow?.id,
22+
stepId: $.step?.id,
23+
executionId: $.execution?.id,
24+
retryAfterMs: error.response?.headers?.['retry-after'],
25+
})
26+
27+
throw new RetriableError({
28+
error: 'Rate limited by AISAY.',
29+
delayInMs: retryAfterMs,
30+
delayType: 'queue',
31+
})
32+
}
33+
34+
// Retry failures
35+
const handle503: ThrowingHandler = function ($, error) {
36+
const status = error.response.status
37+
logger.warn('Received HTTP 503 from AISAY', {
38+
event: 'aisay-http-503',
39+
clientId: $.auth.data.clientId,
40+
baseUrl: error.response.config.baseURL,
41+
url: error.response.config.url,
42+
flowId: $.flow?.id,
43+
stepId: $.step?.id,
44+
executionId: $.execution?.id,
45+
retryAfterMs: error.response?.headers?.['retry-after'],
46+
})
47+
48+
// AISAY will specify a Retry-After header when it returns 503.
49+
const retryAfterMs =
50+
parseRetryAfterToMs(error.response?.headers?.['retry-after']) ?? 'default'
51+
throw new RetriableError({
52+
error: `Encountered HTTP ${status} from AISAY`,
53+
delayInMs: retryAfterMs,
54+
delayType: 'step',
55+
})
56+
}
57+
58+
const errorHandler: IApp['requestErrorHandler'] = async function ($, error) {
59+
switch (error.response.status) {
60+
case 429:
61+
return handle429($, error)
62+
case 503:
63+
return handle503($, error)
64+
}
65+
}
66+
67+
export default errorHandler

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

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

3+
import requestErrorHandler from './common/interceptors/request-error-handler'
34
import { aisayUrlConfig } from './common/url-config'
45
import actions from './actions'
56
import auth from './auth'
7+
import queue from './queue'
68

79
const app: IApp = {
810
name: 'AISAY',
@@ -16,10 +18,12 @@ const app: IApp = {
1618
apiBaseUrl: '',
1719
primaryColor: '0059F7',
1820
actions,
21+
requestErrorHandler,
1922
substepLabels: {
2023
connectionStepLabel: 'Connect your AISAY account',
2124
addConnectionLabel: 'Add new AISAY connection',
2225
},
26+
queue,
2327
}
2428

2529
export default app

0 commit comments

Comments
 (0)