From 17bae51ff228c59407acd8b1882c26b9ac400539 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 11:15:24 +1000 Subject: [PATCH 1/7] feat(audience-core): add AudienceError + unified onError callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the public error surface into core so every audience surface (web, pixel, unity, unreal) reports failures through the same shape without per-package error classes or duplicated mapping logic. - New AudienceError class in errors.ts: extends Error, carries a closed AudienceErrorCode union ('FLUSH_FAILED', 'CONSENT_SYNC_FAILED', 'NETWORK_ERROR'), plus status / endpoint / responseBody / cause for triage. Routable to Sentry / Datadog without an adapter. - New toAudienceError(err, source, count?) helper that maps a low-level TransportError into an AudienceError. Centralised so MessageQueue and ConsentManager don't carry duplicate copies of the status === 0 → NETWORK_ERROR mapping. - MessageQueue accepts onError?: (err: AudienceError) => void in its options. Fires after onFlush on a failed flush, with errors mapped via toAudienceError(_, 'flush', batch.length). onFlush is unchanged and stays focused on debug/metrics observability. - createConsentManager accepts onError?: (err: AudienceError) => void as a new optional last parameter. Wires a .then() onto the consent PUT so synchronous fire-and-forget behaviour is preserved while failures are still observable. Mapped via toAudienceError(_, 'consent'). - Both call sites swallow exceptions thrown from the onError callback — the queue and consent state machine must not wedge on a bad handler. Tests: errors.test.ts covers AudienceError construction and toAudienceError mapping for all source/error combinations. queue.test.ts adds four onError tests (mapped FLUSH_FAILED, mapped NETWORK_ERROR with batch count, no-fire on success, swallows callback exceptions). consent.test.ts adds the same four tests plus the synchronous-throw guarantee. 104 core tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/consent.test.ts | 83 ++++++++++++++++- packages/audience/core/src/consent.ts | 20 ++++- packages/audience/core/src/errors.test.ts | 98 ++++++++++++++++++++ packages/audience/core/src/errors.ts | 100 +++++++++++++++++++++ packages/audience/core/src/index.ts | 7 +- packages/audience/core/src/queue.test.ts | 72 ++++++++++++++- packages/audience/core/src/queue.ts | 23 +++++ 7 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 packages/audience/core/src/errors.test.ts diff --git a/packages/audience/core/src/consent.test.ts b/packages/audience/core/src/consent.test.ts index 4a235ee5fe..016da6c97c 100644 --- a/packages/audience/core/src/consent.test.ts +++ b/packages/audience/core/src/consent.test.ts @@ -1,7 +1,7 @@ import { createConsentManager } from './consent'; import type { HttpSend } from './transport'; -function createMockSend(): jest.MockedFunction { +function createMockSend() { return jest.fn, Parameters>().mockResolvedValue({ ok: true }); } @@ -117,4 +117,85 @@ describe('createConsentManager', () => { Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true }); }); + + describe('onError callback', () => { + it('fires onError with mapped CONSENT_SYNC_FAILED on consent PUT failure', async () => { + const queue = createMockQueue(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: { + status: 503, + endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent', + body: { code: 'SERVICE_UNAVAILABLE' }, + }, + }); + const onError = jest.fn(); + const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); + + manager.setLevel('anonymous'); + + // notifyBackend's .then() runs on the microtask queue. + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledTimes(1); + const err = onError.mock.calls[0][0]; + expect(err.code).toBe('CONSENT_SYNC_FAILED'); + expect(err.status).toBe(503); + expect(err.message).toBe('Consent sync failed with status 503'); + }); + + it('fires onError with NETWORK_ERROR on network failure', async () => { + const queue = createMockQueue(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: { + status: 0, + endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent', + cause: new TypeError('Failed to fetch'), + }, + }); + const onError = jest.fn(); + const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); + + manager.setLevel('anonymous'); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).toHaveBeenCalledTimes(1); + const err = onError.mock.calls[0][0]; + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toBe('Network error syncing consent'); + }); + + it('does not fire onError on successful consent sync', async () => { + const queue = createMockQueue(); + const send = createMockSend(); + const onError = jest.fn(); + const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); + + manager.setLevel('anonymous'); + await Promise.resolve(); + await Promise.resolve(); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('swallows exceptions thrown from the onError callback', async () => { + const queue = createMockQueue(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: { status: 500, endpoint: 'x', body: null }, + }); + const onError = jest.fn().mockImplementation(() => { throw new Error('callback boom'); }); + const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); + + // Synchronous call must not throw even though the .then() handler will. + expect(() => manager.setLevel('anonymous')).not.toThrow(); + + await Promise.resolve(); + await Promise.resolve(); + expect(onError).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/audience/core/src/consent.ts b/packages/audience/core/src/consent.ts index 561b2711f8..9e7a7a46ee 100644 --- a/packages/audience/core/src/consent.ts +++ b/packages/audience/core/src/consent.ts @@ -3,6 +3,7 @@ import type { } from './types'; import type { MessageQueue } from './queue'; import type { HttpSend } from './transport'; +import { type AudienceError, toAudienceError } from './errors'; import { CONSENT_PATH, getBaseUrl } from './config'; export interface ConsentManager { @@ -29,6 +30,10 @@ export function detectDoNotTrack(): boolean { * - Fires PUT to `/v1/audience/tracking-consent` on every state change via * the injected `send`. Sharing the same `HttpSend` instance with the queue * keeps the transport layer uniform — no module-level mocking required. + * - On consent sync failure, fires `onError` with a public {@link AudienceError} + * mapped via {@link toAudienceError}, so callers don't have to repeat the + * `status === 0 → NETWORK_ERROR` mapping themselves. Exceptions thrown + * from the callback are swallowed. */ export function createConsentManager( queue: MessageQueue, @@ -38,6 +43,7 @@ export function createConsentManager( environment: Environment, source: string, initialLevel?: ConsentLevel, + onError?: (err: AudienceError) => void, ): ConsentManager { const dntDetected = detectDoNotTrack(); let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none'); @@ -47,8 +53,18 @@ export function createConsentManager( function notifyBackend(level: ConsentLevel): void { const url = `${getBaseUrl(environment)}${CONSENT_PATH}`; const payload: ConsentUpdatePayload = { anonymousId, status: level, source }; - // Fire-and-forget. HttpSend never rejects, so a floating promise is safe. - send(url, publishableKey, payload, { method: 'PUT', keepalive: true }); + // Fire-and-forget. HttpSend never rejects, so the floating chain is safe. + send(url, publishableKey, payload, { method: 'PUT', keepalive: true }) + .then((result) => { + if (!result.ok && result.error && onError) { + try { + onError(toAudienceError(result.error, 'consent')); + } catch { + // Swallow callback errors — the consent state machine must not + // wedge on a throwing handler. + } + } + }); } const manager: ConsentManager = { diff --git a/packages/audience/core/src/errors.test.ts b/packages/audience/core/src/errors.test.ts new file mode 100644 index 0000000000..b3904ac989 --- /dev/null +++ b/packages/audience/core/src/errors.test.ts @@ -0,0 +1,98 @@ +import { + AudienceError, toAudienceError, type TransportError, +} from './errors'; + +describe('AudienceError', () => { + it('is an instance of Error', () => { + const err = new AudienceError({ + code: 'FLUSH_FAILED', + message: 'flush failed', + status: 500, + endpoint: 'https://example.com', + }); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(AudienceError); + expect(err.name).toBe('AudienceError'); + }); + + it('exposes structured fields from init', () => { + const cause = new TypeError('boom'); + const err = new AudienceError({ + code: 'NETWORK_ERROR', + message: 'network down', + status: 0, + endpoint: 'https://example.com', + responseBody: { detail: 'x' }, + cause, + }); + + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toBe('network down'); + expect(err.status).toBe(0); + expect(err.endpoint).toBe('https://example.com'); + expect(err.responseBody).toEqual({ detail: 'x' }); + expect(err.cause).toBe(cause); + }); +}); + +describe('toAudienceError', () => { + const httpError: TransportError = { + status: 500, + endpoint: 'https://api.dev.immutable.com/v1/audience/messages', + body: { code: 'INTERNAL_ERROR' }, + }; + + const networkError: TransportError = { + status: 0, + endpoint: 'https://api.dev.immutable.com/v1/audience/messages', + cause: new TypeError('Failed to fetch'), + }; + + describe('flush source', () => { + it('maps HTTP error to FLUSH_FAILED with status in message', () => { + const err = toAudienceError(httpError, 'flush', 5); + + expect(err.code).toBe('FLUSH_FAILED'); + expect(err.message).toBe('Flush failed with status 500'); + expect(err.status).toBe(500); + expect(err.endpoint).toBe(httpError.endpoint); + expect(err.responseBody).toEqual({ code: 'INTERNAL_ERROR' }); + }); + + it('maps network error to NETWORK_ERROR with batch count in message', () => { + const err = toAudienceError(networkError, 'flush', 5); + + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toBe('Network error sending 5 messages'); + expect(err.status).toBe(0); + expect(err.cause).toBe(networkError.cause); + }); + + it('falls back to count 0 in network message when count is undefined', () => { + const err = toAudienceError(networkError, 'flush'); + expect(err.message).toBe('Network error sending 0 messages'); + }); + }); + + describe('consent source', () => { + it('maps HTTP error to CONSENT_SYNC_FAILED with status in message', () => { + const err = toAudienceError( + { ...httpError, endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent' }, + 'consent', + ); + + expect(err.code).toBe('CONSENT_SYNC_FAILED'); + expect(err.message).toBe('Consent sync failed with status 500'); + }); + + it('maps network error to NETWORK_ERROR with consent-specific message', () => { + const err = toAudienceError(networkError, 'consent'); + + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toBe('Network error syncing consent'); + expect(err.status).toBe(0); + expect(err.cause).toBe(networkError.cause); + }); + }); +}); diff --git a/packages/audience/core/src/errors.ts b/packages/audience/core/src/errors.ts index 11ad1d9d47..60faa3b1c5 100644 --- a/packages/audience/core/src/errors.ts +++ b/packages/audience/core/src/errors.ts @@ -54,3 +54,103 @@ export interface TransportResult { ok: boolean; error?: TransportError; } + +/** + * 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 + * (network failure, CORS, DNS, etc.). + */ +export type AudienceErrorCode = + | 'FLUSH_FAILED' + | 'CONSENT_SYNC_FAILED' + | 'NETWORK_ERROR'; + +/** + * Public error type passed to the SDK's `onError` callback. Wraps the + * low-level {@link TransportError} and adds a closed `code` union plus a + * human-readable `message`. + * + * Lives in `@imtbl/audience-core` so every surface (web, pixel, unity, + * unreal) reports failures through the same shape — no per-package + * error class, no duplicated mapping logic. + * + * Is an instance of `Error` so it can be thrown, logged, or passed to + * Sentry / Datadog without an adapter. + */ +export class AudienceError extends Error { + readonly code: AudienceErrorCode; + + readonly status: number; + + readonly endpoint: string; + + readonly responseBody?: unknown; + + // `cause` is a standard Error prop in ES2022, declared here for older + // TS targets that don't have it in their lib.d.ts. + readonly cause?: unknown; + + constructor(init: { + code: AudienceErrorCode; + message: string; + status: number; + endpoint: string; + responseBody?: unknown; + cause?: unknown; + }) { + super(init.message); + this.name = 'AudienceError'; + this.code = init.code; + this.status = init.status; + this.endpoint = init.endpoint; + this.responseBody = init.responseBody; + this.cause = init.cause; + } +} + +/** + * Convert a low-level {@link TransportError} into a public + * {@link AudienceError} for delivery to studio code. + * + * Centralised so MessageQueue and ConsentManager don't each carry their + * 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 + * 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 + * consent failures. + */ +export function toAudienceError( + err: TransportError, + source: 'flush' | 'consent', + count?: number, +): AudienceError { + if (err.status === 0) { + return new AudienceError({ + code: 'NETWORK_ERROR', + message: source === 'flush' + ? `Network error sending ${count ?? 0} messages` + : 'Network error syncing consent', + status: 0, + endpoint: err.endpoint, + cause: err.cause, + }); + } + + return new AudienceError({ + code: source === 'flush' ? 'FLUSH_FAILED' : 'CONSENT_SYNC_FAILED', + message: source === 'flush' + ? `Flush failed with status ${err.status}` + : `Consent sync failed with status ${err.status}`, + status: err.status, + endpoint: err.endpoint, + responseBody: err.body, + cause: err.cause, + }); +} diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 08bb4bf005..ea64d96d57 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -43,7 +43,12 @@ export { generateId, getTimestamp, isBrowser } from './utils'; export type { HttpSend, TransportOptions } from './transport'; export { httpSend } from './transport'; -export type { TransportError, TransportResult } from './errors'; +export type { + TransportError, + TransportResult, + AudienceErrorCode, +} from './errors'; +export { AudienceError, toAudienceError } from './errors'; export { MessageQueue } from './queue'; export { collectContext } from './context'; export { diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index d1811a2f61..9f4f792319 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -1,6 +1,6 @@ import { MessageQueue } from './queue'; import type { HttpSend } from './transport'; -import { TransportError, type TransportResult } from './errors'; +import type { AudienceError, TransportResult } from './errors'; import type { Message } from './types'; import * as storage from './storage'; @@ -30,6 +30,7 @@ interface QueueOpts { flushIntervalMs?: number; flushSize?: number; onFlush?: (ok: boolean, count: number) => void; + onError?: (err: AudienceError) => void; staleFilter?: (msg: Message) => boolean; } @@ -43,7 +44,11 @@ function createQueue( 'pk_imx_test', opts.flushIntervalMs ?? 5_000, opts.flushSize ?? 20, - { onFlush: opts.onFlush, staleFilter: opts.staleFilter }, + { + onFlush: opts.onFlush, + onError: opts.onError, + staleFilter: opts.staleFilter, + }, ); } @@ -197,6 +202,69 @@ describe('MessageQueue', () => { expect(onFlush).toHaveBeenCalledWith(true, 1); }); + it('fires onError with mapped AudienceError on flush failure', async () => { + const onError = jest.fn(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: { status: 500, endpoint: 'https://api.immutable.com/v1/audience/messages', body: null }, + }); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + queue.enqueue(makeMessage('2')); + await queue.flush(); + + expect(onError).toHaveBeenCalledTimes(1); + const err = onError.mock.calls[0][0]; + expect(err.code).toBe('FLUSH_FAILED'); + expect(err.status).toBe(500); + expect(err.message).toBe('Flush failed with status 500'); + }); + + it('fires onError with NETWORK_ERROR on network failure', async () => { + const onError = jest.fn(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: { + status: 0, + endpoint: 'https://api.immutable.com/v1/audience/messages', + cause: new TypeError('Failed to fetch'), + }, + }); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + queue.enqueue(makeMessage('2')); + queue.enqueue(makeMessage('3')); + await queue.flush(); + + expect(onError).toHaveBeenCalledTimes(1); + const err = onError.mock.calls[0][0]; + expect(err.code).toBe('NETWORK_ERROR'); + expect(err.message).toBe('Network error sending 3 messages'); + }); + + it('does not fire onError on successful flush', async () => { + const onError = jest.fn(); + const send = jest.fn, Parameters>().mockResolvedValue(okResult); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + await queue.flush(); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('swallows exceptions thrown from the onError callback', async () => { + const onError = jest.fn().mockImplementation(() => { throw new Error('callback boom'); }); + const send = jest.fn, Parameters>().mockResolvedValue(failResult); + const queue = createQueue(send, { onError }); + + queue.enqueue(makeMessage('1')); + await expect(queue.flush()).resolves.toBeUndefined(); + expect(onError).toHaveBeenCalled(); + }); + it('purges messages matching a predicate', () => { const send = jest.fn, Parameters>().mockResolvedValue(okResult); const queue = createQueue(send); diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index e2e97602bd..a174fc7769 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -1,5 +1,6 @@ import type { Message, BatchPayload } from './types'; import type { HttpSend } from './transport'; +import { type AudienceError, toAudienceError } from './errors'; import * as storage from './storage'; import { isBrowser } from './utils'; @@ -7,7 +8,19 @@ const STORAGE_KEY = 'queue'; const MAX_BATCH_SIZE = 100; // Backend maxItems limit per OAS export interface MessageQueueOptions { + /** + * Fired after every flush, success or failure. Used for debug + * logging / metrics. Errors are reported separately via `onError`. + */ onFlush?: (ok: boolean, count: number) => void; + /** + * Fired when a flush fails. The error has been mapped from the raw + * transport-level failure into a public {@link AudienceError} via + * {@link toAudienceError}, so the same shape comes out of every + * audience surface (web, pixel, ...). Exceptions thrown from the + * callback are swallowed so the queue can't wedge on a bad handler. + */ + onError?: (err: AudienceError) => void; staleFilter?: (msg: Message) => boolean; /** * Override the localStorage key prefix (default: '__imtbl_audience_'). @@ -50,6 +63,8 @@ export class MessageQueue { private readonly onFlush?: (ok: boolean, count: number) => void; + private readonly onError?: (err: AudienceError) => void; + private readonly storagePrefix?: string; constructor( @@ -61,6 +76,7 @@ export class MessageQueue { options?: MessageQueueOptions, ) { this.onFlush = options?.onFlush; + this.onError = options?.onError; this.storagePrefix = options?.storagePrefix; const restored = (storage.getItem(STORAGE_KEY, this.storagePrefix) as Message[] | undefined) ?? []; @@ -119,6 +135,13 @@ export class MessageQueue { this.persist(); } this.onFlush?.(result.ok, batch.length); + if (!result.ok && result.error && this.onError) { + try { + this.onError(toAudienceError(result.error, 'flush', batch.length)); + } catch { + // Swallow callback errors — the queue must not wedge on a throwing handler. + } + } } finally { this.flushing = false; } From d6d9a683eefb91f5a35cd61054ff4a8225b3772d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 11:18:37 +1000 Subject: [PATCH 2/7] fix(audience-core): surface backend-rejected batches as VALIDATION_REJECTED instead of silent drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a silent data-loss bug uncovered while wiring the new error surface. When the backend returned 200 with body { accepted: N, rejected: M } the queue would clear the entire batch on result.ok and never observe that M messages were dropped — no callback fired, no metric, no log. The fix has three parts working together: 1. httpSend now parses the 2xx response body. If it reports rejected > 0, httpSend returns ok:false with status:200 and body containing the parsed { accepted, rejected } counts. Also fires a new `transport_partial_rejected` metric for internal observability. 2. toAudienceError adds a 2xx-with-rejection branch that maps to the new AudienceErrorCode 'VALIDATION_REJECTED', with a human-readable message ("Backend rejected M of N messages") and the parsed body preserved as responseBody. 3. MessageQueue.flush now distinguishes terminal failures from retryable ones. On VALIDATION_REJECTED the batch is dropped (the backend deterministically rejected those messages — retrying won't help) AND onError fires. Generic FLUSH_FAILED / NETWORK_ERROR still retain messages for retry on the next flush cycle, as before. TDD: started with the failing queue test that captures the user-facing contract (partial-success drops the batch + fires onError). Saw it fail (queue retained both messages). Implemented the fix across errors.ts + queue.ts + transport.ts. Added supplemental tests for the httpSend detection path and the toAudienceError mapping branch. Tests: 109 core, 47 sdk, 64 pixel. All pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/errors.test.ts | 30 +++++++++++++++++ packages/audience/core/src/errors.ts | 26 ++++++++++++++- packages/audience/core/src/queue.test.ts | 30 +++++++++++++++++ packages/audience/core/src/queue.ts | 18 +++++++++-- packages/audience/core/src/transport.test.ts | 34 +++++++++++++++++++- packages/audience/core/src/transport.ts | 23 +++++++++++++ 6 files changed, 156 insertions(+), 5 deletions(-) diff --git a/packages/audience/core/src/errors.test.ts b/packages/audience/core/src/errors.test.ts index b3904ac989..e26ba6407c 100644 --- a/packages/audience/core/src/errors.test.ts +++ b/packages/audience/core/src/errors.test.ts @@ -95,4 +95,34 @@ describe('toAudienceError', () => { expect(err.cause).toBe(networkError.cause); }); }); + + describe('partial-rejection (2xx with rejected > 0)', () => { + it('maps to VALIDATION_REJECTED with backend body preserved', () => { + const partialError: TransportError = { + status: 200, + endpoint: 'https://api.dev.immutable.com/v1/audience/messages', + body: { accepted: 50, rejected: 50 }, + }; + + const err = toAudienceError(partialError, 'flush', 100); + + expect(err.code).toBe('VALIDATION_REJECTED'); + expect(err.status).toBe(200); + expect(err.message).toBe('Backend rejected 50 of 100 messages'); + expect(err.responseBody).toEqual({ accepted: 50, rejected: 50 }); + }); + + it('handles missing accepted/rejected fields gracefully', () => { + const partialError: TransportError = { + status: 200, + endpoint: 'https://api.dev.immutable.com/v1/audience/messages', + body: {}, + }; + + const err = toAudienceError(partialError, 'flush'); + + expect(err.code).toBe('VALIDATION_REJECTED'); + expect(err.message).toBe('Backend rejected 0 of 0 messages'); + }); + }); }); diff --git a/packages/audience/core/src/errors.ts b/packages/audience/core/src/errors.ts index 60faa3b1c5..2e3c35b800 100644 --- a/packages/audience/core/src/errors.ts +++ b/packages/audience/core/src/errors.ts @@ -63,11 +63,17 @@ export interface TransportResult { * - `'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 + * `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. */ export type AudienceErrorCode = | 'FLUSH_FAILED' | 'CONSENT_SYNC_FAILED' - | 'NETWORK_ERROR'; + | 'NETWORK_ERROR' + | 'VALIDATION_REJECTED'; /** * Public error type passed to the SDK's `onError` callback. Wraps the @@ -131,6 +137,7 @@ export function toAudienceError( source: 'flush' | 'consent', count?: number, ): AudienceError { + // Network failure — no HTTP response received. if (err.status === 0) { return new AudienceError({ code: 'NETWORK_ERROR', @@ -143,6 +150,23 @@ 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 + // 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; + const rejected = body?.rejected ?? 0; + const accepted = body?.accepted ?? 0; + return new AudienceError({ + code: 'VALIDATION_REJECTED', + message: `Backend rejected ${rejected} of ${rejected + accepted} messages`, + status: err.status, + endpoint: err.endpoint, + responseBody: err.body, + }); + } + + // Generic HTTP failure (4xx / 5xx). 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 9f4f792319..c9ec9e45cb 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -265,6 +265,36 @@ describe('MessageQueue', () => { expect(onError).toHaveBeenCalled(); }); + it('drops batch and fires VALIDATION_REJECTED when backend reports partial rejection', async () => { + // Backend rejected one message in a batch of two. The 200 OK response + // body says { accepted: 1, rejected: 1 }. Expected behaviour: + // - Queue clears the batch (retrying validation failures won't help). + // - onError fires with code 'VALIDATION_REJECTED' so studios are aware. + // - Bug fix: previously the queue checked only result.ok and dropped + // the entire batch silently, losing rejected messages with no signal. + const onError = jest.fn(); + const send = jest.fn, Parameters>().mockResolvedValue({ + ok: false, + error: { + status: 200, + endpoint: 'https://api.immutable.com/v1/audience/messages', + body: { accepted: 1, rejected: 1 }, + }, + }); + 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(200); + expect(err.responseBody).toEqual({ accepted: 1, rejected: 1 }); + }); + it('purges messages matching a predicate', () => { const send = jest.fn, Parameters>().mockResolvedValue(okResult); const queue = createQueue(send); diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index a174fc7769..e83bfd3ae6 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -130,14 +130,26 @@ export class MessageQueue { const payload: BatchPayload = { messages: batch }; const result = await this.send(this.endpointUrl, this.publishableKey, payload); - if (result.ok) { + + let audienceErr: AudienceError | undefined; + if (!result.ok && result.error) { + audienceErr = toAudienceError(result.error, 'flush', batch.length); + } + + // 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 + // accumulate stale data forever. + const isTerminal = audienceErr?.code === 'VALIDATION_REJECTED'; + if (result.ok || isTerminal) { this.messages = this.messages.slice(batch.length); this.persist(); } + this.onFlush?.(result.ok, batch.length); - if (!result.ok && result.error && this.onError) { + if (audienceErr && this.onError) { try { - this.onError(toAudienceError(result.error, 'flush', batch.length)); + this.onError(audienceErr); } catch { // Swallow callback errors — the queue must not wedge on a throwing handler. } diff --git a/packages/audience/core/src/transport.test.ts b/packages/audience/core/src/transport.test.ts index a446a69cbd..aa945dc56f 100644 --- a/packages/audience/core/src/transport.test.ts +++ b/packages/audience/core/src/transport.test.ts @@ -66,13 +66,45 @@ describe('httpSend', () => { })); }); - it('returns ok on 2xx response', async () => { + it('returns ok on 2xx response with no body', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true }); const result = await httpSend('https://example.com', 'pk', payload); expect(result.ok).toBe(true); expect(result.error).toBeUndefined(); }); + it('returns ok on 2xx response when body reports zero rejected', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, accepted: 1, rejected: 0 }), + }); + + const result = await httpSend('https://example.com', 'pk', payload); + + expect(result.ok).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('returns ok:false with status 200 when backend reports partial rejection', async () => { + // The silent-drop bug: backend returns 200 with { accepted: 1, rejected: 1 } + // and the queue used to clear the entire batch without surfacing the + // rejection. After this fix httpSend treats it as a structured failure. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ success: true, accepted: 1, rejected: 1 }), + }); + + const result = await httpSend('https://example.com', 'pk', payload); + + expect(result.ok).toBe(false); + expect(result.error?.status).toBe(200); + expect(result.error?.body).toEqual({ success: true, accepted: 1, rejected: 1 }); + }); + it('returns structured error on HTTP failure with parsed JSON body', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: false, diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index 6b3ddea7a1..6ff19877fa 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -67,6 +67,29 @@ export const httpSend: HttpSend = async ( }; } + // Successful HTTP, but the backend MessagesResponse may report + // per-message validation failures via { accepted, rejected }. Treat + // any rejection as a non-retryable failure so the queue surfaces it + // through onError instead of silently dropping the rejected items. + const body = await parseBody(response); + const bodyObj = (typeof body === 'object' && body !== null) + ? body as { accepted?: number; rejected?: number } + : undefined; + if ((bodyObj?.rejected ?? 0) > 0) { + track('audience', 'transport_partial_rejected', { + status: response.status, + rejected: bodyObj?.rejected ?? 0, + }); + return { + ok: false, + error: { + status: response.status, + endpoint: url, + body: bodyObj, + }, + }; + } + return { ok: true }; } catch (err) { const error = new TransportError({ From 40b4c638e7beb7a820f5fce8f29bc0d44befff00 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 14:19:42 +1000 Subject: [PATCH 3/7] fix: align with TransportError class from squash-merged SDK-84 The squash merge of PR #2839 changed TransportError from an interface to a class extending Error. Update test fixtures and the partial-success detection in httpSend to use `new TransportError(...)` instead of plain object literals. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/consent.test.ts | 11 ++++++----- packages/audience/core/src/errors.test.ts | 18 +++++++++--------- packages/audience/core/src/queue.test.ts | 14 ++++++++------ packages/audience/core/src/transport.ts | 4 ++-- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/audience/core/src/consent.test.ts b/packages/audience/core/src/consent.test.ts index 016da6c97c..dc770a7e58 100644 --- a/packages/audience/core/src/consent.test.ts +++ b/packages/audience/core/src/consent.test.ts @@ -1,5 +1,6 @@ import { createConsentManager } from './consent'; import type { HttpSend } from './transport'; +import { TransportError } from './errors'; function createMockSend() { return jest.fn, Parameters>().mockResolvedValue({ ok: true }); @@ -123,11 +124,11 @@ describe('createConsentManager', () => { const queue = createMockQueue(); const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, - error: { + error: new TransportError({ status: 503, endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent', body: { code: 'SERVICE_UNAVAILABLE' }, - }, + }), }); const onError = jest.fn(); const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); @@ -149,11 +150,11 @@ describe('createConsentManager', () => { const queue = createMockQueue(); const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, - error: { + error: new TransportError({ status: 0, endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent', cause: new TypeError('Failed to fetch'), - }, + }), }); const onError = jest.fn(); const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); @@ -185,7 +186,7 @@ describe('createConsentManager', () => { const queue = createMockQueue(); const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, - error: { status: 500, endpoint: 'x', body: null }, + error: new TransportError({ status: 500, endpoint: 'x', body: null }), }); const onError = jest.fn().mockImplementation(() => { throw new Error('callback boom'); }); const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError); diff --git a/packages/audience/core/src/errors.test.ts b/packages/audience/core/src/errors.test.ts index e26ba6407c..dd1e4216c5 100644 --- a/packages/audience/core/src/errors.test.ts +++ b/packages/audience/core/src/errors.test.ts @@ -1,5 +1,5 @@ import { - AudienceError, toAudienceError, type TransportError, + AudienceError, TransportError, toAudienceError, } from './errors'; describe('AudienceError', () => { @@ -37,17 +37,17 @@ describe('AudienceError', () => { }); describe('toAudienceError', () => { - const httpError: TransportError = { + const httpError = new TransportError({ status: 500, endpoint: 'https://api.dev.immutable.com/v1/audience/messages', body: { code: 'INTERNAL_ERROR' }, - }; + }); - const networkError: TransportError = { + const networkError = new TransportError({ status: 0, endpoint: 'https://api.dev.immutable.com/v1/audience/messages', cause: new TypeError('Failed to fetch'), - }; + }); describe('flush source', () => { it('maps HTTP error to FLUSH_FAILED with status in message', () => { @@ -98,11 +98,11 @@ describe('toAudienceError', () => { describe('partial-rejection (2xx with rejected > 0)', () => { it('maps to VALIDATION_REJECTED with backend body preserved', () => { - const partialError: TransportError = { + const partialError = new TransportError({ status: 200, endpoint: 'https://api.dev.immutable.com/v1/audience/messages', body: { accepted: 50, rejected: 50 }, - }; + }); const err = toAudienceError(partialError, 'flush', 100); @@ -113,11 +113,11 @@ describe('toAudienceError', () => { }); it('handles missing accepted/rejected fields gracefully', () => { - const partialError: TransportError = { + const partialError = new TransportError({ status: 200, endpoint: 'https://api.dev.immutable.com/v1/audience/messages', body: {}, - }; + }); const err = toAudienceError(partialError, 'flush'); diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index c9ec9e45cb..12283a673a 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -1,6 +1,6 @@ import { MessageQueue } from './queue'; import type { HttpSend } from './transport'; -import type { AudienceError, TransportResult } from './errors'; +import { TransportError, type AudienceError, type TransportResult } from './errors'; import type { Message } from './types'; import * as storage from './storage'; @@ -206,7 +206,9 @@ describe('MessageQueue', () => { const onError = jest.fn(); const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, - error: { status: 500, endpoint: 'https://api.immutable.com/v1/audience/messages', body: null }, + error: new TransportError({ + status: 500, endpoint: 'https://api.immutable.com/v1/audience/messages', body: null, + }), }); const queue = createQueue(send, { onError }); @@ -225,11 +227,11 @@ describe('MessageQueue', () => { const onError = jest.fn(); const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, - error: { + error: new TransportError({ status: 0, endpoint: 'https://api.immutable.com/v1/audience/messages', cause: new TypeError('Failed to fetch'), - }, + }), }); const queue = createQueue(send, { onError }); @@ -275,11 +277,11 @@ describe('MessageQueue', () => { const onError = jest.fn(); const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, - error: { + error: new TransportError({ status: 200, endpoint: 'https://api.immutable.com/v1/audience/messages', body: { accepted: 1, rejected: 1 }, - }, + }), }); const queue = createQueue(send, { onError }); diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index 6ff19877fa..0a0cd5b26a 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -82,11 +82,11 @@ export const httpSend: HttpSend = async ( }); return { ok: false, - error: { + error: new TransportError({ status: response.status, endpoint: url, body: bodyObj, - }, + }), }; } From 8d1d35fdeabec990108770faa2ac6795d545260d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 14:37:11 +1000 Subject: [PATCH 4/7] chore(audience-core): remove 9 dead exports from public surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip exports that no consumer outside core ever imports: - getAnonymousId (internal to cookie.ts) - setCookie (internal to cookie.ts) - storage namespace (internal to queue.ts) - SESSION_MAX_AGE (internal to session.ts) - getOrCreateSessionId (consumers use getOrCreateSession instead) - getSessionId (same) - truncateSource (internal to validation.ts) - clearAttribution (never imported externally) - ConsentUpdatePayload (internal consent wire type) Shrinks the public API surface of @imtbl/audience-core. No consumer code changes needed — nothing imported these. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/index.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index ea64d96d57..048dc2f3ef 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -13,18 +13,14 @@ export type { BatchPayload, ConsentLevel, ConsentStatus, - ConsentUpdatePayload, } from './types'; export { IdentityType } from './types'; export { getOrCreateAnonymousId, - getAnonymousId, getCookie, - setCookie, deleteCookie, } from './cookie'; -export * as storage from './storage'; export { getBaseUrl, @@ -34,7 +30,6 @@ export { FLUSH_SIZE, COOKIE_NAME, SESSION_COOKIE, - SESSION_MAX_AGE, SESSION_START, SESSION_END, } from './config'; @@ -43,25 +38,16 @@ export { generateId, getTimestamp, isBrowser } from './utils'; export type { HttpSend, TransportOptions } from './transport'; export { httpSend } from './transport'; -export type { - TransportError, - TransportResult, - AudienceErrorCode, -} from './errors'; -export { AudienceError, toAudienceError } from './errors'; +export type { TransportResult, AudienceErrorCode } from './errors'; +export { TransportError, AudienceError, toAudienceError } from './errors'; export { MessageQueue } from './queue'; export { collectContext } from './context'; -export { - isTimestampValid, - isAliasValid, - truncate, - truncateSource, -} from './validation'; +export { isTimestampValid, isAliasValid, truncate } from './validation'; -export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; +export { getOrCreateSession } from './session'; export type { SessionResult } from './session'; -export { collectAttribution, clearAttribution } from './attribution'; +export { collectAttribution } from './attribution'; export type { Attribution } from './attribution'; export { createConsentManager, detectDoNotTrack } from './consent'; From 5c83b64a2740e9ab5018741810617b72ca84c39a Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 16:55:35 +1000 Subject: [PATCH 5/7] fix(audience-core): guard partial-rejection cast against arrays with 'rejected' in check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous `typeof body === 'object' && body !== null` gate let arrays slip through — `typeof [] === 'object'` passes, and `[].rejected` silently returns undefined, so a malformed backend response shaped as an array would bypass the VALIDATION_REJECTED path entirely. Add a `'rejected' in body` guard before the cast to rule out arrays, primitives, and null. Pass the raw parsed body to TransportError (runtime behavior unchanged — the old cast was type-only, the object reference was identical). Addresses review comment r3055686412. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/transport.ts | 39 +++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/audience/core/src/transport.ts b/packages/audience/core/src/transport.ts index 0a0cd5b26a..8d92cc20fa 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -71,23 +71,32 @@ export const httpSend: HttpSend = async ( // per-message validation failures via { accepted, rejected }. Treat // any rejection as a non-retryable failure so the queue surfaces it // through onError instead of silently dropping the rejected items. + // + // The `'rejected' in body` check is load-bearing: `typeof [] === 'object'` + // so a bare `typeof === 'object' && !== null` cast would let arrays + // through and silently return `undefined` for `.rejected`. The `in` + // guard rules out arrays, primitives, and null before we cast. const body = await parseBody(response); - const bodyObj = (typeof body === 'object' && body !== null) - ? body as { accepted?: number; rejected?: number } - : undefined; - if ((bodyObj?.rejected ?? 0) > 0) { - track('audience', 'transport_partial_rejected', { - status: response.status, - rejected: bodyObj?.rejected ?? 0, - }); - return { - ok: false, - error: new TransportError({ + if ( + typeof body === 'object' + && body !== null + && 'rejected' in body + ) { + const rejected = (body as { rejected?: number }).rejected ?? 0; + if (rejected > 0) { + track('audience', 'transport_partial_rejected', { status: response.status, - endpoint: url, - body: bodyObj, - }), - }; + rejected, + }); + return { + ok: false, + error: new TransportError({ + status: response.status, + endpoint: url, + body, + }), + }; + } } return { ok: true }; From 77ce0aee2ce761ade5c2ec6f5b178ff5f717f5aa Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 16:56:28 +1000 Subject: [PATCH 6/7] refactor(audience-core): extract invokeOnError helper to dedupe queue/consent callback safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MessageQueue.flush and createConsentManager each carried an identical if (onError) { try { onError(err); } catch {} } block to guard against studio-supplied handlers that throw. Lifting this into a shared invokeOnError helper in errors.ts: - removes ~10 lines of duplicated try/catch scaffolding - centralises the "swallow — state machine must not wedge" contract in one place so future surfaces (unity, unreal) get it for free - keeps the helper internal — not re-exported from index.ts, since studios never invoke it directly Existing "swallows exceptions thrown from the onError callback" tests in queue.test.ts and consent.test.ts continue to pass, confirming the behavior is unchanged. Addresses review comment r3055675964. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/consent.ts | 11 +++-------- packages/audience/core/src/errors.ts | 25 +++++++++++++++++++++++++ packages/audience/core/src/queue.ts | 10 +++------- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/audience/core/src/consent.ts b/packages/audience/core/src/consent.ts index 9e7a7a46ee..7dc58b2f66 100644 --- a/packages/audience/core/src/consent.ts +++ b/packages/audience/core/src/consent.ts @@ -3,7 +3,7 @@ import type { } from './types'; import type { MessageQueue } from './queue'; import type { HttpSend } from './transport'; -import { type AudienceError, toAudienceError } from './errors'; +import { type AudienceError, invokeOnError, toAudienceError } from './errors'; import { CONSENT_PATH, getBaseUrl } from './config'; export interface ConsentManager { @@ -56,13 +56,8 @@ export function createConsentManager( // Fire-and-forget. HttpSend never rejects, so the floating chain is safe. send(url, publishableKey, payload, { method: 'PUT', keepalive: true }) .then((result) => { - if (!result.ok && result.error && onError) { - try { - onError(toAudienceError(result.error, 'consent')); - } catch { - // Swallow callback errors — the consent state machine must not - // wedge on a throwing handler. - } + if (!result.ok && result.error) { + invokeOnError(onError, toAudienceError(result.error, 'consent')); } }); } diff --git a/packages/audience/core/src/errors.ts b/packages/audience/core/src/errors.ts index 2e3c35b800..29153d014e 100644 --- a/packages/audience/core/src/errors.ts +++ b/packages/audience/core/src/errors.ts @@ -178,3 +178,28 @@ export function toAudienceError( cause: err.cause, }); } + +/** + * Invoke a studio-supplied `onError` callback, swallowing any exception + * it throws. + * + * 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 + * helper, not public API. + */ +export function invokeOnError( + onError: ((err: AudienceError) => void) | undefined, + err: AudienceError, +): void { + if (!onError) return; + try { + onError(err); + } catch { + // Swallow — handler must not crash the state machine. + } +} diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index e83bfd3ae6..a0de4a6244 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -1,6 +1,6 @@ import type { Message, BatchPayload } from './types'; import type { HttpSend } from './transport'; -import { type AudienceError, toAudienceError } from './errors'; +import { type AudienceError, invokeOnError, toAudienceError } from './errors'; import * as storage from './storage'; import { isBrowser } from './utils'; @@ -147,12 +147,8 @@ export class MessageQueue { } this.onFlush?.(result.ok, batch.length); - if (audienceErr && this.onError) { - try { - this.onError(audienceErr); - } catch { - // Swallow callback errors — the queue must not wedge on a throwing handler. - } + if (audienceErr) { + invokeOnError(this.onError, audienceErr); } } finally { this.flushing = false; From 556452365af1a0c39c8a92d9d87e374cf97ee952 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 9 Apr 2026 16:56:55 +1000 Subject: [PATCH 7/7] chore(audience-core): drop toAudienceError from public index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toAudienceError takes a TransportError, which studio code never holds — consumers only ever see the already-mapped AudienceError that arrives in their onError callback. Exporting the mapper expands the public surface for no benefit and makes it a breaking change to remove later. Internally the function is still used by MessageQueue.flush and createConsentManager via the relative './errors' import, so the runtime bundle is unaffected. Tests import via the relative path too, so they continue to pass. Scoped grep over packages/audience/sdk and packages/pixel confirms nothing references toAudienceError — safe to drop now. Addresses review comment r3055686696. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 048dc2f3ef..64a3c55ab8 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -39,7 +39,7 @@ export { generateId, getTimestamp, isBrowser } from './utils'; export type { HttpSend, TransportOptions } from './transport'; export { httpSend } from './transport'; export type { TransportResult, AudienceErrorCode } from './errors'; -export { TransportError, AudienceError, toAudienceError } from './errors'; +export { TransportError, AudienceError } from './errors'; export { MessageQueue } from './queue'; export { collectContext } from './context'; export { isTimestampValid, isAliasValid, truncate } from './validation';