Skip to content

Commit

Permalink
implement RateLimiter & Retry axios middlewares (#113)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
olivercsr authored Mar 25, 2024
1 parent c7cea07 commit 0e48171
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 1 deletion.
19 changes: 19 additions & 0 deletions src/util/http/default-axios.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
2 changes: 2 additions & 0 deletions src/util/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
56 changes: 55 additions & 1 deletion src/util/http/rate-limited-axios.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
}
104 changes: 104 additions & 0 deletions src/util/http/retrying-axios.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions src/util/lang/delay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function delay(ms: number = 100) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
2 changes: 2 additions & 0 deletions src/util/lang/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { diffArrays } from './diff';

export { diffArrays };

export * from './delay';

0 comments on commit 0e48171

Please sign in to comment.