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:
63
packages/cookie-banner/src/errors.ts
Normal file
63
packages/cookie-banner/src/errors.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
168
packages/cookie-banner/src/http.ts
Normal file
168
packages/cookie-banner/src/http.ts
Normal 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!;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user