Skip to content

Commit

Permalink
add retry fetch (#1306)
Browse files Browse the repository at this point in the history
* add sleep util

* add retry logic to hub API client

* unit tests for retry
  • Loading branch information
codemonkey800 authored Mar 6, 2024
1 parent a2800d7 commit 06e0acc
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 32 deletions.
47 changes: 47 additions & 0 deletions frontend/src/utils/HubAPIClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import axios, { AxiosInstance } from 'axios';

import mockPlugin from '@/fixtures/plugin.json';

import { HubAPIClient } from './HubAPIClient';
import { validatePluginData } from './validate';

describe('HubAPIClient', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should retry fetching a failed request', async () => {
let retryCount = 0;
jest.spyOn(axios, 'create').mockImplementation(() => {
return {
request: jest.fn(() => {
if (retryCount > 1) {
return Promise.resolve({
data: mockPlugin,
status: 200,
});
}

retryCount += 1;
throw new Error('failure');
}),
} as unknown as AxiosInstance;
});

const client = new HubAPIClient();
await expect(client.getPlugin('test')).resolves.toEqual(
validatePluginData(mockPlugin),
);
});

it('should fail when the request fails too many times', async () => {
jest.spyOn(axios, 'create').mockImplementation(() => {
return {
request: jest.fn().mockRejectedValue(new Error('failure')),
} as unknown as AxiosInstance;
});

const client = new HubAPIClient();
await expect(client.getPlugin('test')).rejects.toThrow('failure');
});
});
102 changes: 70 additions & 32 deletions frontend/src/utils/HubAPIClient.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable max-classes-per-file */

import axios, { AxiosRequestConfig } from 'axios';
Expand All @@ -12,6 +13,7 @@ import {
} from '@/types';

import { Logger } from './logger';
import { sleep } from './sleep';
import { getFullPathFromAxios } from './url';
import {
validateMetricsData,
Expand Down Expand Up @@ -70,12 +72,27 @@ function isHubAPIErrorResponse(
return !!(data as HubAPIErrorResponse).errorType;
}

/**
* Max number of times to retry fetching a request.
*/
const MAX_RETRIES = 3;

/**
* Backoff factory for increasing the delay when re-fetching requests.
*/
const RETRY_BACKOFF_FACTOR = 2;

/**
* Initial delay before retrying a request in milliseconds.
*/
const INITIAL_RETRY_DELAY_MS = 1000;

/**
* Class for interacting with the hub API. Each function makes a request to the
* hub API and runs client-side data validation on the data to ensure
* consistency with static typing and reduce the chance of errors occurring.
*/
class HubAPIClient {
export class HubAPIClient {
private api = axios.create({
baseURL: API_URL,
headers: BROWSER
Expand All @@ -88,43 +105,64 @@ class HubAPIClient {
private async sendRequest<T>(url: string, config?: AxiosRequestConfig<T>) {
const method = config?.method ?? 'GET';
const path = getFullPathFromAxios(url, config);
let retryDelay = INITIAL_RETRY_DELAY_MS;

try {
const { data, status } = await this.api.request<T>({
url,
...config,
});

if (SERVER) {
logger.info({
path,
method,
for (let retry = 0; retry < MAX_RETRIES; retry += 1) {
try {
const { data, status } = await this.api.request<T>({
url,
status,
params: {
...config?.params,
retryCount: retry,
} as Record<string, unknown>,
...config,
});
}

return data;
} catch (err) {
if (axios.isAxiosError(err)) {
const status = err.response?.status;
const level =
status !== undefined && status >= 400 && status < 500
? 'warn'
: 'error';

logger[level]({
message: 'Error sending request',
error: err.message,
method,
path,
url,
...(err.response?.status ? { status: err.response.status } : {}),
});
if (SERVER) {
logger.info({
path,
method,
url,
status,
});
}

return data;
} catch (err) {
const isRetrying = retry < MAX_RETRIES - 1;

if (axios.isAxiosError(err)) {
const status = err.response?.status;
const level =
isRetrying ||
(status !== undefined && status >= 400 && status < 500)
? 'warn'
: 'error';

logger[level]({
message: 'Error sending request',
error: err.message,
method,
path,
url,
isRetrying,
retry,
...(err.response?.status ? { status: err.response.status } : {}),
});
}

if (isRetrying) {
await sleep(retryDelay);
retryDelay *= RETRY_BACKOFF_FACTOR;
} else {
throw err;
}
}

throw err;
}

// This edge case should never happen but is required by TypeScript to
// prevent a compile error.
throw new Error('failed to request data');
}

async getPluginIndex(): Promise<PluginIndexData[]> {
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/utils/sleep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function sleep(duration: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, duration);
});
}

0 comments on commit 06e0acc

Please sign in to comment.