diff --git a/packages/audience/core/src/consent.test.ts b/packages/audience/core/src/consent.test.ts index 4a235ee5fe..dc770a7e58 100644 --- a/packages/audience/core/src/consent.test.ts +++ b/packages/audience/core/src/consent.test.ts @@ -1,7 +1,8 @@ import { createConsentManager } from './consent'; import type { HttpSend } from './transport'; +import { TransportError } from './errors'; -function createMockSend(): jest.MockedFunction { +function createMockSend() { return jest.fn, Parameters>().mockResolvedValue({ ok: true }); } @@ -117,4 +118,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: 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); + + 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: 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); + + 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: 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); + + // 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..7dc58b2f66 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, invokeOnError, 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,13 @@ 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) { + invokeOnError(onError, toAudienceError(result.error, 'consent')); + } + }); } 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..dd1e4216c5 --- /dev/null +++ b/packages/audience/core/src/errors.test.ts @@ -0,0 +1,128 @@ +import { + AudienceError, TransportError, toAudienceError, +} 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 = new TransportError({ + status: 500, + endpoint: 'https://api.dev.immutable.com/v1/audience/messages', + body: { code: 'INTERNAL_ERROR' }, + }); + + 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', () => { + 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); + }); + }); + + describe('partial-rejection (2xx with rejected > 0)', () => { + it('maps to VALIDATION_REJECTED with backend body preserved', () => { + 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); + + 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 = new 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 11ad1d9d47..29153d014e 100644 --- a/packages/audience/core/src/errors.ts +++ b/packages/audience/core/src/errors.ts @@ -54,3 +54,152 @@ 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.). + * - `'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' + | 'VALIDATION_REJECTED'; + +/** + * 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 { + // Network failure — no HTTP response received. + 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, + }); + } + + // 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' + ? `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, + }); +} + +/** + * 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/index.ts b/packages/audience/core/src/index.ts index 08bb4bf005..64a3c55ab8 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,20 +38,16 @@ export { generateId, getTimestamp, isBrowser } from './utils'; export type { HttpSend, TransportOptions } from './transport'; export { httpSend } from './transport'; -export type { TransportError, TransportResult } from './errors'; +export type { TransportResult, AudienceErrorCode } from './errors'; +export { TransportError, AudienceError } 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'; diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index d1811a2f61..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 { TransportError, type TransportResult } from './errors'; +import { TransportError, type AudienceError, type 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,101 @@ 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: new TransportError({ + 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: new TransportError({ + 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('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: new TransportError({ + 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 e2e97602bd..a0de4a6244 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, invokeOnError, 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) ?? []; @@ -114,11 +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 (audienceErr) { + invokeOnError(this.onError, audienceErr); + } } finally { this.flushing = false; } 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..8d92cc20fa 100644 --- a/packages/audience/core/src/transport.ts +++ b/packages/audience/core/src/transport.ts @@ -67,6 +67,38 @@ 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. + // + // 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); + 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, + rejected, + }); + return { + ok: false, + error: new TransportError({ + status: response.status, + endpoint: url, + body, + }), + }; + } + } + return { ok: true }; } catch (err) { const error = new TransportError({