Skip to content

Commit 0e48171

Browse files
authored
implement RateLimiter & Retry axios middlewares (#113)
* adding implementations for delay & retry functionality * working on retry() * implement axios interceptor for rate limiter functionality * work on implementing axios interceptors for rate limit & retry * implement retryDecider concept, i.e. expect a function from caller that will be called on error and can decide whether to retry the request or not * adding default axios, combining rate-limiting & retrying middlewares * reorder types * remove retry fn, as it's not being used * improve logging for axios middlewares, - log via user key if given - don't log error messages while retries are still going on, to not trigger any alarms prematurely
1 parent c7cea07 commit 0e48171

File tree

6 files changed

+187
-1
lines changed

6 files changed

+187
-1
lines changed

src/util/http/default-axios.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { AxiosInstance } from 'axios';
2+
import { RateLimitConfig, useRateLimitInterceptor } from './rate-limited-axios';
3+
import { RetryConfig, useRetryOnErrorInterceptor } from './retrying-axios';
4+
import { randomUUID } from 'crypto';
5+
6+
export function useDefaultInterceptors(
7+
axiosInstance: AxiosInstance,
8+
rateLimitConfig: RateLimitConfig,
9+
retryConfig: RetryConfig,
10+
key?: string,
11+
): AxiosInstance {
12+
const effectiveKey = key || randomUUID();
13+
14+
return useRateLimitInterceptor(
15+
useRetryOnErrorInterceptor(axiosInstance, retryConfig, effectiveKey),
16+
rateLimitConfig,
17+
effectiveKey,
18+
);
19+
}

src/util/http/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ export { Pagination, RateLimitedAxios, getSubdomain };
66

77
export * from './pagination';
88
export * from './rate-limited-axios';
9+
export * from './retrying-axios';
10+
export * from './default-axios';
911
export * from './url';

src/util/http/rate-limited-axios.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
1+
import axios, {
2+
AxiosInstance,
3+
AxiosRequestConfig,
4+
AxiosResponse,
5+
InternalAxiosRequestConfig,
6+
} from 'axios';
27
import { RateLimiterMemory } from 'rate-limiter-flexible';
38
import { infoLogger } from '../logger.util';
9+
import { randomUUID } from 'crypto';
410

511
const DEFAULT_KEY = 'DEFAULT_KEY';
612

@@ -86,3 +92,51 @@ export class RateLimitedAxios {
8692
return axios.patch(url, data, config);
8793
}
8894
}
95+
96+
export type RateLimitConfig = {
97+
allowedCalls: number;
98+
intervalSeconds: number;
99+
enableLogging?: boolean;
100+
};
101+
102+
export function useRateLimitInterceptor(
103+
axiosInstance: AxiosInstance,
104+
config: RateLimitConfig,
105+
key?: string,
106+
): AxiosInstance {
107+
const effectiveKey = key || randomUUID();
108+
const enableLogging = !!config.enableLogging;
109+
110+
const rateLimiter = new RateLimiterMemory({
111+
points: config.allowedCalls,
112+
duration: config.intervalSeconds,
113+
});
114+
115+
const checkRateLimitAndWait = async () => {
116+
try {
117+
await rateLimiter.consume(effectiveKey, 1);
118+
} catch (rateLimiterRes: any) {
119+
enableLogging &&
120+
infoLogger(
121+
'axiosRateLimitInterceptor',
122+
`Waiting ${rateLimiterRes.msBeforeNext} to respect rate limit`,
123+
effectiveKey,
124+
);
125+
126+
await new Promise((resolve) => {
127+
setTimeout(resolve, rateLimiterRes.msBeforeNext);
128+
});
129+
130+
await checkRateLimitAndWait();
131+
}
132+
};
133+
134+
const requestHandler = async (config: InternalAxiosRequestConfig) => {
135+
await checkRateLimitAndWait();
136+
return config;
137+
};
138+
139+
axiosInstance.interceptors.request.use(requestHandler);
140+
141+
return axiosInstance;
142+
}

src/util/http/retrying-axios.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
2+
import { infoLogger, warnLogger } from '../logger.util';
3+
import { delay } from '../lang/delay';
4+
import { randomUUID } from 'crypto';
5+
6+
export type RetryDecision = {
7+
retryDesired: boolean;
8+
delayMs?: number;
9+
};
10+
11+
export type RetryDecider = (
12+
error: AxiosError,
13+
retryCount: number,
14+
) => RetryDecision;
15+
16+
export type RetryConfig = {
17+
retryDecider: RetryDecider;
18+
retryCountHeader?: string;
19+
};
20+
21+
function formatAxiosErrorForLogging(error: AxiosError) {
22+
return {
23+
code: error.code,
24+
message: error.message,
25+
stack: error.stack,
26+
method: error.config?.method,
27+
url: error.config?.url,
28+
responseStatus: error.response?.status,
29+
responseStatusText: error.response?.statusText,
30+
responseHeaders: { ...error.response?.headers },
31+
responseData: error.response?.data,
32+
};
33+
}
34+
35+
export function useRetryOnErrorInterceptor(
36+
axiosInstance: AxiosInstance,
37+
config: RetryConfig,
38+
key?: string,
39+
): AxiosInstance {
40+
const effectiveKey = key || randomUUID();
41+
const retryCountHeader =
42+
config.retryCountHeader || 'X-SipgateIntegration-RetryCount';
43+
44+
axiosInstance.interceptors.request.use(
45+
(config: InternalAxiosRequestConfig) => {
46+
if (config.headers[retryCountHeader] !== undefined) {
47+
config.headers[retryCountHeader] =
48+
parseInt(config.headers[retryCountHeader]) + 1;
49+
} else {
50+
config.headers[retryCountHeader] = 0;
51+
}
52+
53+
return config;
54+
},
55+
);
56+
57+
axiosInstance.interceptors.response.use(
58+
(response) => response,
59+
async (error: AxiosError) => {
60+
const retryCount: number | undefined =
61+
error.config && error.config.headers[retryCountHeader] !== undefined
62+
? parseInt(error.config.headers[retryCountHeader])
63+
: undefined;
64+
65+
if (retryCount !== undefined) {
66+
const { retryDesired, delayMs } = config.retryDecider(
67+
error,
68+
retryCount,
69+
);
70+
71+
if (retryDesired && error.config) {
72+
infoLogger(
73+
'axiosRetryInterceptor',
74+
'request was not successful - will retry',
75+
effectiveKey,
76+
{
77+
status: error.response?.status,
78+
retryCount,
79+
delayMs,
80+
},
81+
);
82+
83+
delayMs && (await delay(delayMs));
84+
85+
return axiosInstance.request(error.config);
86+
}
87+
}
88+
89+
warnLogger(
90+
'axiosRetryInterceptor',
91+
'request finally failed with error',
92+
effectiveKey,
93+
{
94+
error: formatAxiosErrorForLogging(error),
95+
retryCount,
96+
},
97+
);
98+
99+
return Promise.reject(error);
100+
},
101+
);
102+
103+
return axiosInstance;
104+
}

src/util/lang/delay.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function delay(ms: number = 100) {
2+
return new Promise((resolve) => {
3+
setTimeout(resolve, ms);
4+
});
5+
}

src/util/lang/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
import { diffArrays } from './diff';
22

33
export { diffArrays };
4+
5+
export * from './delay';

0 commit comments

Comments
 (0)