Skip to content

feat(audience-core): unified AudienceError surface + fix partial-success silent drop#2841

Merged
ImmutableJeffrey merged 7 commits intomainfrom
feat/audience-core-error-surface
Apr 9, 2026
Merged

feat(audience-core): unified AudienceError surface + fix partial-success silent drop#2841
ImmutableJeffrey merged 7 commits intomainfrom
feat/audience-core-error-surface

Conversation

@ImmutableJeffrey
Copy link
Copy Markdown
Contributor

@ImmutableJeffrey ImmutableJeffrey commented Apr 9, 2026

Summary

Adds a unified AudienceError surface in @imtbl/audience-core so every audience surface (web, pixel, future Unity / Unreal) reports failures through the same type. Also fixes a silent data-loss bug in MessageQueue.flush where backend partial-rejections were silently dropped.

Part A — Error surface

  • New AudienceError class in core/src/errors.ts — extends Error with code: AudienceErrorCode, status, endpoint, responseBody, cause.
  • AudienceErrorCode closed union: 'FLUSH_FAILED' | 'CONSENT_SYNC_FAILED' | 'NETWORK_ERROR' | 'VALIDATION_REJECTED'.
  • toAudienceError(err, source, count?) helper centralises the mapping from TransportError to AudienceError, removing the need for per-consumer mapping logic. Kept internal — not re-exported from the public index since consumer code never holds a TransportError.
  • invokeOnError(onError, err) helper centralises the swallow-and-continue semantics shared by MessageQueue and createConsentManager so the internal state machines can't wedge on a throwing handler.
  • MessageQueue accepts onError?: (err: AudienceError) => void in options. Fires after failed flushes. Callback exceptions are swallowed via invokeOnError.
  • createConsentManager accepts onError? 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

  • httpSend now parses the 2xx response body. If it reports rejected > 0, returns ok: false with the parsed body — instead of pretending everything succeeded.
  • The partial-rejection check uses a 'rejected' in body type guard before casting, so malformed backend responses shaped as arrays can't slip through (typeof [] === 'object' would otherwise let them pass).
  • toAudienceError maps 2xx failures to 'VALIDATION_REJECTED' with message "Backend rejected M of N messages".
  • MessageQueue.flush drops the batch on VALIDATION_REJECTED (retrying won't fix what the backend deterministically rejected) and fires onError. Generic FLUSH_FAILED / NETWORK_ERROR still retain messages for retry.

Breaking changes

@imtbl/audience-core is private: true so these are internal-only, but any code in packages/audience/sdk or packages/pixel that still imported the following from @imtbl/audience-core will need to update:

Removed exports (dead code — nothing in the repo consumed them):

  • ConsentUpdatePayload (type) — moved fully internal; only httpSend constructs these.
  • getAnonymousId — superseded by getOrCreateAnonymousId.
  • setCookie — only the read/delete side is part of the public API now.
  • storage namespace — internal-only persistence layer.
  • SESSION_MAX_AGE — replaced by SESSION_START / SESSION_END.
  • truncateSource — the single caller was inlined.
  • getOrCreateSessionId, getSessionId — superseded by getOrCreateSession which returns the full session result.
  • clearAttribution — attribution is now immutable per session; no clear path needed.

Changed exports:

  • TransportError is now a runtime export (class), previously a type-only export. Instances have status, endpoint, body, and cause fields. This matches the squash-merged SDK-84 shape.
  • toAudienceError is still used internally by queue + consent but is not re-exported from the public index — consumer code receives an already-mapped AudienceError from its onError callback and never needs the raw TransportErrorAudienceError mapper.

New exports:

  • AudienceError (class), AudienceErrorCode (type).

Linear

  • SDK-85 — Audience SDK: unified AudienceError surface in core + fix partial-success silent drop

Test plan

  • pnpm --filter @imtbl/audience-core --filter @imtbl/audience --filter @imtbl/pixel test — 113 core + 49 sdk + 64 pixel
  • pnpm --filter @imtbl/audience-core --filter @imtbl/audience --filter @imtbl/pixel typecheck — clean
  • pnpm --filter @imtbl/audience-core --filter @imtbl/audience --filter @imtbl/pixel lint — clean
  • CI green

What's next

One follow-up PR wires the new onError surface through @imtbl/audience and @imtbl/pixel (SDK-88).

🤖 Generated with Claude Code

@ImmutableJeffrey ImmutableJeffrey requested a review from a team as a code owner April 9, 2026 04:20
@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Apr 9, 2026

View your CI Pipeline Execution ↗ for commit 5564523

Command Status Duration Result
nx affected -t build,lint,test ✅ Succeeded 14s View ↗
nx run-many -p @imtbl/sdk,@imtbl/checkout-widge... ✅ Succeeded 2s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-09 07:29:12 UTC

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

✅ Pixel Bundle Size — @imtbl/pixel

Metric Size Delta vs main
Gzipped 4879 bytes (4.76 KB) +496 bytes
Raw (minified) 13246 bytes +1456 bytes

Budget: 10.00 KB gzipped (warn at 8.00 KB)

ImmutableJeffrey and others added 4 commits April 9, 2026 14:35
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>
@ImmutableJeffrey ImmutableJeffrey force-pushed the feat/audience-core-error-surface branch from 5d697c0 to 8d1d35f Compare April 9, 2026 04:37
Comment thread packages/audience/core/src/index.ts
Comment thread packages/audience/core/src/queue.ts Outdated
Comment thread packages/audience/core/src/transport.ts Outdated
Comment thread packages/audience/core/src/index.ts
ImmutableJeffrey and others added 3 commits April 9, 2026 16:55
…'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>
@ImmutableJeffrey ImmutableJeffrey added this pull request to the merge queue Apr 9, 2026
Merged via the queue into main with commit 906606b Apr 9, 2026
8 checks passed
@ImmutableJeffrey ImmutableJeffrey deleted the feat/audience-core-error-surface branch April 9, 2026 07:49
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants