Add HTTP client with retries and typed errors

Introduces fetchJSON<T> with timeout, exponential backoff
with jitter on network errors and 5xx/429, and error classes
that match the cookie banner API error shape.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-04-15 16:58:39 +04:00
parent be1e94da71
commit 3b677d9464
3 changed files with 243 additions and 1 deletions

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
export class ApiError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
export class BadRequestError extends ApiError {
constructor(message: string) {
super(400, "bad_request", message);
this.name = "BadRequestError";
}
}
export class NotFoundError extends ApiError {
constructor(message: string) {
super(404, "not_found", message);
this.name = "NotFoundError";
}
}
export class InternalServerError extends ApiError {
constructor(message: string) {
super(500, "internal_server_error", message);
this.name = "InternalServerError";
}
}
export class NetworkError extends Error {
readonly cause?: unknown;
constructor(message: string, cause?: unknown) {
super(message);
this.name = "NetworkError";
this.cause = cause;
}
}
export class TimeoutError extends NetworkError {
constructor() {
super("request timed out");
this.name = "TimeoutError";
}
}

View File

@@ -0,0 +1,168 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import {
ApiError,
BadRequestError,
InternalServerError,
NetworkError,
NotFoundError,
TimeoutError,
} from "./errors";
const DEFAULT_TIMEOUT_MS = 10_000;
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1_000;
export interface RequestOptions {
method?: string;
headers?: Record<string, string>;
body?: unknown;
timeout?: number;
signal?: AbortSignal;
}
interface ApiErrorBody {
error: string;
message: string;
}
function isRetryable(status: number): boolean {
return status === 429 || status >= 500;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function jitteredBackoff(attempt: number): number {
const base = BASE_DELAY_MS * Math.pow(2, attempt);
return base + Math.random() * base;
}
function throwApiError(status: number, body: ApiErrorBody): never {
switch (status) {
case 400:
throw new BadRequestError(body.message);
case 404:
throw new NotFoundError(body.message);
case 500:
throw new InternalServerError(body.message);
default:
throw new ApiError(status, body.error, body.message);
}
}
async function parseErrorBody(response: Response): Promise<ApiErrorBody> {
try {
return (await response.json()) as ApiErrorBody;
} catch {
return {
error: "unknown",
message: response.statusText || "unknown error",
};
}
}
async function fetchWithTimeout(
url: string,
init: RequestInit,
timeout: number,
): Promise<Response> {
const controller = new AbortController();
const externalSignal = init.signal;
if (externalSignal?.aborted) {
throw new NetworkError("request aborted", externalSignal.reason);
}
const onExternalAbort = () => controller.abort(externalSignal!.reason);
externalSignal?.addEventListener("abort", onExternalAbort, { once: true });
const timer = setTimeout(() => controller.abort("timeout"), timeout);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (err) {
if (controller.signal.aborted && controller.signal.reason === "timeout") {
throw new TimeoutError();
}
if (externalSignal?.aborted) {
throw new NetworkError("request aborted", err);
}
throw new NetworkError("network request failed", err);
} finally {
clearTimeout(timer);
externalSignal?.removeEventListener("abort", onExternalAbort);
}
}
export async function fetchJSON<T>(
url: string,
options: RequestOptions = {},
): Promise<T> {
const { method = "GET", headers, body, timeout = DEFAULT_TIMEOUT_MS, signal } = options;
const init: RequestInit = {
method,
mode: "cors",
credentials: "omit",
headers: {
Accept: "application/json",
...(body !== undefined && { "Content-Type": "application/json" }),
...headers,
},
...(body !== undefined && { body: JSON.stringify(body) }),
...(signal && { signal }),
};
let lastError: Error | undefined;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
if (attempt > 0) {
await delay(jitteredBackoff(attempt - 1));
}
let response: Response;
try {
response = await fetchWithTimeout(url, init, timeout);
} catch (err) {
if (err instanceof TimeoutError || err instanceof NetworkError) {
lastError = err;
continue;
}
throw err;
}
if (response.ok) {
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
if (!isRetryable(response.status)) {
const body = await parseErrorBody(response);
throwApiError(response.status, body);
}
lastError = new ApiError(
response.status,
"server_error",
`server returned ${response.status}`,
);
}
throw lastError!;
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
@@ -14,6 +14,17 @@
export const VERSION = "0.0.0";
export {
ApiError,
BadRequestError,
InternalServerError,
NetworkError,
NotFoundError,
TimeoutError,
} from "./errors";
export { fetchJSON } from "./http";
export type { RequestOptions } from "./http";
export type CookieBannerConfig = {
bannerId: string;
baseUrl: string;