feat(audience-core): unified AudienceError surface + fix partial-success silent drop#2841
Merged
ImmutableJeffrey merged 7 commits intomainfrom Apr 9, 2026
Merged
Conversation
|
View your CI Pipeline Execution ↗ for commit 5564523
☁️ Nx Cloud last updated this comment at |
✅ Pixel Bundle Size — @imtbl/pixel
Budget: 10.00 KB gzipped (warn at 8.00 KB) |
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>
…JECTED 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>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
5d697c0 to
8d1d35f
Compare
nattb8
reviewed
Apr 9, 2026
nattb8
reviewed
Apr 9, 2026
nattb8
reviewed
Apr 9, 2026
nattb8
reviewed
Apr 9, 2026
…'rejected' in check 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) <noreply@anthropic.com>
…/consent callback safety
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
nattb8
approved these changes
Apr 9, 2026
ImmutableJeffrey
added a commit
that referenced
this pull request
Apr 9, 2026
After rebasing feat/audience-web-sdk-demo onto main, four places still referenced the pre-review shape of AudienceError that #2841 changed during review, plus the lockfile carried stale resolutions from before main drifted: - src/cdn.ts: AudienceError now lives in @imtbl/audience-core/errors, not sdk/src/types. Import it from core alongside IdentityType. - README.md: the AudienceError.code union lost 'UNKNOWN' and gained 'VALIDATION_REJECTED' (terminal failure, 2xx with rejected > 0). Update the documented shape to match. - src/cdn.test.ts: the smoke test's fake AudienceError was constructed with code: 'UNKNOWN', which is no longer a valid AudienceErrorCode. Use 'NETWORK_ERROR' which matches the test's status: 0 setup. - pnpm-lock.yaml: reset to main's baseline and reinstall so the only delta is the new esbuild-plugin-replace dev-dep added by the CDN bundle build commit. This avoids the rebase carrying over stale @types/node and peer-context resolutions from before main drifted. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7 tasks
ImmutableJeffrey
added a commit
that referenced
this pull request
Apr 9, 2026
After rebasing feat/audience-web-sdk-demo onto main, four places still referenced the pre-review shape of AudienceError that #2841 changed during review, plus the lockfile carried stale resolutions from before main drifted: - src/cdn.ts: AudienceError now lives in @imtbl/audience-core/errors, not sdk/src/types. Import it from core alongside IdentityType. - README.md: the AudienceError.code union lost 'UNKNOWN' and gained 'VALIDATION_REJECTED' (terminal failure, 2xx with rejected > 0). Update the documented shape to match. - src/cdn.test.ts: the smoke test's fake AudienceError was constructed with code: 'UNKNOWN', which is no longer a valid AudienceErrorCode. Use 'NETWORK_ERROR' which matches the test's status: 0 setup. - pnpm-lock.yaml: reset to main's baseline and reinstall so the only delta is the new esbuild-plugin-replace dev-dep added by the CDN bundle build commit. This avoids the rebase carrying over stale @types/node and peer-context resolutions from before main drifted. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a unified
AudienceErrorsurface in@imtbl/audience-coreso every audience surface (web, pixel, future Unity / Unreal) reports failures through the same type. Also fixes a silent data-loss bug inMessageQueue.flushwhere backend partial-rejections were silently dropped.Part A — Error surface
AudienceErrorclass incore/src/errors.ts— extends Error withcode: AudienceErrorCode,status,endpoint,responseBody,cause.AudienceErrorCodeclosed union:'FLUSH_FAILED' | 'CONSENT_SYNC_FAILED' | 'NETWORK_ERROR' | 'VALIDATION_REJECTED'.toAudienceError(err, source, count?)helper centralises the mapping fromTransportErrortoAudienceError, removing the need for per-consumer mapping logic. Kept internal — not re-exported from the public index since consumer code never holds aTransportError.invokeOnError(onError, err)helper centralises the swallow-and-continue semantics shared byMessageQueueandcreateConsentManagerso the internal state machines can't wedge on a throwing handler.MessageQueueacceptsonError?: (err: AudienceError) => voidin options. Fires after failed flushes. Callback exceptions are swallowed viainvokeOnError.createConsentManageracceptsonError?as a new optional last parameter. Fires on consent PUT failure via.then()on the fire-and-forget chain.Part B — Partial-success bug fix
httpSendnow parses the 2xx response body. If it reportsrejected > 0, returnsok: falsewith the parsed body — instead of pretending everything succeeded.'rejected' in bodytype guard before casting, so malformed backend responses shaped as arrays can't slip through (typeof [] === 'object'would otherwise let them pass).toAudienceErrormaps 2xx failures to'VALIDATION_REJECTED'with message"Backend rejected M of N messages".MessageQueue.flushdrops the batch onVALIDATION_REJECTED(retrying won't fix what the backend deterministically rejected) and firesonError. GenericFLUSH_FAILED/NETWORK_ERRORstill retain messages for retry.Breaking changes
@imtbl/audience-coreisprivate: trueso these are internal-only, but any code inpackages/audience/sdkorpackages/pixelthat still imported the following from@imtbl/audience-corewill need to update:Removed exports (dead code — nothing in the repo consumed them):
ConsentUpdatePayload(type) — moved fully internal; onlyhttpSendconstructs these.getAnonymousId— superseded bygetOrCreateAnonymousId.setCookie— only the read/delete side is part of the public API now.storagenamespace — internal-only persistence layer.SESSION_MAX_AGE— replaced bySESSION_START/SESSION_END.truncateSource— the single caller was inlined.getOrCreateSessionId,getSessionId— superseded bygetOrCreateSessionwhich returns the full session result.clearAttribution— attribution is now immutable per session; no clear path needed.Changed exports:
TransportErroris now a runtime export (class), previously a type-only export. Instances havestatus,endpoint,body, andcausefields. This matches the squash-merged SDK-84 shape.toAudienceErroris still used internally by queue + consent but is not re-exported from the public index — consumer code receives an already-mappedAudienceErrorfrom itsonErrorcallback and never needs the rawTransportError→AudienceErrormapper.New exports:
AudienceError(class),AudienceErrorCode(type).Linear
Test plan
pnpm --filter @imtbl/audience-core --filter @imtbl/audience --filter @imtbl/pixel test— 113 core + 49 sdk + 64 pixelpnpm --filter @imtbl/audience-core --filter @imtbl/audience --filter @imtbl/pixel typecheck— cleanpnpm --filter @imtbl/audience-core --filter @imtbl/audience --filter @imtbl/pixel lint— cleanWhat's next
One follow-up PR wires the new
onErrorsurface through@imtbl/audienceand@imtbl/pixel(SDK-88).🤖 Generated with Claude Code