From 7fd92ba1d9c32fcf19a7adf29b592c9d433aa0c8 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 20 May 2026 15:52:06 +1200 Subject: [PATCH 1/2] feat(audience): exponential backoff, 429/Retry-After, HTTP timeout, 4xx drop (SDK-291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exponential backoff in MessageQueue: 5s → 10s → 20s → 40s → 60s cap - 429 handling: parse Retry-After header, override backoff with server delay, emit RATE_LIMITED - 30s AbortController timeout on fetch; timeouts surface as NETWORK_ERROR - 4xx (non-429) treated as terminal: drop batch, emit VALIDATION_REJECTED Co-Authored-By: Claude Sonnet 4.6 --- packages/audience/core/src/errors.ts | 48 ++++- packages/audience/core/src/queue.test.ts | 174 +++++++++++++++++++ packages/audience/core/src/queue.ts | 43 +++++ packages/audience/core/src/transport.test.ts | 86 +++++++++ packages/audience/core/src/transport.ts | 37 ++++ 5 files changed, 382 insertions(+), 6 deletions(-) diff --git a/packages/audience/core/src/errors.ts b/packages/audience/core/src/errors.ts index 29153d014e..10356f59d5 100644 --- a/packages/audience/core/src/errors.ts +++ b/packages/audience/core/src/errors.ts @@ -49,10 +49,15 @@ export class TransportError extends Error { * Implementations of `HttpSend` MUST NOT reject — failures travel * through this result type. Callers (notably `MessageQueue.flushUnload`) * rely on this contract for fire-and-forget paths. + * + * `retryAfterMs` is set when the server returned 429 with a parseable + * `Retry-After` header. The queue uses this to override the exponential + * backoff schedule with the server-supplied delay. */ export interface TransportResult { ok: boolean; error?: TransportError; + retryAfterMs?: number; } /** @@ -64,16 +69,20 @@ export interface TransportResult { * - `'NETWORK_ERROR'` — fetch rejected before a response was received * (network failure, CORS, DNS, etc.). * - `'VALIDATION_REJECTED'` — backend returned 2xx but the body reported - * `rejected > 0`. Terminal: retrying won't help, the - * messages were dropped from the queue. Inspect - * `responseBody` for the per-message detail when the - * backend provides it. + * `rejected > 0`, or the server returned a 4xx (non-429) + * indicating the payload was malformed or unauthorised. + * Terminal: retrying won't help, the messages were dropped + * from the queue. + * - `'RATE_LIMITED'` — server returned 429. The batch is retained and will + * be retried after the backoff window (honoring + * `Retry-After` when present). */ export type AudienceErrorCode = | 'FLUSH_FAILED' | 'CONSENT_SYNC_FAILED' | 'NETWORK_ERROR' - | 'VALIDATION_REJECTED'; + | 'VALIDATION_REJECTED' + | 'RATE_LIMITED'; /** * Public error type passed to the SDK's `onError` callback. Wraps the @@ -166,7 +175,34 @@ export function toAudienceError( }); } - // Generic HTTP failure (4xx / 5xx). + // 429 — rate limited, retryable. Batch is kept; queue applies backoff. + if (err.status === 429) { + return new AudienceError({ + code: 'RATE_LIMITED', + message: source === 'flush' + ? 'Flush rate limited (429)' + : 'Consent sync rate limited (429)', + status: err.status, + endpoint: err.endpoint, + responseBody: err.body, + }); + } + + // 4xx (non-429) — server deterministically rejected the payload. Terminal: + // a bad publishable key or malformed body won't be fixed by retrying. + if (err.status >= 400 && err.status < 500) { + return new AudienceError({ + code: source === 'flush' ? 'VALIDATION_REJECTED' : 'CONSENT_SYNC_FAILED', + message: source === 'flush' + ? `Flush rejected with status ${err.status}` + : `Consent sync failed with status ${err.status}`, + status: err.status, + endpoint: err.endpoint, + responseBody: err.body, + }); + } + + // 5xx or other non-2xx/4xx — server unhealthy. Keep batch, apply backoff. return new AudienceError({ code: source === 'flush' ? 'FLUSH_FAILED' : 'CONSENT_SYNC_FAILED', message: source === 'flush' diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index da9d515fe7..de42a34042 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -326,6 +326,180 @@ describe('MessageQueue', () => { }); }); +describe('exponential backoff', () => { + it('skips flush while inside the backoff window', async () => { + const start = 1_000_000; + jest.setSystemTime(start); + const send = jest.fn, Parameters>().mockResolvedValue(failResult); + const queue = createQueue(send); + + queue.enqueue(makeMessage('1')); + + // First flush — records failure, sets backoff to start+5000 + await queue.flush(); + expect(send).toHaveBeenCalledTimes(1); + + // Still inside backoff window — flush is a no-op + jest.setSystemTime(start + 4_999); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(1); + + // Past backoff window — flush proceeds + jest.setSystemTime(start + 5_001); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(2); + }); + + it('escalates backoff: 5s → 10s → 20s → 40s → 60s', async () => { + // Each step: trigger a failure, assert blocked before window, assert unblocked after. + const send = jest.fn, Parameters>().mockResolvedValue(failResult); + const queue = createQueue(send); + queue.enqueue(makeMessage('1')); + + let now = 1_000_000; + let calls = 0; + + const step = async (delay: number) => { + jest.setSystemTime(now); + await queue.flush(); + calls++; + expect(send).toHaveBeenCalledTimes(calls); + + jest.setSystemTime(now + delay - 1); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(calls); // still blocked + + now += delay + 1; + jest.setSystemTime(now); + }; + + await step(5_000); + await step(10_000); + await step(20_000); + await step(40_000); + await step(60_000); + }); + + it('resets backoff on a successful flush', async () => { + const start = 1_000_000; + jest.setSystemTime(start); + const send = jest.fn, Parameters>() + .mockResolvedValueOnce(failResult) + .mockResolvedValue(okResult); + const queue = createQueue(send); + + queue.enqueue(makeMessage('1')); + queue.enqueue(makeMessage('2')); + + // First flush fails — backoff starts + await queue.flush(); + expect(send).toHaveBeenCalledTimes(1); + + // Advance past the 5s window; second flush succeeds — backoff resets + jest.setSystemTime(start + 5_001); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(2); + + // Should be able to flush immediately after reset + queue.enqueue(makeMessage('3')); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(3); + }); + + it('uses Retry-After delay when server supplies it on 429', async () => { + const start = 1_000_000; + jest.setSystemTime(start); + const rateLimitResult: TransportResult = { + ok: false, + error: new TransportError({ status: 429, endpoint: 'https://api.immutable.com/v1/audience/messages' }), + retryAfterMs: 30_000, + }; + const send = jest.fn, Parameters>() + .mockResolvedValueOnce(rateLimitResult) + .mockResolvedValue(okResult); + const queue = createQueue(send); + + queue.enqueue(makeMessage('1')); + + await queue.flush(); + expect(send).toHaveBeenCalledTimes(1); + + // 29s — still inside Retry-After window + jest.setSystemTime(start + 29_000); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(1); + + // Past the window + jest.setSystemTime(start + 30_001); + await queue.flush(); + expect(send).toHaveBeenCalledTimes(2); + expect(queue.length).toBe(0); + }); + + it('fires RATE_LIMITED via onError on 429 and keeps the batch', async () => { + const onError = jest.fn(); + const rateLimitResult: TransportResult = { + ok: false, + error: new TransportError({ status: 429, endpoint: 'https://api.immutable.com/v1/audience/messages' }), + }; + const send = jest.fn, Parameters>().mockResolvedValue(rateLimitResult); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + await queue.flush(); + + expect(queue.length).toBe(1); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].code).toBe('RATE_LIMITED'); + expect(onError.mock.calls[0][0].status).toBe(429); + }); +}); + +describe('4xx drop', () => { + it('drops batch and fires VALIDATION_REJECTED on non-retryable 4xx', async () => { + const onError = jest.fn(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: new TransportError({ + status: 401, + endpoint: 'https://api.immutable.com/v1/audience/messages', + body: 'Unauthorized', + }), + }); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + queue.enqueue(makeMessage('2')); + await queue.flush(); + + expect(queue.length).toBe(0); + expect(onError).toHaveBeenCalledTimes(1); + const err = onError.mock.calls[0][0]; + expect(err.code).toBe('VALIDATION_REJECTED'); + expect(err.status).toBe(401); + }); + + it('drops batch and fires VALIDATION_REJECTED on 400', async () => { + const onError = jest.fn(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: new TransportError({ + status: 400, + endpoint: 'https://api.immutable.com/v1/audience/messages', + body: { error: 'bad request' }, + }), + }); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + await queue.flush(); + + expect(queue.length).toBe(0); + expect(onError.mock.calls[0][0].code).toBe('VALIDATION_REJECTED'); + expect(onError.mock.calls[0][0].status).toBe(400); + }); +}); + describe('page-unload flush (keepalive)', () => { it('flushes via keepalive fetch on visibilitychange to hidden', () => { const send = jest.fn, Parameters>().mockResolvedValue(okResult); diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index 7013182ca7..8cbc8aa896 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -82,6 +82,11 @@ export class MessageQueue { private readonly flushSize: number; + private consecutiveFailures = 0; + + // Epoch ms before which flush() is a no-op. Zero = no active backoff. + private nextAttemptAt = 0; + constructor( private readonly send: HttpSend, private readonly publishableKey: string, @@ -138,6 +143,7 @@ export class MessageQueue { */ async flush(): Promise { if (this.flushing || this.messages.length === 0) return; + if (Date.now() < this.nextAttemptAt) return; this.flushing = true; try { @@ -159,6 +165,14 @@ export class MessageQueue { if (result.ok || isTerminal) { this.messages = this.messages.slice(batch.length); this.persist(); + this.resetBackoff(); + } else if (audienceErr) { + // 429 with Retry-After overrides the exponential schedule. + if (result.retryAfterMs !== undefined) { + this.setBackoffUntil(Date.now() + result.retryAfterMs); + } else { + this.recordFailure(); + } } this.onFlush?.(result.ok, batch.length); @@ -237,6 +251,35 @@ export class MessageQueue { this.unloadBound = false; } + private backoffDelayMs(): number { + switch (this.consecutiveFailures) { + case 0: return 0; + case 1: return 5_000; + case 2: return 10_000; + case 3: return 20_000; + case 4: return 40_000; + default: return 60_000; + } + } + + private recordFailure(): void { + const now = Date.now(); + // Don't compound backoff if we're already inside a prior window. + if (now < this.nextAttemptAt) return; + this.consecutiveFailures++; + this.nextAttemptAt = now + this.backoffDelayMs(); + } + + private setBackoffUntil(untilMs: number): void { + this.consecutiveFailures++; + this.nextAttemptAt = untilMs; + } + + private resetBackoff(): void { + this.consecutiveFailures = 0; + this.nextAttemptAt = 0; + } + private persist(): void { storage.setItem(STORAGE_KEY, this.messages, this.storagePrefix); } diff --git a/packages/audience/core/src/transport.test.ts b/packages/audience/core/src/transport.test.ts index aa945dc56f..666d108674 100644 --- a/packages/audience/core/src/transport.test.ts +++ b/packages/audience/core/src/transport.test.ts @@ -38,6 +38,7 @@ describe('httpSend', () => { }, body: JSON.stringify(payload), keepalive: undefined, + signal: expect.any(AbortSignal), }); }); @@ -173,4 +174,89 @@ describe('httpSend', () => { await expect(httpSend('https://example.com', 'pk', payload)).resolves.toBeDefined(); }); + + it('returns status 0 NETWORK_ERROR when the 30s timeout fires', async () => { + jest.useFakeTimers(); + global.fetch = jest.fn().mockImplementation( + (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + (init.signal as AbortSignal).addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }), + ); + + const sendPromise = httpSend('https://example.com', 'pk', payload); + jest.advanceTimersByTime(30_000); + const result = await sendPromise; + + expect(result.ok).toBe(false); + expect(result.error?.status).toBe(0); + + jest.useRealTimers(); + }); + + it('returns ok:false with status 429 on rate limit response', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: () => null }, + }); + + const result = await httpSend('https://example.com', 'pk', payload); + + expect(result.ok).toBe(false); + expect(result.error?.status).toBe(429); + expect(result.retryAfterMs).toBeUndefined(); + }); + + it('attaches retryAfterMs (seconds) when 429 includes Retry-After delta-seconds', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: (name: string) => (name === 'retry-after' ? '60' : null) }, + }); + + const result = await httpSend('https://example.com', 'pk', payload); + + expect(result.ok).toBe(false); + expect(result.retryAfterMs).toBe(60_000); + }); + + it('attaches retryAfterMs (HTTP-date) when 429 includes a future Retry-After date', async () => { + const futureDate = new Date(Date.now() + 45_000).toUTCString(); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: (name: string) => (name === 'retry-after' ? futureDate : null) }, + }); + + const result = await httpSend('https://example.com', 'pk', payload); + + expect(result.ok).toBe(false); + expect(result.retryAfterMs).toBeGreaterThan(0); + expect(result.retryAfterMs).toBeLessThanOrEqual(45_000); + }); + + it('omits retryAfterMs when the Retry-After date is in the past', async () => { + const pastDate = new Date(Date.now() - 1_000).toUTCString(); + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: (name: string) => (name === 'retry-after' ? pastDate : null) }, + }); + + const result = await httpSend('https://example.com', 'pk', payload); + + expect(result.ok).toBe(false); + expect(result.retryAfterMs).toBeUndefined(); + }); + + it('passes the AbortSignal to fetch', async () => { + const mockFetch = jest.fn().mockResolvedValue({ ok: true }); + global.fetch = mockFetch; + + await httpSend('https://api.immutable.com/v1/audience/messages', 'pk_imx_test', payload); + + expect(mockFetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); }); diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index 8d92cc20fa..3498feace4 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -25,6 +25,24 @@ export type HttpSend = ( options?: TransportOptions, ) => Promise; +const HTTP_TIMEOUT_MS = 30_000; + +function parseRetryAfterMs(headers: Headers): number | null { + const value = headers.get?.('retry-after'); + if (!value) return null; + + const seconds = Number(value.trim()); + if (!Number.isNaN(seconds) && seconds >= 0) return Math.round(seconds * 1000); + + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + const ms = date.getTime() - Date.now(); + return ms > 0 ? ms : null; + } + + return null; +} + async function parseBody(response: Response): Promise { const contentType = response.headers?.get?.('content-type') ?? ''; try { @@ -43,6 +61,9 @@ export const httpSend: HttpSend = async ( payload, options, ) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); + try { const response = await fetch(url, { method: options?.method ?? 'POST', @@ -52,8 +73,22 @@ export const httpSend: HttpSend = async ( }, body: JSON.stringify(payload), keepalive: options?.keepalive, + signal: controller.signal, }); + if (response.status === 429) { + track('audience', 'transport_send_failed', { status: 429 }); + const retryAfterMs = parseRetryAfterMs(response.headers); + return { + ok: false, + error: new TransportError({ + status: 429, + endpoint: url, + }), + ...(retryAfterMs !== null ? { retryAfterMs } : {}), + }; + } + if (!response.ok) { const body = await parseBody(response); track('audience', 'transport_send_failed', { status: response.status }); @@ -108,5 +143,7 @@ export const httpSend: HttpSend = async ( }); trackError('audience', 'transport_send', error); return { ok: false, error }; + } finally { + clearTimeout(timeoutId); } }; From a75a24c4ac684c5295c7f743292030475b43c6c9 Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Wed, 20 May 2026 15:57:40 +1200 Subject: [PATCH 2/2] chore(audience): remove em-dashes from comments Co-Authored-By: Claude Sonnet 4.6 --- packages/audience/core/src/errors.ts | 34 ++++++++++---------- packages/audience/core/src/queue.test.ts | 18 +++++------ packages/audience/core/src/queue.ts | 10 +++--- packages/audience/core/src/transport.test.ts | 2 +- packages/audience/core/src/transport.ts | 2 +- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/audience/core/src/errors.ts b/packages/audience/core/src/errors.ts index 10356f59d5..5d95a8ba8e 100644 --- a/packages/audience/core/src/errors.ts +++ b/packages/audience/core/src/errors.ts @@ -46,7 +46,7 @@ export class TransportError extends Error { * `ok: true` means the backend accepted the payload (HTTP 2xx). On * `ok: false`, `error` is always populated with a structured reason. * - * Implementations of `HttpSend` MUST NOT reject — failures travel + * Implementations of `HttpSend` MUST NOT reject; failures travel * through this result type. Callers (notably `MessageQueue.flushUnload`) * rely on this contract for fire-and-forget paths. * @@ -64,16 +64,16 @@ export interface TransportResult { * Stable, machine-readable code identifying the kind of audience SDK * failure. Studios can branch on this in their `onError` handler. * - * - `'FLUSH_FAILED'` — POST to `/v1/audience/messages` returned non-2xx. - * - `'CONSENT_SYNC_FAILED'` — PUT to `/v1/audience/tracking-consent` returned non-2xx. - * - `'NETWORK_ERROR'` — fetch rejected before a response was received + * - `'FLUSH_FAILED'`:POST to `/v1/audience/messages` returned non-2xx. + * - `'CONSENT_SYNC_FAILED'`:PUT to `/v1/audience/tracking-consent` returned non-2xx. + * - `'NETWORK_ERROR'`:fetch rejected before a response was received * (network failure, CORS, DNS, etc.). - * - `'VALIDATION_REJECTED'` — backend returned 2xx but the body reported + * - `'VALIDATION_REJECTED'`:backend returned 2xx but the body reported * `rejected > 0`, or the server returned a 4xx (non-429) * indicating the payload was malformed or unauthorised. * Terminal: retrying won't help, the messages were dropped * from the queue. - * - `'RATE_LIMITED'` — server returned 429. The batch is retained and will + * - `'RATE_LIMITED'`:server returned 429. The batch is retained and will * be retried after the backoff window (honoring * `Retry-After` when present). */ @@ -90,7 +90,7 @@ export type AudienceErrorCode = * human-readable `message`. * * Lives in `@imtbl/audience-core` so every surface (web, pixel, unity, - * unreal) reports failures through the same shape — no per-package + * unreal) reports failures through the same shape, with no per-package * error class, no duplicated mapping logic. * * Is an instance of `Error` so it can be thrown, logged, or passed to @@ -135,7 +135,7 @@ export class AudienceError extends Error { * own copy of `status === 0 → NETWORK_ERROR` mapping logic. * * @param err The transport-level failure. - * @param source Which subsystem hit the error — selects the error code + * @param source Which subsystem hit the error; selects the error code * and shapes the human message. * @param count For `'flush'` failures, the number of messages in the * batch. Used in the human-readable message; ignored for @@ -146,7 +146,7 @@ export function toAudienceError( source: 'flush' | 'consent', count?: number, ): AudienceError { - // Network failure — no HTTP response received. + // Network failure: no HTTP response received. if (err.status === 0) { return new AudienceError({ code: 'NETWORK_ERROR', @@ -159,8 +159,8 @@ export function toAudienceError( }); } - // 2xx response with backend-rejected messages. Terminal, do not retry — - // the only way ok:false comes back with a 2xx status is when httpSend + // 2xx response with backend-rejected messages. Terminal, do not retry. + // The only way ok:false comes back with a 2xx status is when httpSend // detected `rejected > 0` in the parsed response body. if (err.status >= 200 && err.status < 300) { const body = err.body as { accepted?: number; rejected?: number } | undefined; @@ -175,7 +175,7 @@ export function toAudienceError( }); } - // 429 — rate limited, retryable. Batch is kept; queue applies backoff. + // 429: rate limited, retryable. Batch is kept; queue applies backoff. if (err.status === 429) { return new AudienceError({ code: 'RATE_LIMITED', @@ -188,7 +188,7 @@ export function toAudienceError( }); } - // 4xx (non-429) — server deterministically rejected the payload. Terminal: + // 4xx (non-429): server deterministically rejected the payload. Terminal: // a bad publishable key or malformed body won't be fixed by retrying. if (err.status >= 400 && err.status < 500) { return new AudienceError({ @@ -202,7 +202,7 @@ export function toAudienceError( }); } - // 5xx or other non-2xx/4xx — server unhealthy. Keep batch, apply backoff. + // 5xx or other non-2xx/4xx: server unhealthy. Keep batch, apply backoff. return new AudienceError({ code: source === 'flush' ? 'FLUSH_FAILED' : 'CONSENT_SYNC_FAILED', message: source === 'flush' @@ -219,13 +219,13 @@ export function toAudienceError( * Invoke a studio-supplied `onError` callback, swallowing any exception * it throws. * - * Used by {@link MessageQueue} and {@link createConsentManager} — both + * Used by {@link MessageQueue} and {@link createConsentManager}; both * must not wedge their internal state machines on a badly-written handler. * Centralised here to keep the swallow-and-continue semantics identical * across every audience surface and avoid duplicating the try/catch at * each call site. * - * Intentionally not re-exported from `index.ts` — this is an internal + * Intentionally not re-exported from `index.ts`; this is an internal * helper, not public API. */ export function invokeOnError( @@ -236,6 +236,6 @@ export function invokeOnError( try { onError(err); } catch { - // Swallow — handler must not crash the state machine. + // Swallow; handler must not crash the state machine. } } diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index de42a34042..b5d1cd0530 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -93,7 +93,7 @@ describe('MessageQueue', () => { expect(send).not.toHaveBeenCalled(); queue.enqueue(makeMessage('2')); - // flush is async — await the microtask + // flush is async, await the microtask await Promise.resolve(); expect(send).toHaveBeenCalledTimes(1); }); @@ -335,16 +335,16 @@ describe('exponential backoff', () => { queue.enqueue(makeMessage('1')); - // First flush — records failure, sets backoff to start+5000 + // First flush: records failure, sets backoff to start+5000 await queue.flush(); expect(send).toHaveBeenCalledTimes(1); - // Still inside backoff window — flush is a no-op + // Still inside backoff window: flush is a no-op jest.setSystemTime(start + 4_999); await queue.flush(); expect(send).toHaveBeenCalledTimes(1); - // Past backoff window — flush proceeds + // Past backoff window: flush proceeds jest.setSystemTime(start + 5_001); await queue.flush(); expect(send).toHaveBeenCalledTimes(2); @@ -391,11 +391,11 @@ describe('exponential backoff', () => { queue.enqueue(makeMessage('1')); queue.enqueue(makeMessage('2')); - // First flush fails — backoff starts + // First flush fails; backoff starts await queue.flush(); expect(send).toHaveBeenCalledTimes(1); - // Advance past the 5s window; second flush succeeds — backoff resets + // Advance past the 5s window; second flush succeeds, backoff resets jest.setSystemTime(start + 5_001); await queue.flush(); expect(send).toHaveBeenCalledTimes(2); @@ -424,7 +424,7 @@ describe('exponential backoff', () => { await queue.flush(); expect(send).toHaveBeenCalledTimes(1); - // 29s — still inside Retry-After window + // 29s: still inside Retry-After window jest.setSystemTime(start + 29_000); await queue.flush(); expect(send).toHaveBeenCalledTimes(1); @@ -591,7 +591,7 @@ describe('page-unload flush (keepalive)', () => { ); expect(queue.length).toBe(0); - // Listeners removed — no double flush + // Listeners removed - no double flush queue.enqueue(makeMessage('3')); window.dispatchEvent(new Event('pagehide')); expect(send).toHaveBeenCalledTimes(1); @@ -609,7 +609,7 @@ describe('page-unload flush (keepalive)', () => { // Start an async flush (sets flushing = true) const pending = queue.flush(); - // pagehide fires while async flush is in flight — unload flush should be skipped + // pagehide fires while async flush is in flight; unload flush should be skipped window.dispatchEvent(new Event('pagehide')); // Only 1 call (the async flush), no keepalive call expect(send).toHaveBeenCalledTimes(1); diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index 8cbc8aa896..398e3346a7 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -34,7 +34,7 @@ export interface MessageQueueOptions { /** * Override the localStorage key prefix (default: '__imtbl_audience_'). * Use when multiple SDK surfaces run on the same page to prevent - * queue collision — e.g. web SDK uses '__imtbl_web_' so its queued + * queue collision, e.g. web SDK uses '__imtbl_web_' so its queued * messages don't interfere with the shared SDK's queue. */ storagePrefix?: string; @@ -139,7 +139,7 @@ export class MessageQueue { * On success, sent messages are removed from the queue. On failure, * messages stay queued and retry on the next flush cycle. * Use this for normal operation. For page-unload scenarios, use - * flushUnload() instead — it's fire-and-forget and survives navigation. + * flushUnload() instead; it's fire-and-forget and survives navigation. */ async flush(): Promise { if (this.flushing || this.messages.length === 0) return; @@ -159,7 +159,7 @@ export class MessageQueue { // Drop the batch on success OR on a terminal validation failure. // VALIDATION_REJECTED means the backend deterministically rejected - // some messages — retrying won't help, so we drop them rather than + // some messages - retrying won't help, so we drop them rather than // accumulate stale data forever. const isTerminal = audienceErr?.code === 'VALIDATION_REJECTED'; if (result.ok || isTerminal) { @@ -189,7 +189,7 @@ export class MessageQueue { * * Uses `fetch` with `keepalive: true` so the request survives page * navigation. Unlike `flush()`, this is synchronous and does not wait - * for the response — use it only in `visibilitychange`/`pagehide` + * for the response; use it only in `visibilitychange`/`pagehide` * handlers or in `shutdown()`. */ flushUnload(): void { @@ -198,7 +198,7 @@ export class MessageQueue { const batch = this.messages.slice(0, MAX_BATCH_SIZE); const payload: BatchPayload = { messages: batch }; - // Fire-and-forget — `keepalive: true` lets the request survive page + // Fire-and-forget: `keepalive: true` lets the request survive page // navigation. We optimistically drop the batch because the page is // going away and can't retry. The HttpSend contract guarantees this // promise never rejects, so the floating call is safe. diff --git a/packages/audience/core/src/transport.test.ts b/packages/audience/core/src/transport.test.ts index 666d108674..c7ccc1ff63 100644 --- a/packages/audience/core/src/transport.test.ts +++ b/packages/audience/core/src/transport.test.ts @@ -167,7 +167,7 @@ describe('httpSend', () => { expect(result.error?.cause).toBe(networkError); }); - it('never rejects — even when fetch throws synchronously', async () => { + it('never rejects, even when fetch throws synchronously', async () => { global.fetch = jest.fn().mockImplementation(() => { throw new Error('synchronous boom'); }); diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index 3498feace4..09916bd010 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -14,7 +14,7 @@ export interface TransportOptions { * type into `MessageQueue` and `createConsentManager` so tests can * substitute a fake by passing `jest.fn()` directly. * - * Implementations MUST NOT reject — failures are returned via + * Implementations MUST NOT reject; failures are returned via * {@link TransportResult}. Callers rely on this contract for * fire-and-forget code paths (page-unload flush). */