Skip to content

Commit d6d9a68

Browse files
fix(audience-core): surface backend-rejected batches as VALIDATION_REJECTED instead of silent drop
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) <noreply@anthropic.com>
1 parent 17bae51 commit d6d9a68

6 files changed

Lines changed: 156 additions & 5 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,34 @@ describe('toAudienceError', () => {
9595
expect(err.cause).toBe(networkError.cause);
9696
});
9797
});
98+
99+
describe('partial-rejection (2xx with rejected > 0)', () => {
100+
it('maps to VALIDATION_REJECTED with backend body preserved', () => {
101+
const partialError: 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: 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+
});
98128
});

packages/audience/core/src/errors.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,17 @@ export interface TransportResult {
6363
* - `'CONSENT_SYNC_FAILED'` — PUT to `/v1/audience/tracking-consent` returned non-2xx.
6464
* - `'NETWORK_ERROR'` — fetch rejected before a response was received
6565
* (network failure, CORS, DNS, etc.).
66+
* - `'VALIDATION_REJECTED'` — backend returned 2xx but the body reported
67+
* `rejected > 0`. Terminal: retrying won't help, the
68+
* messages were dropped from the queue. Inspect
69+
* `responseBody` for the per-message detail when the
70+
* backend provides it.
6671
*/
6772
export type AudienceErrorCode =
6873
| 'FLUSH_FAILED'
6974
| 'CONSENT_SYNC_FAILED'
70-
| 'NETWORK_ERROR';
75+
| 'NETWORK_ERROR'
76+
| 'VALIDATION_REJECTED';
7177

7278
/**
7379
* Public error type passed to the SDK's `onError` callback. Wraps the
@@ -131,6 +137,7 @@ export function toAudienceError(
131137
source: 'flush' | 'consent',
132138
count?: number,
133139
): AudienceError {
140+
// Network failure — no HTTP response received.
134141
if (err.status === 0) {
135142
return new AudienceError({
136143
code: 'NETWORK_ERROR',
@@ -143,6 +150,23 @@ export function toAudienceError(
143150
});
144151
}
145152

153+
// 2xx response with backend-rejected messages. Terminal, do not retry —
154+
// the only way ok:false comes back with a 2xx status is when httpSend
155+
// detected `rejected > 0` in the parsed response body.
156+
if (err.status >= 200 && err.status < 300) {
157+
const body = err.body as { accepted?: number; rejected?: number } | undefined;
158+
const rejected = body?.rejected ?? 0;
159+
const accepted = body?.accepted ?? 0;
160+
return new AudienceError({
161+
code: 'VALIDATION_REJECTED',
162+
message: `Backend rejected ${rejected} of ${rejected + accepted} messages`,
163+
status: err.status,
164+
endpoint: err.endpoint,
165+
responseBody: err.body,
166+
});
167+
}
168+
169+
// Generic HTTP failure (4xx / 5xx).
146170
return new AudienceError({
147171
code: source === 'flush' ? 'FLUSH_FAILED' : 'CONSENT_SYNC_FAILED',
148172
message: source === 'flush'

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,36 @@ describe('MessageQueue', () => {
265265
expect(onError).toHaveBeenCalled();
266266
});
267267

268+
it('drops batch and fires VALIDATION_REJECTED when backend reports partial rejection', async () => {
269+
// Backend rejected one message in a batch of two. The 200 OK response
270+
// body says { accepted: 1, rejected: 1 }. Expected behaviour:
271+
// - Queue clears the batch (retrying validation failures won't help).
272+
// - onError fires with code 'VALIDATION_REJECTED' so studios are aware.
273+
// - Bug fix: previously the queue checked only result.ok and dropped
274+
// the entire batch silently, losing rejected messages with no signal.
275+
const onError = jest.fn();
276+
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
277+
ok: false,
278+
error: {
279+
status: 200,
280+
endpoint: 'https://api.immutable.com/v1/audience/messages',
281+
body: { accepted: 1, rejected: 1 },
282+
},
283+
});
284+
const queue = createQueue(send, { onError });
285+
286+
queue.enqueue(makeMessage('1'));
287+
queue.enqueue(makeMessage('2'));
288+
await queue.flush();
289+
290+
expect(queue.length).toBe(0);
291+
expect(onError).toHaveBeenCalledTimes(1);
292+
const err = onError.mock.calls[0][0];
293+
expect(err.code).toBe('VALIDATION_REJECTED');
294+
expect(err.status).toBe(200);
295+
expect(err.responseBody).toEqual({ accepted: 1, rejected: 1 });
296+
});
297+
268298
it('purges messages matching a predicate', () => {
269299
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue(okResult);
270300
const queue = createQueue(send);

packages/audience/core/src/queue.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,26 @@ export class MessageQueue {
130130
const payload: BatchPayload = { messages: batch };
131131

132132
const result = await this.send(this.endpointUrl, this.publishableKey, payload);
133-
if (result.ok) {
133+
134+
let audienceErr: AudienceError | undefined;
135+
if (!result.ok && result.error) {
136+
audienceErr = toAudienceError(result.error, 'flush', batch.length);
137+
}
138+
139+
// Drop the batch on success OR on a terminal validation failure.
140+
// VALIDATION_REJECTED means the backend deterministically rejected
141+
// some messages — retrying won't help, so we drop them rather than
142+
// accumulate stale data forever.
143+
const isTerminal = audienceErr?.code === 'VALIDATION_REJECTED';
144+
if (result.ok || isTerminal) {
134145
this.messages = this.messages.slice(batch.length);
135146
this.persist();
136147
}
148+
137149
this.onFlush?.(result.ok, batch.length);
138-
if (!result.ok && result.error && this.onError) {
150+
if (audienceErr && this.onError) {
139151
try {
140-
this.onError(toAudienceError(result.error, 'flush', batch.length));
152+
this.onError(audienceErr);
141153
} catch {
142154
// Swallow callback errors — the queue must not wedge on a throwing handler.
143155
}

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

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,45 @@ describe('httpSend', () => {
6666
}));
6767
});
6868

69-
it('returns ok on 2xx response', async () => {
69+
it('returns ok on 2xx response with no body', async () => {
7070
global.fetch = jest.fn().mockResolvedValue({ ok: true });
7171
const result = await httpSend('https://example.com', 'pk', payload);
7272
expect(result.ok).toBe(true);
7373
expect(result.error).toBeUndefined();
7474
});
7575

76+
it('returns ok on 2xx response when body reports zero rejected', async () => {
77+
global.fetch = jest.fn().mockResolvedValue({
78+
ok: true,
79+
status: 200,
80+
headers: { get: () => 'application/json' },
81+
json: async () => ({ success: true, accepted: 1, rejected: 0 }),
82+
});
83+
84+
const result = await httpSend('https://example.com', 'pk', payload);
85+
86+
expect(result.ok).toBe(true);
87+
expect(result.error).toBeUndefined();
88+
});
89+
90+
it('returns ok:false with status 200 when backend reports partial rejection', async () => {
91+
// The silent-drop bug: backend returns 200 with { accepted: 1, rejected: 1 }
92+
// and the queue used to clear the entire batch without surfacing the
93+
// rejection. After this fix httpSend treats it as a structured failure.
94+
global.fetch = jest.fn().mockResolvedValue({
95+
ok: true,
96+
status: 200,
97+
headers: { get: () => 'application/json' },
98+
json: async () => ({ success: true, accepted: 1, rejected: 1 }),
99+
});
100+
101+
const result = await httpSend('https://example.com', 'pk', payload);
102+
103+
expect(result.ok).toBe(false);
104+
expect(result.error?.status).toBe(200);
105+
expect(result.error?.body).toEqual({ success: true, accepted: 1, rejected: 1 });
106+
});
107+
76108
it('returns structured error on HTTP failure with parsed JSON body', async () => {
77109
global.fetch = jest.fn().mockResolvedValue({
78110
ok: false,

packages/audience/core/src/transport.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,29 @@ export const httpSend: HttpSend = async (
6767
};
6868
}
6969

70+
// Successful HTTP, but the backend MessagesResponse may report
71+
// per-message validation failures via { accepted, rejected }. Treat
72+
// any rejection as a non-retryable failure so the queue surfaces it
73+
// through onError instead of silently dropping the rejected items.
74+
const body = await parseBody(response);
75+
const bodyObj = (typeof body === 'object' && body !== null)
76+
? body as { accepted?: number; rejected?: number }
77+
: undefined;
78+
if ((bodyObj?.rejected ?? 0) > 0) {
79+
track('audience', 'transport_partial_rejected', {
80+
status: response.status,
81+
rejected: bodyObj?.rejected ?? 0,
82+
});
83+
return {
84+
ok: false,
85+
error: {
86+
status: response.status,
87+
endpoint: url,
88+
body: bodyObj,
89+
},
90+
};
91+
}
92+
7093
return { ok: true };
7194
} catch (err) {
7295
const error = new TransportError({

0 commit comments

Comments
 (0)