type GraphQLRequest = {
endpoint: string;
token?: string;
query: string;
variables?: Record<string, unknown>;
maxRetries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
signal?: AbortSignal;
};
type GraphQLResponse<T> = {
data?: T;
errors?: Array<{ message: string; [key: string]: unknown }>;
};
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(new DOMException('Aborted', 'AbortError'));
const timer = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
function computeBackoffMs(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
minimumMs = 1000
): number {
const exp = Math.max(minimumMs, baseDelayMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * Math.min(250, exp));
return Math.min(exp + jitter, maxDelayMs);
}
function containsConcurrencyError(errors?: Array<{ message: string }>): boolean {
if (!errors) return false;
return errors.some(
(e) =>
typeof e.message === 'string' &&
e.message.toLowerCase().includes('concurrent operations limit exceeded')
);
}
export async function graphqlFetchWithRetry<T = unknown>({
endpoint,
token,
query,
variables,
maxRetries = 5,
baseDelayMs = 250,
maxDelayMs = 5000,
signal,
}: GraphQLRequest): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ query, variables }),
signal,
cache: 'no-store',
});
const is429 = res.status === 429;
if (res.ok) {
const json = (await res.json()) as GraphQLResponse<T>;
const concurrencyHit = containsConcurrencyError(json.errors);
if (!concurrencyHit) {
if (json.errors) {
const messages = json.errors.map((e) => e.message).join('; ');
throw new Error(`GraphQL error: ${messages}`);
}
return json.data as T;
}
if (attempt < maxRetries) {
const delay = computeBackoffMs(attempt, baseDelayMs, maxDelayMs, 1000);
await sleep(delay, signal);
continue;
}
throw new Error('Exceeded retries due to concurrent operations limit.');
}
if (is429 && attempt < maxRetries) {
const delay = computeBackoffMs(attempt, baseDelayMs, maxDelayMs, 1000);
await sleep(delay, signal);
continue;
}
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
} catch (err) {
lastError = err;
const isAbort = err instanceof DOMException && err.name === 'AbortError';
if (isAbort) throw err;
if (attempt < maxRetries) {
const delay = computeBackoffMs(attempt, baseDelayMs, maxDelayMs, 1000);
await sleep(delay, signal);
continue;
}
break;
}
}
throw lastError instanceof Error ? lastError : new Error('Request failed.');
}