Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save WarrenBuffering/f4be4a282dd3922f4325098811464a78 to your computer and use it in GitHub Desktop.
Save WarrenBuffering/f4be4a282dd3922f4325098811464a78 to your computer and use it in GitHub Desktop.
Fetch Util w/ Exponential Backoff
import { StatusMessage } from '../constants';
import { isObject } from '../utils/isObject';
import type { RequestResponse } from '../types';
export type RequestParams = {
body?: BodyInit;
credentials?: RequestCredentials;
headers?: HeadersInit;
integrity?: string;
keepalive?: boolean;
method?: string;
mode?: RequestMode;
referrer?: string;
signal?: AbortSignal;
token?: string;
url: string;
timeout?: number;
retryConfig?: {
maxAttempts: number;
initialDelay: number;
multiplier: number;
statuses: number[];
};
};
const defaultRetryStatuses = [429, 500, 502, 503, 504]
const bodyRequired: Record<string, boolean> = {
PATCH: true,
POST: true,
PUT: true,
};
function fetchWithTimeout(url: string, options: RequestInit & { timeout?: number }): Promise<Response> {
const controller = new AbortController();
let cleanup: () => void;
if (options.signal) {
const abortListener = () => {
controller.abort();
cleanup();
};
options.signal.addEventListener('abort', abortListener);
cleanup = () => {
if (options.signal) {
options.signal.removeEventListener('abort', abortListener);
}
}
} else {
cleanup = () => { };
}
const promise = fetch(url, { ...options, signal: controller.signal });
if (!options.timeout) {
return promise.finally(cleanup);
}
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
return promise.finally(() => {
clearTimeout(timeoutId);
cleanup();
});
}
function formatBody(body: unknown): BodyInit | null {
if (typeof body === 'string' || body instanceof FormData || body instanceof Blob || body instanceof URLSearchParams || body instanceof ArrayBuffer || body instanceof ReadableStream) {
return body;
}
if (isObject(body)) {
return JSON.stringify(body);
}
return null;
}
function sleep(delay: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, delay));
}
export function request<ResponseData = null>({
body,
credentials,
headers,
integrity,
keepalive,
method = 'GET',
mode,
referrer,
token,
signal,
url,
timeout,
retryConfig = {
maxAttempts: 1,
initialDelay: 60000,
multiplier: 2,
statuses: defaultRetryStatuses,
},
attempt = 1,
}: RequestParams & { attempt?: number }): Promise<RequestResponse<ResponseData>> {
if (bodyRequired[method] && !body) {
return Promise.resolve({ data: null, error: { code: null, message: `A body is required for the ${method} method` } });
}
const newHeaders: HeadersInit = {
'Accept': 'application/json',
'Content-Type': 'application/json',
...(headers as Record<string, string>),
};
if (token) {
newHeaders['Authorization'] = `Bearer ${token}`;
}
return fetchWithTimeout(url, {
body: formatBody(body),
credentials,
headers: newHeaders,
integrity,
keepalive,
method,
mode,
referrer,
signal,
timeout,
}).then(response => {
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
return { data: null, error: null };
}
return response.json().then(data => {
if (!response.ok) {
if (retryConfig.statuses?.includes(response.status) && attempt < retryConfig.maxAttempts) {
const delay = retryConfig.initialDelay * Math.pow(retryConfig.multiplier, attempt - 1);
return sleep(delay).then(() => request<ResponseData>({ ...arguments[0], attempt: attempt + 1 }));
}
return {
data: null,
error: {
code: response.status,
message: data.message || StatusMessage[response.status] || 'Unknown error',
},
};
}
return { data, error: null };
}).catch(() => ({
data: null,
error: {
code: response.status,
message: 'Failed to parse JSON response'
}
}));
}).catch(error => {
return {
data: null,
error: {
code: 0,
message: error instanceof Error ? error.message : 'An unknown error occurred',
},
};
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment