From 75996d2dd6273609a8c17862ddff54b33b3c8292 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 16 Apr 2026 11:23:15 +0400 Subject: [PATCH] Tune HTTP timeouts and add localStorage consent retry queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce timeout/retry defaults (5s timeout, 2 retries, 500ms base delay) to cap worst-case page-load blocking at ~12s instead of ~36s. Add a localStorage-backed queue that persists failed consent POSTs and replays them on next page load, closing the compliance gap where a network failure could permanently lose the server-side audit record. Signed-off-by: Émile Ré --- packages/cookie-banner/src/client.ts | 37 +++++++---- packages/cookie-banner/src/http.ts | 6 +- packages/cookie-banner/src/queue.ts | 96 ++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 14 deletions(-) create mode 100644 packages/cookie-banner/src/queue.ts diff --git a/packages/cookie-banner/src/client.ts b/packages/cookie-banner/src/client.ts index e43955615..cd56088d3 100644 --- a/packages/cookie-banner/src/client.ts +++ b/packages/cookie-banner/src/client.ts @@ -16,6 +16,7 @@ import { activateElements, observeAndActivate } from "./activation"; import { getConsentCookie, setConsentCookie } from "./cookie"; import { NotFoundError } from "./errors"; import { fetchJSON } from "./http"; +import { enqueue, flush } from "./queue"; import { getOrCreateVisitorId } from "./visitor"; export interface CookieItem { @@ -124,6 +125,8 @@ export class CookieBannerClient { } else { this.consent = null; } + + void flush(this.bannerId); } get config(): BannerConfig { @@ -182,23 +185,30 @@ export class CookieBannerClient { ): Promise { const cfg = this.config; const url = `${this.baseUrl}/${this.bannerId}/consents`; + const body = { + visitor_id: this.visitorId, + version: cfg.version, + action, + consent_data: consentData, + }; - const record = await fetchJSON(url, { - method: "POST", - body: { - visitor_id: this.visitorId, - version: cfg.version, - action, - consent_data: consentData, - }, - }); + let record: ConsentRecord | null = null; + try { + record = await fetchJSON(url, { + method: "POST", + body, + }); + void flush(this.bannerId); + } catch { + enqueue(this.bannerId, url, body); + } this.consent = { visitor_id: this.visitorId, version: cfg.version, action, consent_data: consentData, - created_at: record.created_at, + created_at: record?.created_at ?? "", }; setConsentCookie( @@ -213,7 +223,12 @@ export class CookieBannerClient { this.activate(consentData); - return record; + return record ?? { + id: "", + visitor_id: this.visitorId, + action, + created_at: "", + }; } private activate(consentData: Record): void { diff --git a/packages/cookie-banner/src/http.ts b/packages/cookie-banner/src/http.ts index 134aae13a..be975b70a 100644 --- a/packages/cookie-banner/src/http.ts +++ b/packages/cookie-banner/src/http.ts @@ -21,9 +21,9 @@ import { TimeoutError, } from "./errors"; -const DEFAULT_TIMEOUT_MS = 10_000; -const MAX_RETRIES = 3; -const BASE_DELAY_MS = 1_000; +const DEFAULT_TIMEOUT_MS = 5_000; +const MAX_RETRIES = 2; +const BASE_DELAY_MS = 500; export interface RequestOptions { method?: string; diff --git a/packages/cookie-banner/src/queue.ts b/packages/cookie-banner/src/queue.ts new file mode 100644 index 000000000..8506358b1 --- /dev/null +++ b/packages/cookie-banner/src/queue.ts @@ -0,0 +1,96 @@ +// 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 { fetchJSON } from "./http"; + +const STORAGE_KEY_PREFIX = "probo_consent"; +const MAX_QUEUE_SIZE = 10; +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +interface PendingConsent { + url: string; + body: unknown; + timestamp: number; +} + +function storageKey(bannerId: string): string { + return `${STORAGE_KEY_PREFIX}:${bannerId}:queue`; +} + +function readQueue(bannerId: string): PendingConsent[] { + try { + const raw = localStorage.getItem(storageKey(bannerId)); + if (!raw) { + return []; + } + return JSON.parse(raw) as PendingConsent[]; + } catch { + return []; + } +} + +function writeQueue(bannerId: string, queue: PendingConsent[]): void { + try { + if (queue.length === 0) { + localStorage.removeItem(storageKey(bannerId)); + } else { + localStorage.setItem(storageKey(bannerId), JSON.stringify(queue)); + } + } catch { + // localStorage unavailable + } +} + +export function enqueue( + bannerId: string, + url: string, + body: unknown, +): void { + const queue = readQueue(bannerId); + queue.push({ url, body, timestamp: Date.now() }); + + if (queue.length > MAX_QUEUE_SIZE) { + queue.splice(0, queue.length - MAX_QUEUE_SIZE); + } + + writeQueue(bannerId, queue); +} + +export async function flush(bannerId: string): Promise { + const now = Date.now(); + let queue = readQueue(bannerId); + + if (queue.length === 0) { + return; + } + + queue = queue.filter((entry) => now - entry.timestamp < MAX_AGE_MS); + + if (queue.length === 0) { + writeQueue(bannerId, []); + return; + } + + const remaining: PendingConsent[] = []; + + for (const entry of queue) { + try { + await fetchJSON(entry.url, { method: "POST", body: entry.body }); + } catch { + remaining.push(entry); + } + } + + writeQueue(bannerId, remaining); +}