Skip to content

Commit 906606b

Browse files
feat(audience-core): unified AudienceError surface + fix partial-success silent drop (#2841)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c54a577 commit 906606b

9 files changed

Lines changed: 577 additions & 21 deletions

File tree

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

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

4-
function createMockSend(): jest.MockedFunction<HttpSend> {
5+
function createMockSend() {
56
return jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({ ok: true });
67
}
78

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

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

packages/audience/core/src/consent.ts

Lines changed: 13 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, invokeOnError, 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,13 @@ 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) {
60+
invokeOnError(onError, toAudienceError(result.error, 'consent'));
61+
}
62+
});
5263
}
5364

5465
const manager: ConsentManager = {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {
2+
AudienceError, TransportError, toAudienceError,
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 = new TransportError({
41+
status: 500,
42+
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
43+
body: { code: 'INTERNAL_ERROR' },
44+
});
45+
46+
const networkError = new 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+
99+
describe('partial-rejection (2xx with rejected > 0)', () => {
100+
it('maps to VALIDATION_REJECTED with backend body preserved', () => {
101+
const partialError = new TransportError({
102+
status: 200,
103+
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
104+
body: { accepted: 50, rejected: 50 },
105+
});
106+
107+
const err = toAudienceError(partialError, 'flush', 100);
108+
109+
expect(err.code).toBe('VALIDATION_REJECTED');
110+
expect(err.status).toBe(200);
111+
expect(err.message).toBe('Backend rejected 50 of 100 messages');
112+
expect(err.responseBody).toEqual({ accepted: 50, rejected: 50 });
113+
});
114+
115+
it('handles missing accepted/rejected fields gracefully', () => {
116+
const partialError = new TransportError({
117+
status: 200,
118+
endpoint: 'https://api.dev.immutable.com/v1/audience/messages',
119+
body: {},
120+
});
121+
122+
const err = toAudienceError(partialError, 'flush');
123+
124+
expect(err.code).toBe('VALIDATION_REJECTED');
125+
expect(err.message).toBe('Backend rejected 0 of 0 messages');
126+
});
127+
});
128+
});

0 commit comments

Comments
 (0)