From 0e481718363a3cc72cc58b0583a1997d77429430 Mon Sep 17 00:00:00 2001 From: Oliver Wegner CSR <100767229+olivercsr@users.noreply.github.com> Date: Mon, 25 Mar 2024 15:40:40 +0100 Subject: [PATCH] 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 --- src/util/http/default-axios.ts | 19 +++++ src/util/http/index.ts | 2 + src/util/http/rate-limited-axios.ts | 56 ++++++++++++++- src/util/http/retrying-axios.ts | 104 ++++++++++++++++++++++++++++ src/util/lang/delay.ts | 5 ++ src/util/lang/index.ts | 2 + 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 src/util/http/default-axios.ts create mode 100644 src/util/http/retrying-axios.ts create mode 100644 src/util/lang/delay.ts diff --git a/src/util/http/default-axios.ts b/src/util/http/default-axios.ts new file mode 100644 index 00000000..9a19c31d --- /dev/null +++ b/src/util/http/default-axios.ts @@ -0,0 +1,19 @@ +import { AxiosInstance } from 'axios'; +import { RateLimitConfig, useRateLimitInterceptor } from './rate-limited-axios'; +import { RetryConfig, useRetryOnErrorInterceptor } from './retrying-axios'; +import { randomUUID } from 'crypto'; + +export function useDefaultInterceptors( + axiosInstance: AxiosInstance, + rateLimitConfig: RateLimitConfig, + retryConfig: RetryConfig, + key?: string, +): AxiosInstance { + const effectiveKey = key || randomUUID(); + + return useRateLimitInterceptor( + useRetryOnErrorInterceptor(axiosInstance, retryConfig, effectiveKey), + rateLimitConfig, + effectiveKey, + ); +} diff --git a/src/util/http/index.ts b/src/util/http/index.ts index 69afd48c..27f890f5 100644 --- a/src/util/http/index.ts +++ b/src/util/http/index.ts @@ -6,4 +6,6 @@ export { Pagination, RateLimitedAxios, getSubdomain }; export * from './pagination'; export * from './rate-limited-axios'; +export * from './retrying-axios'; +export * from './default-axios'; export * from './url'; diff --git a/src/util/http/rate-limited-axios.ts b/src/util/http/rate-limited-axios.ts index c5479fb9..cf536446 100644 --- a/src/util/http/rate-limited-axios.ts +++ b/src/util/http/rate-limited-axios.ts @@ -1,6 +1,12 @@ -import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; +import axios, { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + InternalAxiosRequestConfig, +} from 'axios'; import { RateLimiterMemory } from 'rate-limiter-flexible'; import { infoLogger } from '../logger.util'; +import { randomUUID } from 'crypto'; const DEFAULT_KEY = 'DEFAULT_KEY'; @@ -86,3 +92,51 @@ export class RateLimitedAxios { return axios.patch(url, data, config); } } + +export type RateLimitConfig = { + allowedCalls: number; + intervalSeconds: number; + enableLogging?: boolean; +}; + +export function useRateLimitInterceptor( + axiosInstance: AxiosInstance, + config: RateLimitConfig, + key?: string, +): AxiosInstance { + const effectiveKey = key || randomUUID(); + const enableLogging = !!config.enableLogging; + + const rateLimiter = new RateLimiterMemory({ + points: config.allowedCalls, + duration: config.intervalSeconds, + }); + + const checkRateLimitAndWait = async () => { + try { + await rateLimiter.consume(effectiveKey, 1); + } catch (rateLimiterRes: any) { + enableLogging && + infoLogger( + 'axiosRateLimitInterceptor', + `Waiting ${rateLimiterRes.msBeforeNext} to respect rate limit`, + effectiveKey, + ); + + await new Promise((resolve) => { + setTimeout(resolve, rateLimiterRes.msBeforeNext); + }); + + await checkRateLimitAndWait(); + } + }; + + const requestHandler = async (config: InternalAxiosRequestConfig) => { + await checkRateLimitAndWait(); + return config; + }; + + axiosInstance.interceptors.request.use(requestHandler); + + return axiosInstance; +} diff --git a/src/util/http/retrying-axios.ts b/src/util/http/retrying-axios.ts new file mode 100644 index 00000000..a882e65e --- /dev/null +++ b/src/util/http/retrying-axios.ts @@ -0,0 +1,104 @@ +import { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; +import { infoLogger, warnLogger } from '../logger.util'; +import { delay } from '../lang/delay'; +import { randomUUID } from 'crypto'; + +export type RetryDecision = { + retryDesired: boolean; + delayMs?: number; +}; + +export type RetryDecider = ( + error: AxiosError, + retryCount: number, +) => RetryDecision; + +export type RetryConfig = { + retryDecider: RetryDecider; + retryCountHeader?: string; +}; + +function formatAxiosErrorForLogging(error: AxiosError) { + return { + code: error.code, + message: error.message, + stack: error.stack, + method: error.config?.method, + url: error.config?.url, + responseStatus: error.response?.status, + responseStatusText: error.response?.statusText, + responseHeaders: { ...error.response?.headers }, + responseData: error.response?.data, + }; +} + +export function useRetryOnErrorInterceptor( + axiosInstance: AxiosInstance, + config: RetryConfig, + key?: string, +): AxiosInstance { + const effectiveKey = key || randomUUID(); + const retryCountHeader = + config.retryCountHeader || 'X-SipgateIntegration-RetryCount'; + + axiosInstance.interceptors.request.use( + (config: InternalAxiosRequestConfig) => { + if (config.headers[retryCountHeader] !== undefined) { + config.headers[retryCountHeader] = + parseInt(config.headers[retryCountHeader]) + 1; + } else { + config.headers[retryCountHeader] = 0; + } + + return config; + }, + ); + + axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const retryCount: number | undefined = + error.config && error.config.headers[retryCountHeader] !== undefined + ? parseInt(error.config.headers[retryCountHeader]) + : undefined; + + if (retryCount !== undefined) { + const { retryDesired, delayMs } = config.retryDecider( + error, + retryCount, + ); + + if (retryDesired && error.config) { + infoLogger( + 'axiosRetryInterceptor', + 'request was not successful - will retry', + effectiveKey, + { + status: error.response?.status, + retryCount, + delayMs, + }, + ); + + delayMs && (await delay(delayMs)); + + return axiosInstance.request(error.config); + } + } + + warnLogger( + 'axiosRetryInterceptor', + 'request finally failed with error', + effectiveKey, + { + error: formatAxiosErrorForLogging(error), + retryCount, + }, + ); + + return Promise.reject(error); + }, + ); + + return axiosInstance; +} diff --git a/src/util/lang/delay.ts b/src/util/lang/delay.ts new file mode 100644 index 00000000..0e054d00 --- /dev/null +++ b/src/util/lang/delay.ts @@ -0,0 +1,5 @@ +export function delay(ms: number = 100) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/src/util/lang/index.ts b/src/util/lang/index.ts index 15f9f58e..b0a71fe5 100644 --- a/src/util/lang/index.ts +++ b/src/util/lang/index.ts @@ -1,3 +1,5 @@ import { diffArrays } from './diff'; export { diffArrays }; + +export * from './delay';