Skip to content

Commit a8918b2

Browse files
authored
feat: Add exponential backoff to requests
- Wrapped api call function with exponential backoff function, which will retry calls that return either 429 - too many requests code or http 500+ codes. - Added test for the exp backoff
2 parents 2cc4eec + e18626b commit a8918b2

File tree

4 files changed

+101
-10
lines changed

4 files changed

+101
-10
lines changed

nodes/Apify/__tests__/Apify.node.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,4 +509,27 @@ describe('Apify Node', () => {
509509
});
510510
});
511511
});
512+
513+
describe('api calls', () => {
514+
it('should retry the specified number of times with exponential delays', async () => {
515+
const storeId = 'yTfMu13hDFe9bRjx6';
516+
const recordKey = 'INPUT';
517+
518+
const scope = nock('https://api.apify.com')
519+
.get(`/v2/key-value-stores/${storeId}/records/${recordKey}`)
520+
.reply(500)
521+
.get(`/v2/key-value-stores/${storeId}/records/${recordKey}`)
522+
.reply(429)
523+
.get(`/v2/key-value-stores/${storeId}/records/${recordKey}`)
524+
.reply(200);
525+
526+
const getKeyValueStoreRecordWorkflow = require('./workflows/key-value-stores/get-key-value-store-record.workflow.json');
527+
await executeWorkflow({
528+
credentialsHelper,
529+
workflow: getKeyValueStoreRecordWorkflow,
530+
});
531+
532+
expect(scope.isDone()).toBe(true);
533+
});
534+
});
512535
});

nodes/Apify/helpers/consts.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,7 @@ export const memoryOptions = [
1414
{ name: '16384 MB (16 GB)', value: 16384 },
1515
{ name: '32768 MB (32 GB)', value: 32768 },
1616
];
17+
18+
export const DEFAULT_EXP_BACKOFF_INTERVAL = 1;
19+
export const DEFAULT_EXP_BACKOFF_EXPONENTIAL = 2;
20+
export const DEFAULT_EXP_BACKOFF_RETRIES = 5;

nodes/Apify/resources/genericFunctions.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88
type ILoadOptionsFunctions,
99
type IRequestOptions,
1010
} from 'n8n-workflow';
11+
import {
12+
DEFAULT_EXP_BACKOFF_EXPONENTIAL,
13+
DEFAULT_EXP_BACKOFF_INTERVAL,
14+
DEFAULT_EXP_BACKOFF_RETRIES,
15+
} from '../helpers/consts';
1116

1217
type IApiRequestOptions = IRequestOptions & { uri?: string };
1318

@@ -50,7 +55,9 @@ export async function apiRequest(
5055
);
5156
}
5257

53-
return await this.helpers.requestWithAuthentication.call(this, authenticationMethod, options);
58+
return await retryWithExponentialBackoff(() =>
59+
this.helpers.requestWithAuthentication.call(this, authenticationMethod, options),
60+
);
5461
} catch (error) {
5562
/**
5663
* using `error instanceof NodeApiError` results in `false`
@@ -71,6 +78,60 @@ export async function apiRequest(
7178
}
7279
}
7380

81+
/**
82+
* Checks if the given status code is retryable
83+
* Status codes 429 (rate limit) and 500+ are retried,
84+
* Other status codes 300-499 (except 429) are not retried,
85+
* because the error is probably caused by invalid URL (redirect 3xx) or invalid user input (4xx).
86+
*/
87+
function isStatusCodeRetryable(statusCode: number) {
88+
if (Number.isNaN(statusCode)) return false;
89+
90+
const RATE_LIMIT_EXCEEDED_STATUS_CODE = 429;
91+
const isRateLimitError = statusCode === RATE_LIMIT_EXCEEDED_STATUS_CODE;
92+
const isInternalError = statusCode >= 500;
93+
return isRateLimitError || isInternalError;
94+
}
95+
96+
/**
97+
* Wraps a function with exponential backoff.
98+
* If request fails with http code 500+ or doesn't return
99+
* a code at all it is retried in 1s,2s,4s,.. up to maxRetries
100+
* @param fn
101+
* @param interval
102+
* @param exponential
103+
* @param maxRetries
104+
* @returns
105+
*/
106+
export async function retryWithExponentialBackoff(
107+
fn: () => Promise<any>,
108+
interval: number = DEFAULT_EXP_BACKOFF_INTERVAL,
109+
exponential: number = DEFAULT_EXP_BACKOFF_EXPONENTIAL,
110+
maxRetries: number = DEFAULT_EXP_BACKOFF_RETRIES,
111+
): Promise<any> {
112+
let lastError;
113+
for (let i = 0; i < maxRetries; i++) {
114+
try {
115+
return await fn();
116+
} catch (error) {
117+
lastError = error;
118+
const status = Number(error?.httpCode);
119+
if (isStatusCodeRetryable(status)) {
120+
//Generate a new sleep time based from interval * exponential^i function
121+
const sleepTimeSecs = interval * Math.pow(exponential, i);
122+
const sleepTimeMs = sleepTimeSecs * 1000;
123+
124+
await sleep(sleepTimeMs);
125+
126+
continue;
127+
}
128+
throw error;
129+
}
130+
}
131+
//In case all of the calls failed with no status or isStatusCodeRetryable, throw the last error
132+
throw lastError;
133+
}
134+
74135
export async function apiRequestAllItems(
75136
this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions,
76137
requestOptions: IApiRequestOptions,

nodes/Apify/resources/key-value-stores/get-key-value-store-record/execute.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
NodeOperationError,
77
} from 'n8n-workflow';
88
import { consts } from '../../../helpers';
9+
import { retryWithExponentialBackoff } from '../../genericFunctions';
910

1011
export async function getKeyValueStoreRecord(
1112
this: IExecuteFunctions,
@@ -19,15 +20,17 @@ export async function getKeyValueStoreRecord(
1920
}
2021

2122
try {
22-
const apiResult = await this.helpers.httpRequestWithAuthentication.call(this, 'apifyApi', {
23-
method: 'GET' as IHttpRequestMethods,
24-
url: `${consts.APIFY_API_URL}/v2/key-value-stores/${storeId.value}/records/${recordKey.value}`,
25-
headers: {
26-
'x-apify-integration-platform': 'n8n',
27-
},
28-
returnFullResponse: true,
29-
encoding: 'arraybuffer',
30-
});
23+
const apiCallFn = () =>
24+
this.helpers.httpRequestWithAuthentication.call(this, 'apifyApi', {
25+
method: 'GET' as IHttpRequestMethods,
26+
url: `${consts.APIFY_API_URL}/v2/key-value-stores/${storeId.value}/records/${recordKey.value}`,
27+
headers: {
28+
'x-apify-integration-platform': 'n8n',
29+
},
30+
returnFullResponse: true,
31+
encoding: 'arraybuffer',
32+
});
33+
const apiResult = await retryWithExponentialBackoff(apiCallFn);
3134

3235
if (!apiResult) {
3336
return { json: {} };

0 commit comments

Comments
 (0)