Skip to content

Commit 520bc0f

Browse files
feat(audience-core): add AudienceError + unified onError callback
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) <noreply@anthropic.com>
1 parent 01a67ef commit 520bc0f

7 files changed

Lines changed: 397 additions & 6 deletions

File tree

packages/audience/core/src/consent.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createConsentManager } from './consent';
22
import type { HttpSend } from './transport';
33

4-
function createMockSend(): jest.MockedFunction<HttpSend> {
4+
function createMockSend() {
55
return jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({ ok: true });
66
}
77

@@ -117,4 +117,85 @@ describe('createConsentManager', () => {
117117

118118
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
119119
});
120+
121+
describe('onError callback', () => {
122+
it('fires onError with mapped CONSENT_SYNC_FAILED on consent PUT failure', async () => {
123+
const queue = createMockQueue();
124+
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
125+
ok: false,
126+
error: {
127+
status: 503,
128+
endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent',
129+
body: { code: 'SERVICE_UNAVAILABLE' },
130+
},
131+
});
132+
const onError = jest.fn();
133+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);
134+
135+
manager.setLevel('anonymous');
136+
137+
// notifyBackend's .then() runs on the microtask queue.
138+
await Promise.resolve();
139+
await Promise.resolve();
140+
141+
expect(onError).toHaveBeenCalledTimes(1);
142+
const err = onError.mock.calls[0][0];
143+
expect(err.code).toBe('CONSENT_SYNC_FAILED');
144+
expect(err.status).toBe(503);
145+
expect(err.message).toBe('Consent sync failed with status 503');
146+
});
147+
148+
it('fires onError with NETWORK_ERROR on network failure', async () => {
149+
const queue = createMockQueue();
150+
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
151+
ok: false,
152+
error: {
153+
status: 0,
154+
endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent',
155+
cause: new TypeError('Failed to fetch'),
156+
},
157+
});
158+
const onError = jest.fn();
159+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);
160+
161+
manager.setLevel('anonymous');
162+
await Promise.resolve();
163+
await Promise.resolve();
164+
165+
expect(onError).toHaveBeenCalledTimes(1);
166+
const err = onError.mock.calls[0][0];
167+
expect(err.code).toBe('NETWORK_ERROR');
168+
expect(err.message).toBe('Network error syncing consent');
169+
});
170+
171+
it('does not fire onError on successful consent sync', async () => {
172+
const queue = createMockQueue();
173+
const send = createMockSend();
174+
const onError = jest.fn();
175+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);
176+
177+
manager.setLevel('anonymous');
178+
await Promise.resolve();
179+
await Promise.resolve();
180+
181+
expect(onError).not.toHaveBeenCalled();
182+
});
183+
184+
it('swallows exceptions thrown from the onError callback', async () => {
185+
const queue = createMockQueue();
186+
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
187+
ok: false,
188+
error: { status: 500, endpoint: 'x', body: null },
189+
});
190+
const onError = jest.fn().mockImplementation(() => { throw new Error('callback boom'); });
191+
const manager = createConsentManager(queue, send, 'pk_test', 'anon-1', 'dev', 'pixel', 'none', onError);
192+
193+
// Synchronous call must not throw even though the .then() handler will.
194+
expect(() => manager.setLevel('anonymous')).not.toThrow();
195+
196+
await Promise.resolve();
197+
await Promise.resolve();
198+
expect(onError).toHaveBeenCalled();
199+
});
200+
});
120201
});

packages/audience/core/src/consent.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
} from './types';
44
import type { MessageQueue } from './queue';
55
import type { HttpSend } from './transport';
6+
import { type AudienceError, toAudienceError } from './errors';
67
import { CONSENT_PATH, getBaseUrl } from './config';
78

89
export interface ConsentManager {
@@ -29,6 +30,10 @@ export function detectDoNotTrack(): boolean {
2930
* - Fires PUT to `/v1/audience/tracking-consent` on every state change via
3031
* the injected `send`. Sharing the same `HttpSend` instance with the queue
3132
* keeps the transport layer uniform — no module-level mocking required.
33+
* - On consent sync failure, fires `onError` with a public {@link AudienceError}
34+
* mapped via {@link toAudienceError}, so callers don't have to repeat the
35+
* `status === 0 → NETWORK_ERROR` mapping themselves. Exceptions thrown
36+
* from the callback are swallowed.
3237
*/
3338
export function createConsentManager(
3439
queue: MessageQueue,
@@ -38,6 +43,7 @@ export function createConsentManager(
3843
environment: Environment,
3944
source: string,
4045
initialLevel?: ConsentLevel,
46+
onError?: (err: AudienceError) => void,
4147
): ConsentManager {
4248
const dntDetected = detectDoNotTrack();
4349
let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none');
@@ -47,8 +53,18 @@ export function createConsentManager(
4753
function notifyBackend(level: ConsentLevel): void {
4854
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
4955
const payload: ConsentUpdatePayload = { anonymousId, status: level, source };
50-
// Fire-and-forget. HttpSend never rejects, so a floating promise is safe.
51-
send(url, publishableKey, payload, { method: 'PUT', keepalive: true });
56+
// Fire-and-forget. HttpSend never rejects, so the floating chain is safe.
57+
send(url, publishableKey, payload, { method: 'PUT', keepalive: true })
58+
.then((result) => {
59+
if (!result.ok && result.error && onError) {
60+
try {
61+
onError(toAudienceError(result.error, 'consent'));
62+
} catch {
63+
// Swallow callback errors — the consent state machine must not
64+
// wedge on a throwing handler.
65+
}
66+
}
67+
});
5268
}
5369

5470
const manager: ConsentManager = {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
AudienceError, toAudienceError, type TransportError,
3+
} from './errors';
4+
5+
describe('AudienceError', () => {
6+
it('is an instance of Error', () => {
7+
const err = new AudienceError({
8+
code: 'FLUSH_FAILED',
9+
message: 'flush failed',
10+
status: 500,
11+
endpoint: 'https://example.com',
12+
});
13+
14+
expect(err).toBeInstanceOf(Error);
15+
expect(err).toBeInstanceOf(AudienceError);
16+
expect(err.name).toBe('AudienceError');
17+
});
18+
19+
it('exposes structured fields from init', () => {
20+
const cause = new TypeError('boom');
21+
const err = new AudienceError({
22+
code: 'NETWORK_ERROR',
23+
message: 'network down',
24+
status: 0,
25+
endpoint: 'https://example.com',
26+
responseBody: { detail: 'x' },
27+
cause,
28+
});
29+
30+
expect(err.code).toBe('NETWORK_ERROR');
31+
expect(err.message).toBe('network down');
32+
expect(err.status).toBe(0);
33+
expect(err.endpoint).toBe('https://example.com');
34+
expect(err.responseBody).toEqual({ detail: 'x' });
35+
expect(err.cause).toBe(cause);
36+
});
37+
});
38+
39+
describe('toAudienceError', () => {
40+
const httpError: TransportError = {
41+
status: 500,
42+
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
43+
body: { code: 'INTERNAL_ERROR' },
44+
};
45+
46+
const networkError: TransportError = {
47+
status: 0,
48+
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
49+
cause: new TypeError('Failed to fetch'),
50+
};
51+
52+
describe('flush source', () => {
53+
it('maps HTTP error to FLUSH_FAILED with status in message', () => {
54+
const err = toAudienceError(httpError, 'flush', 5);
55+
56+
expect(err.code).toBe('FLUSH_FAILED');
57+
expect(err.message).toBe('Flush failed with status 500');
58+
expect(err.status).toBe(500);
59+
expect(err.endpoint).toBe(httpError.endpoint);
60+
expect(err.responseBody).toEqual({ code: 'INTERNAL_ERROR' });
61+
});
62+
63+
it('maps network error to NETWORK_ERROR with batch count in message', () => {
64+
const err = toAudienceError(networkError, 'flush', 5);
65+
66+
expect(err.code).toBe('NETWORK_ERROR');
67+
expect(err.message).toBe('Network error sending 5 messages');
68+
expect(err.status).toBe(0);
69+
expect(err.cause).toBe(networkError.cause);
70+
});
71+
72+
it('falls back to count 0 in network message when count is undefined', () => {
73+
const err = toAudienceError(networkError, 'flush');
74+
expect(err.message).toBe('Network error sending 0 messages');
75+
});
76+
});
77+
78+
describe('consent source', () => {
79+
it('maps HTTP error to CONSENT_SYNC_FAILED with status in message', () => {
80+
const err = toAudienceError(
81+
{ ...httpError, endpoint: 'https://api.dev.immutable.com/v1/audience/tracking-consent' },
82+
'consent',
83+
);
84+
85+
expect(err.code).toBe('CONSENT_SYNC_FAILED');
86+
expect(err.message).toBe('Consent sync failed with status 500');
87+
});
88+
89+
it('maps network error to NETWORK_ERROR with consent-specific message', () => {
90+
const err = toAudienceError(networkError, 'consent');
91+
92+
expect(err.code).toBe('NETWORK_ERROR');
93+
expect(err.message).toBe('Network error syncing consent');
94+
expect(err.status).toBe(0);
95+
expect(err.cause).toBe(networkError.cause);
96+
});
97+
});
98+
});

packages/audience/core/src/errors.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,103 @@ export interface TransportResult {
5454
ok: boolean;
5555
error?: TransportError;
5656
}
57+
58+
/**
59+
* Stable, machine-readable code identifying the kind of audience SDK
60+
* failure. Studios can branch on this in their `onError` handler.
61+
*
62+
* - `'FLUSH_FAILED'` — POST to `/v1/audience/messages` returned non-2xx.
63+
* - `'CONSENT_SYNC_FAILED'` — PUT to `/v1/audience/tracking-consent` returned non-2xx.
64+
* - `'NETWORK_ERROR'` — fetch rejected before a response was received
65+
* (network failure, CORS, DNS, etc.).
66+
*/
67+
export type AudienceErrorCode =
68+
| 'FLUSH_FAILED'
69+
| 'CONSENT_SYNC_FAILED'
70+
| 'NETWORK_ERROR';
71+
72+
/**
73+
* Public error type passed to the SDK's `onError` callback. Wraps the
74+
* low-level {@link TransportError} and adds a closed `code` union plus a
75+
* human-readable `message`.
76+
*
77+
* Lives in `@imtbl/audience-core` so every surface (web, pixel, unity,
78+
* unreal) reports failures through the same shape — no per-package
79+
* error class, no duplicated mapping logic.
80+
*
81+
* Is an instance of `Error` so it can be thrown, logged, or passed to
82+
* Sentry / Datadog without an adapter.
83+
*/
84+
export class AudienceError extends Error {
85+
readonly code: AudienceErrorCode;
86+
87+
readonly status: number;
88+
89+
readonly endpoint: string;
90+
91+
readonly responseBody?: unknown;
92+
93+
// `cause` is a standard Error prop in ES2022, declared here for older
94+
// TS targets that don't have it in their lib.d.ts.
95+
readonly cause?: unknown;
96+
97+
constructor(init: {
98+
code: AudienceErrorCode;
99+
message: string;
100+
status: number;
101+
endpoint: string;
102+
responseBody?: unknown;
103+
cause?: unknown;
104+
}) {
105+
super(init.message);
106+
this.name = 'AudienceError';
107+
this.code = init.code;
108+
this.status = init.status;
109+
this.endpoint = init.endpoint;
110+
this.responseBody = init.responseBody;
111+
this.cause = init.cause;
112+
}
113+
}
114+
115+
/**
116+
* Convert a low-level {@link TransportError} into a public
117+
* {@link AudienceError} for delivery to studio code.
118+
*
119+
* Centralised so MessageQueue and ConsentManager don't each carry their
120+
* own copy of `status === 0 → NETWORK_ERROR` mapping logic.
121+
*
122+
* @param err The transport-level failure.
123+
* @param source Which subsystem hit the error — selects the error code
124+
* and shapes the human message.
125+
* @param count For `'flush'` failures, the number of messages in the
126+
* batch. Used in the human-readable message; ignored for
127+
* consent failures.
128+
*/
129+
export function toAudienceError(
130+
err: TransportError,
131+
source: 'flush' | 'consent',
132+
count?: number,
133+
): AudienceError {
134+
if (err.status === 0) {
135+
return new AudienceError({
136+
code: 'NETWORK_ERROR',
137+
message: source === 'flush'
138+
? `Network error sending ${count ?? 0} messages`
139+
: 'Network error syncing consent',
140+
status: 0,
141+
endpoint: err.endpoint,
142+
cause: err.cause,
143+
});
144+
}
145+
146+
return new AudienceError({
147+
code: source === 'flush' ? 'FLUSH_FAILED' : 'CONSENT_SYNC_FAILED',
148+
message: source === 'flush'
149+
? `Flush failed with status ${err.status}`
150+
: `Consent sync failed with status ${err.status}`,
151+
status: err.status,
152+
endpoint: err.endpoint,
153+
responseBody: err.body,
154+
cause: err.cause,
155+
});
156+
}

packages/audience/core/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@ export { generateId, getTimestamp, isBrowser } from './utils';
4343

4444
export type { HttpSend, TransportOptions } from './transport';
4545
export { httpSend } from './transport';
46-
export type { TransportError, TransportResult } from './errors';
46+
export type {
47+
TransportError,
48+
TransportResult,
49+
AudienceErrorCode,
50+
} from './errors';
51+
export { AudienceError, toAudienceError } from './errors';
4752
export { MessageQueue } from './queue';
4853
export { collectContext } from './context';
4954
export {

0 commit comments

Comments
 (0)