From 3b677d946428ce99a3be49ecd2cc5be7f2701322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 15 Apr 2026 16:58:39 +0400 Subject: [PATCH] Add HTTP client with retries and typed errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces fetchJSON 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é --- packages/cookie-banner/src/errors.ts | 63 ++++++++++ packages/cookie-banner/src/http.ts | 168 +++++++++++++++++++++++++++ packages/cookie-banner/src/index.ts | 13 ++- 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 packages/cookie-banner/src/errors.ts create mode 100644 packages/cookie-banner/src/http.ts diff --git a/packages/cookie-banner/src/errors.ts b/packages/cookie-banner/src/errors.ts new file mode 100644 index 000000000..b5a117b3e --- /dev/null +++ b/packages/cookie-banner/src/errors.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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"; + } +} diff --git a/packages/cookie-banner/src/http.ts b/packages/cookie-banner/src/http.ts new file mode 100644 index 000000000..134aae13a --- /dev/null +++ b/packages/cookie-banner/src/http.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; + 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 { + 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 { + 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 { + 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( + url: string, + options: RequestOptions = {}, +): Promise { + 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!; +} diff --git a/packages/cookie-banner/src/index.ts b/packages/cookie-banner/src/index.ts index eee723157..6f248ef07 100644 --- a/packages/cookie-banner/src/index.ts +++ b/packages/cookie-banner/src/index.ts @@ -1,4 +1,4 @@ -// Copyright (c) 2025-2026 Probo Inc . +// Copyright (c) 2026 Probo Inc . // // 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;