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
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
// purpose with or without fee is hereby granted, provided that the above
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
@@ -14,6 +14,17 @@
|
|||||||
|
|
||||||
export const VERSION = "0.0.0";
|
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 = {
|
export type CookieBannerConfig = {
|
||||||
bannerId: string;
|
bannerId: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user