235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
const DEFAULT_BASE_DELAY_MS = 250;
|
|
const DEFAULT_RETRY_AFTER_CAP_MS = 60_000;
|
|
|
|
export type HttpFailureKind = 'status' | 'timeout' | 'network' | 'aborted' | 'invalid-body';
|
|
|
|
export type RequestJsonInit = RequestInit & {
|
|
timeoutMs?: number;
|
|
maxAttempts?: number;
|
|
/** First-retry backoff, doubled per attempt. Exposed so tests need not wait on real backoff. */
|
|
baseDelayMs?: number;
|
|
/** Upper bound on a honored `Retry-After`, so a hostile header cannot park the process. */
|
|
retryAfterCapMs?: number;
|
|
};
|
|
|
|
export class HttpRequestError extends Error {
|
|
readonly url: string;
|
|
readonly status: number | undefined;
|
|
readonly attempts: number;
|
|
readonly kind: HttpFailureKind;
|
|
|
|
constructor(
|
|
url: string,
|
|
kind: HttpFailureKind,
|
|
attempts: number,
|
|
status?: number,
|
|
detail?: string
|
|
) {
|
|
const statusPart = status === undefined ? '' : ` status ${status}`;
|
|
const detailPart = detail === undefined ? '' : `: ${detail}`;
|
|
super(
|
|
`HTTP request to ${url} failed after ${attempts} attempt(s) (${kind}${statusPart})${detailPart}`
|
|
);
|
|
this.name = 'HttpRequestError';
|
|
this.url = url;
|
|
this.status = status;
|
|
this.attempts = attempts;
|
|
this.kind = kind;
|
|
}
|
|
}
|
|
|
|
export const isHttpRequestError = (err: unknown): err is HttpRequestError =>
|
|
err instanceof HttpRequestError;
|
|
|
|
/**
|
|
* Returns the delay a `Retry-After` header asks for, clamped to `capMs`, or null when the
|
|
* header is absent or unintelligible. Supports both delay-seconds and HTTP-date forms.
|
|
*/
|
|
export const parseRetryAfter = (
|
|
headerValue: string | null,
|
|
capMs: number = DEFAULT_RETRY_AFTER_CAP_MS
|
|
): number | null => {
|
|
if (headerValue === null) {
|
|
return null;
|
|
}
|
|
|
|
const raw = headerValue.trim();
|
|
if (raw === '') {
|
|
return null;
|
|
}
|
|
|
|
if (/^\d+$/.test(raw)) {
|
|
return Math.min(Number(raw) * 1000, capMs);
|
|
}
|
|
|
|
const deadline = Date.parse(raw);
|
|
if (Number.isNaN(deadline)) {
|
|
return null;
|
|
}
|
|
|
|
return Math.min(Math.max(deadline - Date.now(), 0), capMs);
|
|
};
|
|
|
|
const backoffDelay = (attempt: number, baseDelayMs: number, capMs: number): number => {
|
|
const ceiling = Math.min(baseDelayMs * 2 ** (attempt - 1), capMs);
|
|
// Half fixed, half jittered, so concurrent callers do not resynchronize on the same tick.
|
|
return Math.round(ceiling / 2 + Math.random() * (ceiling / 2));
|
|
};
|
|
|
|
/** Settles early when `signal` fires; the caller re-checks the signal before retrying. */
|
|
const sleep = (ms: number, signal: AbortSignal | undefined): Promise<void> =>
|
|
new Promise<void>((resolve) => {
|
|
if (signal?.aborted === true) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
let timer: ReturnType<typeof setTimeout>;
|
|
|
|
const onAbort = (): void => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
};
|
|
|
|
timer = setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, ms);
|
|
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
|
|
const isRetryableStatus = (status: number): boolean => status === 429 || status >= 500;
|
|
|
|
/** Frees the socket for reuse; a body we never read would otherwise stay pending. */
|
|
const discardBody = async (response: Response): Promise<void> => {
|
|
try {
|
|
await response.body?.cancel();
|
|
} catch {
|
|
// A already-consumed or errored body is irrelevant to the retry decision.
|
|
}
|
|
};
|
|
|
|
const readJson = async <T>(response: Response, url: string, attempts: number): Promise<T> => {
|
|
// 204/205 are defined as bodiless, so absence of JSON is the correct outcome, not a failure.
|
|
if (response.status === 204 || response.status === 205) {
|
|
return undefined as T;
|
|
}
|
|
|
|
let text: string;
|
|
try {
|
|
text = await response.text();
|
|
} catch (err) {
|
|
throw new HttpRequestError(
|
|
url,
|
|
'invalid-body',
|
|
attempts,
|
|
response.status,
|
|
err instanceof Error ? err.message : 'could not read response body'
|
|
);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text) as T;
|
|
} catch {
|
|
throw new HttpRequestError(
|
|
url,
|
|
'invalid-body',
|
|
attempts,
|
|
response.status,
|
|
'response body was not valid JSON'
|
|
);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Performs a JSON request, retrying only failures a retry can plausibly fix: 429, 5xx, timeouts
|
|
* and transport errors. Any other non-2xx fails on the first attempt, since re-sending a 400 or
|
|
* 401 only spends rate limit against an answer that will not change.
|
|
*
|
|
* Resolves `undefined` for bodiless 204/205 responses; any other 2xx whose body is not valid
|
|
* JSON rejects rather than resolving `undefined` silently.
|
|
*/
|
|
export const requestJson = async <T>(url: string, init?: RequestJsonInit): Promise<T> => {
|
|
const {
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
maxAttempts = DEFAULT_MAX_ATTEMPTS,
|
|
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
retryAfterCapMs = DEFAULT_RETRY_AFTER_CAP_MS,
|
|
signal,
|
|
...requestInit
|
|
} = init ?? {};
|
|
|
|
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
|
throw new TypeError('requestJson(init.maxAttempts) requires a positive integer');
|
|
}
|
|
|
|
const callerSignal = signal ?? undefined;
|
|
// Read through a call so narrowing never freezes this at its first observed value.
|
|
const callerAborted = (): boolean => callerSignal !== undefined && callerSignal.aborted;
|
|
|
|
let lastFailure: HttpRequestError | undefined;
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
if (callerAborted()) {
|
|
throw new HttpRequestError(url, 'aborted', attempt - 1, undefined, 'caller aborted');
|
|
}
|
|
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
const attemptSignal =
|
|
callerSignal === undefined
|
|
? timeoutSignal
|
|
: AbortSignal.any([callerSignal, timeoutSignal]);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, { ...requestInit, signal: attemptSignal });
|
|
} catch (err) {
|
|
if (callerAborted()) {
|
|
throw new HttpRequestError(url, 'aborted', attempt, undefined, 'caller aborted');
|
|
}
|
|
|
|
const kind: HttpFailureKind = timeoutSignal.aborted ? 'timeout' : 'network';
|
|
const detail = timeoutSignal.aborted
|
|
? `attempt exceeded ${timeoutMs}ms`
|
|
: err instanceof Error
|
|
? err.message
|
|
: 'transport error';
|
|
lastFailure = new HttpRequestError(url, kind, attempt, undefined, detail);
|
|
|
|
if (attempt === maxAttempts) {
|
|
throw lastFailure;
|
|
}
|
|
|
|
await sleep(backoffDelay(attempt, baseDelayMs, retryAfterCapMs), callerSignal);
|
|
continue;
|
|
}
|
|
|
|
if (response.ok) {
|
|
return await readJson<T>(response, url, attempt);
|
|
}
|
|
|
|
await discardBody(response);
|
|
|
|
if (!isRetryableStatus(response.status)) {
|
|
throw new HttpRequestError(url, 'status', attempt, response.status);
|
|
}
|
|
|
|
lastFailure = new HttpRequestError(url, 'status', attempt, response.status);
|
|
if (attempt === maxAttempts) {
|
|
throw lastFailure;
|
|
}
|
|
|
|
const retryAfter = parseRetryAfter(response.headers.get('retry-after'), retryAfterCapMs);
|
|
await sleep(
|
|
retryAfter ?? backoffDelay(attempt, baseDelayMs, retryAfterCapMs),
|
|
callerSignal
|
|
);
|
|
}
|
|
|
|
// Unreachable while maxAttempts >= 1; the loop either returns or throws.
|
|
throw lastFailure ?? new HttpRequestError(url, 'network', maxAttempts);
|
|
};
|