Skip to content

Error handling

The SDK distinguishes two failure regimes: transient (worth retrying) and fatal (stop the client). Your code should handle them differently.

Errors reach you through two channels:

  1. connect() rejects — only on fatal errors. Transient errors keep the promise pending while reconnect attempts run.
  2. error events — both fatal and transient surface here, distinguished by the fatal: boolean flag.
client.on('error', ({ message, fatal }) => {
if (fatal) console.error('stop:', message)
else console.warn('transient:', message)
})
try {
await client.connect()
} catch (err) {
// only reached on fatal — same message as the fatal `error` event
}

The SDK considers an error fatal when:

  • The server closed with 4001 (missing apiKey), 4002 (invalid apiKey), or 4003 (quota exceeded).
  • The server reported an incompatible protocol version.
  • maxAttempts reconnect attempts have been exhausted.

When fatal:

  1. The reconnect loop stops.
  2. error fires with fatal: true.
  3. The pending connect() promise (if any) rejects.
  4. The client transitions to connectionState.status: 'disconnected'.
  5. Calling disconnect() is safe but optional — the SDK is already idle.

To recover, construct a new client (createClient(...) again). The old instance is dead.

Transient errors include WebSocket drops, network blips, gateway restarts. The SDK reconnects with exponential backoff (see Reconnect tuning) and emits:

  • disconnected: { willReconnect: true, code, reason } when the connection drops.
  • reconnecting: { attempt, delayMs } before each reconnect attempt.
  • connected (no payload) on successful reconnection.
  • Sometimes error: { fatal: false, message } for low-level transport errors that the SDK is already retrying.

Most apps don’t need to react to transient error events — the reconnect machinery is already handling them. Surface them only for debugging or telemetry.

While the SDK reconnects, your in-memory state is frozenclient.snapshot() returns the last known state with stale: true. Display this in your UI:

function isStale() {
return client.snapshot().stale
}

For a “last update was N seconds ago” indicator on individual rows:

const lastSeenAt = new Map<SportEventId, number>()
client.on('sportEvent:updated', ({ sportEvent, receivedAt }) => {
lastSeenAt.set(sportEvent.id, receivedAt)
})
// somewhere in your UI:
const ageMs = Date.now() - (lastSeenAt.get(eventId) ?? 0)

receivedAt is the SDK’s local clock — see Time semantics.

Throwing from a listener does not crash the SDK. The emitter catches and logs (to console.error). But it’s still your problem — wrap risky logic:

client.on('odds:changed', payload => {
try {
expensiveAnalytics(payload)
} catch (err) {
reportToTelemetry(err)
}
})

This keeps a single bad listener from drowning telemetry on every tick.

What “incompatible protocol” looks like

Section titled “What “incompatible protocol” looks like”

If you upgrade realtimeodds past a major version where the gateway is older (or vice versa), the gateway may reject the handshake with a protocol mismatch. The SDK surfaces this as a fatal error with a descriptive message. Pin both sides to the same minor track during alpha — see Stability for the version policy.

client.off(event, listener) requires the same reference you passed to on. This won’t work:

// BAD — bound function is a new reference each time
client.on('odds:changed', payload => handle(payload))
client.off('odds:changed', payload => handle(payload)) // doesn't remove anything

Save the reference:

const handler = payload => handle(payload)
client.on('odds:changed', handler)
client.off('odds:changed', handler)