Infrastructure build to support third-party app integrations
This commit is contained in:
234
backend/lib/httpClient.ts
Normal file
234
backend/lib/httpClient.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user