diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index b559b900..06ad9048 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/browser-sdk", - "version": "1.4.4", + "version": "1.4.6", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/bulkQueue.ts b/packages/browser-sdk/src/bulkQueue.ts index 0d83c611..3c605319 100644 --- a/packages/browser-sdk/src/bulkQueue.ts +++ b/packages/browser-sdk/src/bulkQueue.ts @@ -1,16 +1,7 @@ import { logResponseError } from "./utils/responseError"; -import { - BULK_QUEUE_FLUSH_DELAY_MS, - BULK_QUEUE_MAX_SIZE, - BULK_QUEUE_RETRY_BASE_DELAY_MS, - BULK_QUEUE_RETRY_MAX_DELAY_MS, -} from "./config"; +import { BULK_QUEUE_FLUSH_DELAY_MS, BULK_QUEUE_MAX_SIZE } from "./config"; import { Logger } from "./logger"; -const BULK_QUEUE_STORAGE_KEY = "__reflag_bulk_queue_v1"; -const WARN_AFTER_CONSECUTIVE_FAILURES = 10; -const WARN_AFTER_FAILURE_MS = 5 * 60 * 1000; -const WARN_THROTTLE_MS = 15 * 60 * 1000; const DROP_ERROR_THROTTLE_MS = 15 * 60 * 1000; type PayloadContext = { @@ -61,84 +52,19 @@ export type BulkEvent = export type BulkQueueOptions = { flushDelayMs?: number; maxSize?: number; - retryBaseDelayMs?: number; - retryMaxDelayMs?: number; - storageKey?: string; logger?: Logger; }; -function getSessionStorage(): Storage | null { - try { - if (typeof sessionStorage === "undefined") { - return null; - } - return sessionStorage; - } catch { - return null; - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function isBulkEvent(value: unknown): value is BulkEvent { - if (!isObject(value) || typeof value.type !== "string") { - return false; - } - - if (value.type === "user") { - return typeof value.userId === "string"; - } - - if (value.type === "company") { - return typeof value.companyId === "string"; - } - - if (value.type === "event") { - return typeof value.userId === "string" && typeof value.event === "string"; - } - - if (value.type === "feature-flag-event") { - return ( - typeof value.key === "string" && - (value.action === "check-is-enabled" || value.action === "check-config") - ); - } - - if (value.type === "prompt-event") { - return ( - typeof value.featureId === "string" && - typeof value.promptId === "string" && - typeof value.userId === "string" && - typeof value.promptedQuestion === "string" && - (value.action === "received" || - value.action === "shown" || - value.action === "dismissed") - ); - } - - return false; -} - export class BulkQueue { private readonly flushDelayMs: number; private readonly maxSize: number; - private readonly retryBaseDelayMs: number; - private readonly retryMaxDelayMs: number; - private readonly storageKey: string; - private readonly storage: Storage | null; private readonly logger?: Logger; private readonly sendBulk: (events: BulkEvent[]) => Promise; private queue: BulkEvent[] = []; private timer: ReturnType | null = null; private inFlightBatch: BulkEvent[] | null = null; - private inFlightPromise: Promise | null = null; - private retryCount = 0; - private consecutiveFailures = 0; - private firstFailureAt: number | null = null; - private lastWarnAt: number | null = null; + private inFlightPromise: Promise | null = null; private lastDropErrorAt: number | null = null; private totalDroppedEvents = 0; private droppedSinceLastError = 0; @@ -150,24 +76,12 @@ export class BulkQueue { this.sendBulk = sendBulk; this.flushDelayMs = opts.flushDelayMs ?? BULK_QUEUE_FLUSH_DELAY_MS; this.maxSize = opts.maxSize ?? BULK_QUEUE_MAX_SIZE; - this.retryBaseDelayMs = - opts.retryBaseDelayMs ?? BULK_QUEUE_RETRY_BASE_DELAY_MS; - this.retryMaxDelayMs = - opts.retryMaxDelayMs ?? BULK_QUEUE_RETRY_MAX_DELAY_MS; - this.storageKey = opts.storageKey ?? BULK_QUEUE_STORAGE_KEY; - this.storage = getSessionStorage(); this.logger = opts.logger; - - this.restoreQueueFromStorage(); - if (this.queue.length > 0) { - this.schedule(this.flushDelayMs); - } } async enqueue(event: BulkEvent) { this.queue.push(event); this.trimPendingQueueToCapacity(); - this.persistQueueToStorage(); const maxPending = Math.max(0, this.maxSize - this.getInFlightBatchSize()); if (this.queue.length > 0 && this.queue.length >= maxPending) { @@ -198,19 +112,17 @@ export class BulkQueue { const sendPromise = this.sendBatch(batch); this.inFlightPromise = sendPromise; - let nextDelayMs: number | null = null; try { - nextDelayMs = await sendPromise; + await sendPromise; } finally { if (this.inFlightPromise === sendPromise) { this.inFlightPromise = null; } this.inFlightBatch = null; - this.persistQueueToStorage(); } - if (this.queue.length > 0 && !this.timer && nextDelayMs !== null) { - this.schedule(nextDelayMs); + if (this.queue.length > 0 && !this.timer) { + this.schedule(this.flushDelayMs); } } @@ -218,15 +130,6 @@ export class BulkQueue { return this.queue.length + this.getInFlightBatchSize(); } - private getRetryDelay() { - const maxExponent = 6; - const exponent = Math.min(this.retryCount - 1, maxExponent); - return Math.min( - this.retryBaseDelayMs * 2 ** exponent, - this.retryMaxDelayMs, - ); - } - private schedule(delayMs: number) { if (this.timer || this.inFlightPromise || this.queue.length === 0) { return; @@ -244,130 +147,27 @@ export class BulkQueue { } private async sendBatch(batch: BulkEvent[]) { - let nextDelayMs: number | null = null; - + let res: Response; try { - const res = await this.sendBulk(batch); - if (!res.ok) { - if (res.status >= 400 && res.status < 500) { - this.retryCount = 0; - this.firstFailureAt = null; - this.consecutiveFailures = 0; - this.lastWarnAt = null; - if (this.logger) { - await logResponseError({ - logger: this.logger, - res, - message: - "bulk request failed with non-retriable status; dropping batch", - }); - } - nextDelayMs = this.flushDelayMs; - } else { - throw new Error(`unexpected status ${res.status}`); - } - } else { - this.retryCount = 0; - if (this.firstFailureAt !== null && this.consecutiveFailures > 0) { - this.logger?.info("bulk delivery recovered", { - outageMs: Date.now() - this.firstFailureAt, - failedAttempts: this.consecutiveFailures, - }); - } - this.firstFailureAt = null; - this.consecutiveFailures = 0; - this.lastWarnAt = null; - nextDelayMs = this.flushDelayMs; - } + res = await this.sendBulk(batch); } catch (error) { - this.queue = batch.concat(this.queue); - - const now = Date.now(); - if (this.firstFailureAt === null) { - this.firstFailureAt = now; - } - this.consecutiveFailures += 1; - this.retryCount += 1; - const retryInMs = this.getRetryDelay(); - nextDelayMs = retryInMs; - this.logger?.info("bulk retry scheduled", { - retryInMs, - queueSize: this.queue.length + this.getInFlightBatchSize(), - consecutiveFailures: this.consecutiveFailures, + this.logger?.error("bulk request failed; dropping batch", { + error, + batchSize: batch.length, }); - - const outageMs = now - this.firstFailureAt; - const shouldWarn = - this.consecutiveFailures >= WARN_AFTER_CONSECUTIVE_FAILURES || - outageMs >= WARN_AFTER_FAILURE_MS; - const canWarnNow = - this.lastWarnAt === null || now - this.lastWarnAt >= WARN_THROTTLE_MS; - if (shouldWarn && canWarnNow) { - this.logger?.warn("bulk delivery degraded", { - consecutiveFailures: this.consecutiveFailures, - outageMs, - queueSize: this.queue.length + this.getInFlightBatchSize(), - retryInMs, - error, - }); - this.lastWarnAt = now; - } - } - - return nextDelayMs; - } - - private getPersistedQueue() { - const inFlight = this.inFlightBatch ?? []; - return inFlight.concat(this.queue).slice(-this.maxSize); - } - - private persistQueueToStorage() { - if (!this.storage) { return; } - try { - const persisted = this.getPersistedQueue(); - if (persisted.length === 0) { - this.storage.removeItem(this.storageKey); - return; + if (!res.ok) { + if (this.logger) { + await logResponseError({ + logger: this.logger, + res, + message: "bulk request failed; dropping batch", + }); } - - this.storage.setItem(this.storageKey, JSON.stringify(persisted)); - } catch { - // ignore persistence failures - } - } - - private restoreQueueFromStorage() { - if (!this.storage) { return; } - - try { - const raw = this.storage.getItem(this.storageKey); - if (!raw) { - return; - } - - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - throw new Error("invalid stored bulk queue"); - } - - this.queue = parsed.filter(isBulkEvent).slice(-this.maxSize); - if (this.queue.length === 0) { - this.storage.removeItem(this.storageKey); - } - } catch { - this.queue = []; - try { - this.storage.removeItem(this.storageKey); - } catch { - // ignore cleanup failures - } - } } private getInFlightBatchSize() { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 461c350e..af22a57f 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -310,8 +310,7 @@ export type InitOptions = ReflagDeprecatedContext & { /** * Queue settings for tracking updates sent to `/bulk`. * Applies to user/company updates, check events, and prompt events. - * Queue data is persisted in `sessionStorage` and restored on reloads - * within the same browser tab. + * Events are buffered in memory and flushed in the background. */ trackingQueue?: { /** @@ -329,14 +328,12 @@ export type InitOptions = ReflagDeprecatedContext & { maxSize?: number; /** - * Base retry delay in milliseconds after a failed bulk request. - * Defaults to 5000ms. + * Deprecated: retries are no longer performed for bulk delivery. */ retryBaseDelayMs?: number; /** - * Maximum retry delay in milliseconds after repeated failures. - * Defaults to 60000ms. + * Deprecated: retries are no longer performed for bulk delivery. */ retryMaxDelayMs?: number; }; @@ -432,6 +429,7 @@ export class ReflagClient { private autoFeedbackInit: Promise | undefined; private readonly flagsClient: FlagsClient; private readonly bulkQueue: BulkQueue | undefined; + private readonly handleBeforeUnload?: () => void; public readonly logger: Logger; @@ -476,17 +474,27 @@ export class ReflagClient { }); if (!this.config.offline && this.config.enableTracking) { this.bulkQueue = new BulkQueue( - (events) => this.httpClient.post({ path: "/bulk", body: events }), + (events) => + this.httpClient.post({ + path: "/bulk", + body: events, + keepalive: true, + }), { flushDelayMs: opts.trackingQueue?.flushDelayMs, maxSize: opts.trackingQueue?.maxSize, - retryBaseDelayMs: opts.trackingQueue?.retryBaseDelayMs, - retryMaxDelayMs: opts.trackingQueue?.retryMaxDelayMs, - storageKey: `__reflag_bulk_queue_v1:${this.config.apiBaseUrl}:${this.publishableKey}`, logger: this.logger, }, ); } + if (this.bulkQueue && !IS_SERVER) { + this.handleBeforeUnload = () => { + void this.bulkQueue?.flush(); + }; + window.addEventListener("beforeunload", this.handleBeforeUnload, { + capture: true, + }); + } const bulkQueue = this.bulkQueue; @@ -597,6 +605,12 @@ export class ReflagClient { * **/ async stop() { + if (this.handleBeforeUnload && !IS_SERVER) { + window.removeEventListener("beforeunload", this.handleBeforeUnload, { + capture: true, + }); + } + if (this.bulkQueue) { await this.bulkQueue.flush(); let remaining = await this.bulkQueue.size(); diff --git a/packages/browser-sdk/src/config.ts b/packages/browser-sdk/src/config.ts index b45ef204..ceec3b46 100644 --- a/packages/browser-sdk/src/config.ts +++ b/packages/browser-sdk/src/config.ts @@ -11,8 +11,6 @@ export const FLAG_EVENTS_PER_MIN = 1; export const FLAGS_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000; // expire entirely after 30 days export const BULK_QUEUE_MAX_SIZE = 100; export const BULK_QUEUE_FLUSH_DELAY_MS = 2000; -export const BULK_QUEUE_RETRY_BASE_DELAY_MS = 5000; -export const BULK_QUEUE_RETRY_MAX_DELAY_MS = 60_000; export const IS_SERVER = typeof window === "undefined" || typeof document === "undefined"; diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index 53272d76..20e6c939 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -4,6 +4,7 @@ import { Logger } from "../logger"; import { AblySSEChannel, openAblySSEChannel } from "../sse"; import { Position } from "../ui/types"; import { logResponseError } from "../utils/responseError"; +import { retryOnThrow } from "../utils/retry"; import { FeedbackSubmission, @@ -19,6 +20,8 @@ import { getAuthToken } from "./promptStorage"; import * as feedbackLib from "./ui"; import { DEFAULT_POSITION } from "./ui"; +const INITIAL_FETCH_RETRY_DELAYS_MS = [0, 5000]; + export type Key = string; export type FeedbackOptions = { @@ -494,7 +497,7 @@ export class AutoFeedback { } try { - if (!channel) { + return await retryOnThrow(INITIAL_FETCH_RETRY_DELAYS_MS, async () => { const res = await this.httpClient.post({ path: `/feedback/prompting-init`, body: { @@ -504,11 +507,18 @@ export class AutoFeedback { this.logger.debug(`automatic feedback status sent`, res); if (!res.ok) { - await logResponseError({ - logger: this.logger, - res, - message: "automatic feedback init request failed", - }); + try { + await logResponseError({ + logger: this.logger, + res, + message: "automatic feedback init request failed", + }); + } catch { + this.logger.error( + `error initializing automatic feedback`, + new Error(`unexpected response code: ${res.status}`), + ); + } return; } @@ -516,11 +526,12 @@ export class AutoFeedback { if (body.success && body.channel) { return body.channel; } - } + + return undefined; + }); } catch (e) { this.logger.error(`error initializing automatic feedback`, e); return; } - return; } } diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 0a131c46..c00c6a9d 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -10,9 +10,12 @@ import { getDefaultStorageAdapter, StorageAdapter } from "../storage"; import { createAbortController } from "../utils/abortController"; import { createEventTarget } from "../utils/eventTarget"; import { logResponseError, parseResponseError } from "../utils/responseError"; +import { retryOnThrow } from "../utils/retry"; import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache"; +const INITIAL_FETCH_RETRY_DELAYS_MS = [0, 5000]; + /** * A flag fetched from the server. */ @@ -411,33 +414,51 @@ export class FlagsClient { async fetchFlags(): Promise { const params = this.fetchParams(); try { - const res = await this.httpClient.get({ - path: "/features/evaluated", - timeoutMs: this.config.timeoutMs, - params, - }); - - if (!res.ok) { - const { errorDetails, errorSummary } = await parseResponseError(res); - const fallbackBody = errorDetails.responseBody - ? ` - ${errorDetails.responseBody}` - : ""; - - throw new Error( - `unexpected response code: ${res.status}${ - errorSummary ? ` - ${errorSummary}` : fallbackBody - }`, - ); - } + return await retryOnThrow(INITIAL_FETCH_RETRY_DELAYS_MS, async () => { + const res = await this.httpClient.get({ + path: "/features/evaluated", + timeoutMs: this.config.timeoutMs, + params, + }); - const typeRes = validateFlagsResponse(await res.json()); - if (!typeRes || !typeRes.success) { - throw new Error("unable to validate response"); - } + if (!res.ok) { + let errorSummary = ""; + let fallbackBody = ""; + try { + const { errorDetails, errorSummary: parsedSummary } = + await parseResponseError(res); + errorSummary = parsedSummary ?? ""; + fallbackBody = errorDetails.responseBody + ? ` - ${errorDetails.responseBody}` + : ""; + } catch { + // Best-effort response parsing only; the response itself is enough to fail. + } + + this.logger.error( + "error fetching flags:", + new Error( + `unexpected response code: ${res.status}${ + errorSummary ? ` - ${errorSummary}` : fallbackBody + }`, + ), + ); + return; + } + + const typeRes = validateFlagsResponse(await res.json()); + if (!typeRes || !typeRes.success) { + this.logger.error( + "error fetching flags:", + new Error("unable to validate response"), + ); + return; + } - return typeRes.flags; + return typeRes.flags; + }); } catch (e) { - this.logger.error("error fetching flags: ", e); + this.logger.error("error fetching flags:", e); return; } } diff --git a/packages/browser-sdk/src/httpClient.ts b/packages/browser-sdk/src/httpClient.ts index bf9f207a..ed243a19 100644 --- a/packages/browser-sdk/src/httpClient.ts +++ b/packages/browser-sdk/src/httpClient.ts @@ -1,17 +1,26 @@ import { createAbortController } from "./utils/abortController"; import { API_BASE_URL, SDK_VERSION, SDK_VERSION_HEADER_NAME } from "./config"; +const KEEPALIVE_MAX_IN_FLIGHT_BYTES = 60 * 1024; +const KEEPALIVE_MAX_IN_FLIGHT_REQUESTS = 15; + export interface HttpClientOptions { baseUrl?: string; sdkVersion?: string; credentials?: RequestCredentials; } +function getBodyByteLength(value: string) { + return new TextEncoder().encode(value).length; +} + export class HttpClient { private readonly baseUrl: string; private readonly sdkVersion: string; private readonly fetchOptions: RequestInit; + private inFlightKeepaliveBytes = 0; + private inFlightKeepaliveRequests = 0; constructor( public publishableKey: string, @@ -78,20 +87,43 @@ export class HttpClient { async post({ path, body, + keepalive, }: { host?: string; path: string; body: any; + keepalive?: boolean; }): ReturnType { - return fetch(this.getUrl(path), { - ...this.fetchOptions, - method: "POST", - headers: { - "Content-Type": "application/json", - [SDK_VERSION_HEADER_NAME]: this.sdkVersion, - Authorization: `Bearer ${this.publishableKey}`, - }, - body: JSON.stringify(body), - }); + const serializedBody = JSON.stringify(body); + const bodyBytes = getBodyByteLength(serializedBody); + const shouldUseKeepalive = + keepalive && + this.inFlightKeepaliveBytes + bodyBytes <= + KEEPALIVE_MAX_IN_FLIGHT_BYTES && + this.inFlightKeepaliveRequests < KEEPALIVE_MAX_IN_FLIGHT_REQUESTS; + + if (shouldUseKeepalive) { + this.inFlightKeepaliveBytes += bodyBytes; + this.inFlightKeepaliveRequests += 1; + } + + try { + return await fetch(this.getUrl(path), { + ...this.fetchOptions, + method: "POST", + keepalive: shouldUseKeepalive, + headers: { + "Content-Type": "application/json", + [SDK_VERSION_HEADER_NAME]: this.sdkVersion, + Authorization: `Bearer ${this.publishableKey}`, + }, + body: serializedBody, + }); + } finally { + if (shouldUseKeepalive) { + this.inFlightKeepaliveBytes -= bodyBytes; + this.inFlightKeepaliveRequests -= 1; + } + } } } diff --git a/packages/browser-sdk/src/utils/retry.ts b/packages/browser-sdk/src/utils/retry.ts new file mode 100644 index 00000000..5b038e16 --- /dev/null +++ b/packages/browser-sdk/src/utils/retry.ts @@ -0,0 +1,31 @@ +function delay(ms: number) { + if (ms <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +export async function retryOnThrow( + retryDelaysMs: number[], + operation: () => Promise, +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) { + try { + return await operation(); + } catch (error) { + lastError = error; + if (attempt === retryDelaysMs.length) { + throw error; + } + + await delay(retryDelaysMs[attempt] ?? 0); + } + } + + throw lastError; +} diff --git a/packages/browser-sdk/test/bulkQueue.test.ts b/packages/browser-sdk/test/bulkQueue.test.ts index e3006297..5b878859 100644 --- a/packages/browser-sdk/test/bulkQueue.test.ts +++ b/packages/browser-sdk/test/bulkQueue.test.ts @@ -34,13 +34,11 @@ const lateTrackEvent: BulkEvent = { describe("BulkQueue", () => { beforeEach(() => { vi.useFakeTimers(); - sessionStorage.clear(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); - sessionStorage.clear(); }); it("batches events and flushes after the delay", async () => { @@ -64,28 +62,33 @@ describe("BulkQueue", () => { expect(sendBulk).toHaveBeenCalledWith([userEvent, companyEvent]); }); - it("retries failed bulk requests later", async () => { + it("drops thrown bulk request failures without retrying", async () => { const sendBulk = vi .fn<(events: BulkEvent[]) => Promise>() - .mockRejectedValueOnce(new Error("network")) - .mockResolvedValue(new Response("", { status: 200 })); + .mockRejectedValueOnce(new Error("network")); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; const queue = new BulkQueue(sendBulk, { flushDelayMs: 10, - retryBaseDelayMs: 20, - retryMaxDelayMs: 20, + logger, }); await queue.enqueue(trackEvent); await vi.advanceTimersByTimeAsync(10); expect(sendBulk).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(19); - expect(sendBulk).toHaveBeenCalledTimes(1); - - await vi.advanceTimersByTimeAsync(1); - expect(sendBulk).toHaveBeenCalledTimes(2); - expect(sendBulk).toHaveBeenNthCalledWith(2, [trackEvent]); + expect(await queue.size()).toBe(0); + expect(logger.error).toHaveBeenCalledWith( + "bulk request failed; dropping batch", + expect.objectContaining({ + error: expect.any(Error), + batchSize: 1, + }), + ); }); it("drops 4xx responses, logs error, and does not retry", async () => { @@ -100,8 +103,6 @@ describe("BulkQueue", () => { }; const queue = new BulkQueue(sendBulk, { flushDelayMs: 10, - retryBaseDelayMs: 20, - retryMaxDelayMs: 20, logger, }); @@ -111,7 +112,7 @@ describe("BulkQueue", () => { expect(sendBulk).toHaveBeenCalledTimes(1); expect(await queue.size()).toBe(0); expect(logger.error).toHaveBeenCalledWith( - "bulk request failed with non-retriable status; dropping batch", + "bulk request failed; dropping batch", expect.objectContaining({ status: 400, responseBody: "invalid payload", @@ -163,6 +164,35 @@ describe("BulkQueue", () => { ); }); + it("drops 5xx responses without retrying", async () => { + const sendBulk = vi + .fn<(events: BulkEvent[]) => Promise>() + .mockResolvedValue(new Response("server error", { status: 500 })); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const queue = new BulkQueue(sendBulk, { + flushDelayMs: 10, + logger, + }); + + await queue.enqueue(trackEvent); + await vi.advanceTimersByTimeAsync(10); + + expect(sendBulk).toHaveBeenCalledTimes(1); + expect(await queue.size()).toBe(0); + expect(logger.error).toHaveBeenCalledWith( + "bulk request failed; dropping batch", + expect.objectContaining({ + status: 500, + responseBody: "server error", + }), + ); + }); + it("does not drop newly queued events when an older batch completes", async () => { let resolveFirstSend: ((res: Response) => void) | undefined; const firstSend = new Promise((resolve) => { @@ -223,29 +253,6 @@ describe("BulkQueue", () => { resolveSend?.(new Response("", { status: 200 })); }); - it("restores queue state between instances in the same tab", async () => { - const firstSend = vi - .fn<(events: BulkEvent[]) => Promise>() - .mockResolvedValue(new Response("", { status: 200 })); - const firstQueue = new BulkQueue(firstSend, { - flushDelayMs: 10_000, - }); - - await firstQueue.enqueue(userEvent); - expect(await firstQueue.size()).toBe(1); - - const secondSend = vi - .fn<(events: BulkEvent[]) => Promise>() - .mockResolvedValue(new Response("", { status: 200 })); - const secondQueue = new BulkQueue(secondSend, { - flushDelayMs: 10_000, - }); - - expect(await secondQueue.size()).toBe(1); - await secondQueue.flush(); - expect(secondSend).toHaveBeenCalledWith([userEvent]); - }); - it("requires a second flush to send pending events after an in-flight batch", async () => { let resolveFirstSend: ((res: Response) => void) | undefined; const firstSend = new Promise((resolve) => { diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 382ea4ac..f3c6f8b7 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -46,6 +46,7 @@ describe("ReflagClient", () => { await vi.waitFor(() => expect(httpClientPost).toHaveBeenCalledWith({ path: "/bulk", + keepalive: true, body: [ { type: "user", @@ -73,6 +74,7 @@ describe("ReflagClient", () => { await vi.waitFor(() => expect(httpClientPost).toHaveBeenCalledWith({ path: "/bulk", + keepalive: true, body: [ { type: "company", @@ -197,6 +199,20 @@ describe("ReflagClient", () => { }); describe("stop", () => { + it("flushes the bulk queue on beforeunload and removes the listener on stop", async () => { + const bulkQueue = client["bulkQueue"]; + expect(bulkQueue).toBeDefined(); + + const flushSpy = vi.spyOn(bulkQueue!, "flush").mockResolvedValue(); + window.dispatchEvent(new Event("beforeunload")); + expect(flushSpy).toHaveBeenCalledTimes(1); + + await client.stop(); + + window.dispatchEvent(new Event("beforeunload")); + expect(flushSpy).toHaveBeenCalledTimes(2); + }); + it("throws if queued bulk events remain after final flush attempt", async () => { const bulkQueue = client["bulkQueue"]; expect(bulkQueue).toBeDefined(); diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index b531f353..9a2261db 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -152,7 +152,9 @@ describe("FlagsClient", () => { fallbackFlags: ["huddle"], }); - await flagsClient.initialize(); + const initializePromise = flagsClient.initialize(); + await vi.advanceTimersByTimeAsync(5000); + await initializePromise; expect(flagsClient.getFlags()).toStrictEqual({ huddle: { isEnabled: true, @@ -179,7 +181,9 @@ describe("FlagsClient", () => { }, }); - await flagsClient.initialize(); + const initializePromise = flagsClient.initialize(); + await vi.advanceTimersByTimeAsync(5000); + await initializePromise; expect(flagsClient.getFlags()).toStrictEqual({ huddle: { isEnabled: true, @@ -196,6 +200,54 @@ describe("FlagsClient", () => { }); }); + test("retries thrown flag fetch failures before succeeding", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + + vi.mocked(httpClient.get) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValue( + new Response(JSON.stringify(flagResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const flagsClient = newFlagsClient(); + const initializePromise = flagsClient.initialize(); + await vi.advanceTimersByTimeAsync(5000); + await initializePromise; + + expect(httpClient.get).toHaveBeenCalledTimes(3); + expect(testLogger.error).not.toHaveBeenCalled(); + expect(flagsClient.getFlags()).toEqual(flagsResult); + }); + + test("retries thrown flag body-read failures before succeeding", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + + vi.mocked(httpClient.get) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), + } as unknown as Response) + .mockResolvedValue( + new Response(JSON.stringify(flagResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const flagsClient = newFlagsClient(); + const initializePromise = flagsClient.initialize(); + await vi.advanceTimersByTimeAsync(0); + await initializePromise; + + expect(httpClient.get).toHaveBeenCalledTimes(2); + expect(testLogger.error).not.toHaveBeenCalled(); + expect(flagsClient.getFlags()).toEqual(flagsResult); + }); + test("caches response", async () => { const { newFlagsClient, httpClient } = flagsClientFactory(); @@ -226,8 +278,10 @@ describe("FlagsClient", () => { vi.advanceTimersByTime(TEST_STALE_MS + 1); // fail this time - await flagsClient.fetchFlags(); - expect(httpClient.get).toBeCalledTimes(2); + const fetchPromise = flagsClient.fetchFlags(); + await vi.advanceTimersByTimeAsync(5000); + await fetchPromise; + expect(httpClient.get).toBeCalledTimes(4); const staleFlags = flagsClient.getFlags(); expect(staleFlags).toEqual(flagsResult); diff --git a/packages/browser-sdk/test/httpClient.test.ts b/packages/browser-sdk/test/httpClient.test.ts index 9389b5ec..2573802e 100644 --- a/packages/browser-sdk/test/httpClient.test.ts +++ b/packages/browser-sdk/test/httpClient.test.ts @@ -64,6 +64,112 @@ describe("sets `credentials`", () => { ); }); + test("uses keepalive for small request bodies when requested", async () => { + const client = new HttpClient("publishableKey"); + + await client.post({ path: "/test", body: { ok: true }, keepalive: true }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ keepalive: true }), + ); + }); + + test("does not use keepalive for large request bodies", async () => { + const client = new HttpClient("publishableKey"); + const largeBody = { payload: "x".repeat(70 * 1024) }; + + await client.post({ path: "/test", body: largeBody, keepalive: true }); + + expect(global.fetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ keepalive: false }), + ); + }); + + test("does not use keepalive when in-flight keepalive bytes would exceed the budget", async () => { + let resolveFirstFetch: ((value: Response) => void) | undefined; + vi.mocked(global.fetch) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockResolvedValueOnce(new Response()); + + const client = new HttpClient("publishableKey"); + const body = { payload: "x".repeat(35 * 1024) }; + + const firstRequest = client.post({ + path: "/test", + body, + keepalive: true, + }); + await Promise.resolve(); + + await client.post({ path: "/test", body, keepalive: true }); + + expect(vi.mocked(global.fetch).mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ keepalive: true }), + ); + expect(vi.mocked(global.fetch).mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ keepalive: false }), + ); + + resolveFirstFetch?.(new Response()); + await firstRequest; + }); + + test("does not use keepalive when the in-flight request count is at the limit", async () => { + const pendingRequests: Array<{ + resolve: (value: Response) => void; + }> = []; + let callCount = 0; + vi.mocked(global.fetch).mockImplementation(() => { + callCount += 1; + if (callCount > 15) { + return Promise.resolve(new Response()); + } + + let resolve!: (value: Response) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + pendingRequests.push({ resolve }); + return promise; + }); + + const client = new HttpClient("publishableKey"); + const body = { payload: "ok" }; + + const inFlightRequests = Array.from({ length: 15 }, () => + client.post({ + path: "/test", + body, + keepalive: true, + }), + ); + await Promise.resolve(); + + await client.post({ path: "/test", body, keepalive: true }); + + expect( + vi + .mocked(global.fetch) + .mock.calls.slice(0, 15) + .every(([, init]) => (init as RequestInit | undefined)?.keepalive), + ).toBe(true); + expect(vi.mocked(global.fetch).mock.calls[15]?.[1]).toEqual( + expect.objectContaining({ keepalive: false }), + ); + + for (const request of pendingRequests) { + request.resolve(new Response()); + } + await Promise.all(inFlightRequests); + }); + test("does not require a writable `URL.search` property", async () => { const OriginalURL = global.URL; diff --git a/packages/browser-sdk/test/init.test.ts b/packages/browser-sdk/test/init.test.ts index 5591344e..fdc093b7 100644 --- a/packages/browser-sdk/test/init.test.ts +++ b/packages/browser-sdk/test/init.test.ts @@ -68,6 +68,9 @@ describe("init", () => { user: { id: "foo" }, apiBaseUrl: "https://example.com", enableTracking: false, + feedback: { + enableAutoFeedback: false, + }, }); await reflagInstance.initialize(); @@ -97,7 +100,6 @@ describe("init", () => { const reflagInstance = new ReflagClient({ publishableKey: KEY, user: { id: "foo" }, - apiBaseUrl: "https://example.com", enableTracking: false, feedback: { enableAutoFeedback: false, diff --git a/packages/browser-sdk/test/usage.test.ts b/packages/browser-sdk/test/usage.test.ts index 274f311b..982798d3 100644 --- a/packages/browser-sdk/test/usage.test.ts +++ b/packages/browser-sdk/test/usage.test.ts @@ -201,6 +201,86 @@ describe("feedback prompting", () => { expect(openAblySSEChannel).toBeCalledTimes(0); }); + + test("retries prompting init fetch failures before succeeding", async () => { + vi.useFakeTimers(); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const post = vi.spyOn(HttpClient.prototype, "post"); + post + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + const reflagInstance = new ReflagClient({ + publishableKey: KEY, + user: { id: "foo" }, + enableTracking: false, + logger, + }); + const initializePromise = reflagInstance.initialize(); + await vi.advanceTimersByTimeAsync(5000); + await initializePromise; + + expect(post).toHaveBeenCalledTimes(3); + expect(logger.error).not.toHaveBeenCalled(); + expect(openAblySSEChannel).toBeCalledTimes(0); + } finally { + vi.useRealTimers(); + post.mockRestore(); + } + }); + + test("retries prompting init body-read failures before succeeding", async () => { + vi.useFakeTimers(); + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const post = vi.spyOn(HttpClient.prototype, "post"); + post + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), + } as unknown as Response) + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + try { + const reflagInstance = new ReflagClient({ + publishableKey: KEY, + user: { id: "foo" }, + enableTracking: false, + logger, + }); + const initializePromise = reflagInstance.initialize(); + await vi.advanceTimersByTimeAsync(0); + await initializePromise; + + expect(post).toHaveBeenCalledTimes(2); + expect(logger.error).not.toHaveBeenCalled(); + expect(openAblySSEChannel).toBeCalledTimes(0); + } finally { + vi.useRealTimers(); + post.mockRestore(); + } + }); }); describe("feedback state management", () => { diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 1b9f4b15..9c3da16c 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/openfeature-browser-provider", - "version": "1.3.2", + "version": "1.3.4", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { @@ -35,7 +35,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.4" + "@reflag/browser-sdk": "1.4.6" }, "devDependencies": { "@openfeature/core": "1.5.0", diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 2a6575a5..8301639b 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-sdk", - "version": "1.4.4", + "version": "1.4.6", "license": "MIT", "repository": { "type": "git", @@ -37,7 +37,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.4" + "@reflag/browser-sdk": "1.4.6" }, "peerDependencies": { "react": "*", diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index fb39999b..d94a6090 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/vue-sdk", - "version": "1.3.2", + "version": "1.3.4", "license": "MIT", "repository": { "type": "git", @@ -35,7 +35,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.4.4" + "@reflag/browser-sdk": "1.4.6" }, "peerDependencies": { "vue": "^3.0.0" diff --git a/yarn.lock b/yarn.lock index b8d20bfa..cd8c368f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5676,7 +5676,19 @@ __metadata: languageName: node linkType: hard -"@reflag/browser-sdk@npm:1.4.4, @reflag/browser-sdk@workspace:packages/browser-sdk": +"@reflag/browser-sdk@npm:1.4.3": + version: 1.4.3 + resolution: "@reflag/browser-sdk@npm:1.4.3" + dependencies: + "@floating-ui/dom": "npm:^1.6.8" + fast-equals: "npm:^5.2.2" + js-cookie: "npm:^3.0.5" + preact: "npm:^10.22.1" + checksum: 10c0/86d4799d9cf7dbdf1cd772a494e0ec153f14119bd252ff6ac60978003a528af5468804514dd365b300a44e0c1d4b8561451d9b23d090b050ee58980f08695e6b + languageName: node + linkType: hard + +"@reflag/browser-sdk@npm:1.4.6, @reflag/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@reflag/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -5817,7 +5829,7 @@ __metadata: dependencies: "@openfeature/core": "npm:1.5.0" "@openfeature/web-sdk": "npm:^1.3.0" - "@reflag/browser-sdk": "npm:1.4.4" + "@reflag/browser-sdk": "npm:1.4.6" "@reflag/eslint-config": "npm:0.0.2" "@reflag/tsconfig": "npm:0.0.2" "@types/node": "npm:^22.12.0" @@ -5875,11 +5887,26 @@ __metadata: languageName: unknown linkType: soft -"@reflag/react-sdk@npm:1.4.4, @reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": +"@reflag/react-sdk@npm:1.4.4": + version: 1.4.4 + resolution: "@reflag/react-sdk@npm:1.4.4" + dependencies: + "@reflag/browser-sdk": "npm:1.4.3" + peerDependencies: + react: "*" + react-dom: "*" + peerDependenciesMeta: + react-dom: + optional: true + checksum: 10c0/d87c9918444e69b872c26db34413721f8d2c7003fa201dad5c95bf0a69397f77d621c78180098a2b6031556a1ef1cfefbf37775dc0e128c5bcf92a1080e3ee6d + languageName: node + linkType: hard + +"@reflag/react-sdk@workspace:^, @reflag/react-sdk@workspace:packages/react-sdk": version: 0.0.0-use.local resolution: "@reflag/react-sdk@workspace:packages/react-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.4.4" + "@reflag/browser-sdk": "npm:1.4.6" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@testing-library/react": "npm:^15.0.7" @@ -5939,7 +5966,7 @@ __metadata: version: 0.0.0-use.local resolution: "@reflag/vue-sdk@workspace:packages/vue-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.4.4" + "@reflag/browser-sdk": "npm:1.4.6" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@types/jsdom": "npm:^21.1.6"