Errors & recovery
Every failure the SDK surfaces is a TouchmarkError with a .code
from a closed set of seven. Branch on .code, never on the message.
import { TouchmarkError } from "@touchmark/sdk";
try {
await tm.session.emit(/* … */);
} catch (err) {
if (err instanceof TouchmarkError && err.code === "session_closed") {
/* recover — see below */
}
}
The seven codes at a glance
| Code | What happened | What you do |
|---|---|---|
session_closed | The session lapsed (≈1 week with no emits) and the server closed it. | Re-open with session.start, then re-emit with the same event_id. Wrap this once. |
session_not_found | The session_id isn't one the server knows. | Re-open the scope with session.start and use the fresh id. |
auth | The api_key is missing, wrong, or lacks permission. | Fix the credential/config. Not retryable — fail loud. |
invalid_payload | The payload, event_idx, or base_price_usd failed validation. | Fix the event data. It's a caller bug; it never reached the wire (for client-side checks). |
timeout | A network attempt exceeded its deadline. | Transient — retry later (e.g. via your outbox). |
unreachable | Touchmark couldn't be reached at all. | Handled for you by default (degraded mode). See below. |
rate_limited | You exceeded your quota. | The SDK already retried with backoff; if you still see it, back off and retry later. |
The rest of this page covers the ones with real recovery logic. The others (auth,
invalid_payload, session_not_found, timeout) are summarized above and revisited briefly at the
end.
session_closed
This is the one error every integration must handle, and the only recovery the SDK deliberately
leaves to you. A session is server-closed after about a week with no emits (consuming the valuation stream
doesn't reset the clock); the next emit (or end) on it throws session_closed.
The recovery is always the same: re-open the scope, swap in the new session_id, and re-emit with
the same event_id (the server de-duplicates, so re-sending is safe even if the first attempt
secretly landed).
try {
await tm.session.emit({ session_id, event_id, event_idx, event_type, payload, base_price_usd });
} catch (err) {
if (err instanceof TouchmarkError && err.code === "session_closed") {
// start is idempotent on scope_id, so this returns a fresh, open session. Keep its
// session_id as your current one, then re-emit with the SAME event_id.
const reopened = await tm.session.start({ scope_id, user_id });
// New session → the per-session counter restarts, so re-emit this event as event_idx 0.
await tm.session.emit({ session_id: reopened.session_id, event_id, event_idx: 0, event_type, payload, base_price_usd });
}
}
The throw is deliberate and impossible to ignore — a stale id can't be used silently — but you own the retry decision, and you write the wrapper once. The copy-pasteable version, with an outbox for anything that still fails, is the tested reference example.
unreachable & degraded mode
When Touchmark can't be reached, the SDK is fail-safe by default so an outage on our side never
takes down your hot path. emit and end swallow the outage, return normally, and fire your
onDegrade hook.
A degraded emit is not delivered — the event never reached Touchmark, and the SDK does not
buffer or replay it. So there is no later valuation for it: the base_price_usd you supplied is the
price that stands, unless you re-emit the event (with the same event_id, so the server
de-duplicates if it secretly landed). The natural place to do that is straight from onDegrade —
enqueue the event into your own outbox and retry it on reconnect. (onDegrade carries the
identifiers, not the payload, so the replay reads from your event store.) Emits that did land are
unaffected — their valuations arrive on the stream as usual.
| Method | Default (strict: false) | strict: true |
|---|---|---|
start | throws unreachable (no safe id to invent) | throws |
emit | degrades — returns, fires onDegrade | throws |
end | degrades — returns, fires onDegrade | throws |
Two things to know:
- Every non-
unreachableerror always throws, even in non-strict mode. Degraded mode only ever swallows a genuine outage — never a caller bug or a server-side error. streamValuationsis separate. It isn't part of degraded mode: it reconnects from its held cursor on a transient drop and surfaces only fatal errors.
Wire onDegrade so a degrade is never silent:
const tm = new Touchmark({
api_key: process.env.TOUCHMARK_API_KEY!,
onDegrade: ({ method, session_id, event_id }) => {
// metrics and logger are your own telemetry — wire these to whatever you already use.
metrics.increment("touchmark.degraded", { method });
logger.warn("Touchmark unreachable; base price stands", { method, session_id, event_id });
},
});
Use strict: true only if you'd rather fail loud than let the base price stand — it opts out of the
safety net everywhere.
rate_limited
The SDK retries rate_limited for you, internally, with full-jitter exponential backoff, all inside
the call's timeout_ms budget. So in normal operation you won't see this code at all — a brief
quota spike is absorbed transparently.
If a rate_limited error does reach you, it means the retries were exhausted within the budget.
Treat it as transient: back off and retry later (your outbox is the natural place), and if it's
persistent, you're over quota — talk to us about limits.
The rest, briefly
auth
A missing/invalid api_key or insufficient permission. Configuration, not a transient fault: it
always throws (never degrades), and retrying won't help — fix the key. Startup smoke-tests are the
cheapest place to catch it.
invalid_payload
Your payload isn't JSON-serializable, or event_idx/base_price_usd is out of range, or the
payload doesn't match the schema registered for your application's event_type. Client-side checks
throw before anything is sent, with a path to the offending value (e.g.
payload.items[3].when: Date is not JSON-serializable). A caller bug — fix the data; don't retry.
If the data is JSON-clean but the server returns invalid_payload, the payload didn't match the
schema we registered for that event_type on your application — a missing required field, a wrong
type, or an unknown event_type. The error message names the offending field. Compare against the
shape we set up at onboarding (see Payload → what schema?); if it still
looks right, send your Touchmark contact the event_type and payload and we'll reconcile the
registered schema. No SDK change is ever needed — schemas live server-side.
session_not_found
The session_id isn't known to the server (usually a stale or hand-built id). Re-open the scope with
session.start and use the id it returns. If you only ever use ids from start, you'll see
session_closed far more often than this.
timeout
A transport/per-attempt deadline elapsed. This is a network deadline, not an eval-completion SLA (scoring is asynchronous and unbounded — valuations are never awaited). Transient: retry later.
See also: the API reference for full signatures, and the
reference example for session_closed recovery + an outbox
in one tested file.