Reference

Resilience & Failure Handling

← Back to System Internals Reference

How the system behaves under network failures, disconnects, and device problems. There are three reconnection tiers, only loosely coordinated:

Tier Layer Auto-recovers? Scope
1. Socket.IO transport control socket ✅ built-in (backoff + jitter + state recovery) the websocket only
2. WebRTC peer (ASR + STV) the built-in client's media layer ✅ 5 attempts × 2s, independent per channel the media peer connections
3. Avatar session this SDK (KalturaAvatarSession) ✅ socket-transport recovery: a recoverable drop → reconnecting → reconnected (same-instance, ≤~20s, no re-join); non-recoverable → clean ended. the whole conversation

The headline risk: tiers 2 and 3 are not wired together for custom non-SDK clients. The SDK wires them via _recoverMedia → _coldReconnect. When the WebRTC layer exhausts retries and emits 'failed', a custom client that does not use the SDK's KalturaAvatarSession must handle this itself.

Device permissions (mic/camera)

connecting → gettingUserMedia calls getUserMedia(audio:true, video:false) on the built-in client's media layer. This is audio only by default; the avatar doesn't need your camera. On denial, the runtime client's device-media handler routes to error with reason: DevicesPermissionDenied, skipDisconnect:true, suppressNotification:true (and shouldPurge=false). This is a clean, retryable abort with no scary toast. The SDK (KalturaAvatarSession) surfaces distinct NotAllowed/NotFound/NotReadable codes; the platform's built-in client's classification is coarse (no NotAllowedError vs NotFoundError vs NotReadableError distinction). No pre-flight navigator.permissions.query, no mid-call device-loss handling.

WebRTC media peer (the built-in client's media layer)

Control socket & session machine (the platform's built-in client)

Failure-mode matrix

Failure Detected by Handling today
User denies mic permission getUserMedia throws Clean abort, no toast, retry possible
No mic / mic busy getUserMedia throws Same generic path (not distinguished)
ASR/STV peer drops the built-in client's media layer ICE state 5× re-join @ 2s; SDK handles via _onIceStateChange; the platform's built-in client's wrapper leaves failed event unhandled
STV server session gone (404) WHEP status Give up; app must recreate session
Control socket transient drop Socket.IO disconnect Socket.IO auto-recovers… but the runtime client's error handler may also tear down
Control socket permanent drop Socket.IO disconnect (!active) Teardown + "reconnect" notification
All agent slots busy throwToNoAgent Availability queue + poll (see Scale & Sticky Sessions)
Plan/tier exceeded throwToExceededTier Fatal, clear message
Connect hangs SDK: setTimeout (TIMEOUTS constants); the platform's built-in client: internal state-machine timeouts 5–30s timeouts → error (well covered)
Player/video element error onPlayerError chain → Disconnect (PlayerConnectionFailed)
Brain stalls mid-conversation KalturaAvatarSession watchdog brainStalled event, repeating every brainStallMs until output lands; the platform's built-in client has no liveness timeout
Tool-call spiral (same command retried with no narration) KalturaAvatarSession two-tier circuit breaker Soft signal (toolSpiralDetected) plus hard cold-reconnect recovery, see Tool-call spiral: what happened and how it's mitigated below
Tab backgrounded / network change KalturaAvatarSession online/offline/visibilitychange handling in SDK; the platform's built-in client does not handle these events

Tool-call spiral: what happened and how it's mitigated

A tool-eager brain can retry the same client command dozens or hundreds of times in one turn instead of narrating. KalturaAvatarSession defends against this with a two-tier circuit breaker.

Soft tier: signal only. Once a turn accumulates toolSpiralLimit (default 10) raw type:"tool" segments, counted before dedup since a spiral is the same call repeating, the SDK emits toolSpiralDetected once. The soft tier only signals. It never calls interrupt(): interrupting a spiral already running server-side has no effect on it, and can truncate the turn's own narration.

A legitimate turn can double its raw tool-segment count when speak()'s barge-in branch (still-playing TTS audio from a prior turn) spawns a parallel tap-to-talk stream for the same question. For example, a 3-tool turn duplicates into 6 raw segments this way. The default limit of 10 is high enough to absorb that duplication without tripping the breaker on an ordinary turn, not a real spiral.

Hard tier: the actual fix. A session-scoped hard counter (hardToolSpiralLimit, default toolSpiralLimit * 3) counts raw tool segments since the last perceivable output. It is immune to turn-boundary resets, so an idle wake-up nudge mid-spiral cannot hide it. Once it's crossed, the SDK emits toolSpiralRecovering (carrying lastTurnText, the abandoned turn) and forces _coldReconnect(). This is the same full media rebuild already used for a dead media channel, replaying threadId so brain memory continues. This turns the eventual uncontrolled JoinRoomTimeout into a deliberate, bounded, self-healing reconnect.

Because the control socket is still live at this point (unlike a genuine transport drop), _coldReconnect() opens a brand-new socket rather than re-join-ing the still-connected one: re-sending join on an already-live socket is a silent no-op server-side. _coldReconnect() detects this case (this.state !== 'reconnecting' at entry means the socket never actually dropped) and opens a genuinely new socket via the same factory connect() uses, before re-join-ing on it. The one path that safely reuses the existing socket is the genuine-transport-disconnect case, reached only after a real drop already set state to 'reconnecting'. There, the prior session is already gone, so re-join-ing it is not a no-op.

The hard guard re-arms on a successful cold reconnect, not just on perceivable output. A spiral by definition never produces spoken or GenUI content, so that's the only reset path that can actually fire while one is running. Without this re-arm, a second spiral later in the same session would find the guard permanently latched from the first recovery, and would hang indefinitely instead of recovering.

A cold reconnect restores connectivity and brain memory (threadId) but otherwise abandons the turn that triggered it. With recoverFromSpiral (default true), the SDK auto-resends that turn's tracked text once, from speak() or ASR's userTranscription. It prefixes the resend with SPIRAL_RECOVERY_PREFIX (the same nudge used on the headless Conversations#send({recoverFromSpiral:true}) path), and emits spiralRecovered {text}. recoverFromSpiral:false suppresses the resend and leaves it to the app via lastTurnText. All three thresholds (brainStallMs, toolSpiralLimit, hardToolSpiralLimit) are configurable at construction; 0 disables any of them. The platform's built-in client has no such breaker. Author-side mitigation (a tool-call budget in the system prompt) and the headless-path equivalent are covered in Client-Side Commands's "Tool spirals starve the voice". This section documents only the SDK's own recovery mechanism.

KalturaChatSession (the HTTP text transport) ports the soft tier only: cfg.toolSpiralLimit (default 10, same counting rule) emits the same toolSpiralDetected {count, limit} once per turn. There's no hard tier here. A chat turn is one stateless HTTPS request with no socket to cold-reconnect, so a stuck turn is bounded by the caller's own sendText({signal}) abort, not by a session-level recovery mechanism.

Session-completion signal (session_completed): telling the backend a conversation is truly over

Without this, the backend only learns a thread is done when its idle timeout fires (about 10 minutes by default). End-of-conversation lifecycle rules (summaries, insights, CRM pushes) then fire minutes late. A closed tab looks identical to a user who just walked away.

KalturaAvatarSession, KalturaChatSession, and KalturaAgentSession all POST {genieUrl}/thread/session_completed ({"id":"<threadId>"}, the same conversation KS as every other client call) the moment a conversation genuinely ends. This includes tab-close, backgrounding, and bfcache freeze. It never fires on an internal transition like a mode switch, and never ends a thread another tab is still using. Full config surface: README.md § Ending a conversation cleanly. Wire shape: Wire Protocol · Events Catalog § Session-completion signal.

Trigger Fires? Why
App calls disconnect() / stop() yes Unambiguous hangup
Idle auto-logoff yes Real end of session
pagehide (tab/window closed, navigated away) yes The primary win over the idle-timeout fallback
pagehide with persisted:true (bfcache freeze) yes, by default The SDK can't survive the freeze anyway: media/socket are already torn down
Hidden longer than hiddenGraceMs (default 30s) yes, by default Catches iOS Safari / Chrome Android tab-kills where pagehide never fires
Server ends the conversation (conversationEnded) no, by default The backend already knows; re-signaling wastes a redundant lifecycle-rule evaluation
KalturaAgentSession.switchMode() tearing down the old transport no Thread continuity is the entire point of switching modes
Fatal/unrecoverable error (_endWith()) no An error isn't a clean end; the app may reconnect and continue the same thread
A second tab on the same thread is still alive (crossTabPresence, same-origin/same-device only via BroadcastChannel) no, suppressed Avoids ending a thread another tab is actively using; the last tab standing still fires

The signal is idempotent: a repeat POST for the same thread is a server-side no-op. It is never awaited on the unload path; the SDK uses fetch(url, {keepalive:true}), not navigator.sendBeacon, because sendBeacon can't carry the Authorization header. Cross-device duplicate tabs are out of scope by design (BroadcastChannel is same-origin/same-device only). The backend's own self-healing on the next real message covers that case.

What's already solid (don't regress)

Doc Covers
System Internals Reference · Scale and Sticky Sessions throwToNoAgent/throwToExceededTier and the availability queue
System Internals Reference · Connection and Handshake Endpoints & Credentials, TURN/relay policy
System Internals Reference Back to the index
Click to talk with Nova — she knows this whole SDK.
Nova AI assistant — knows this whole site

Reloading starts a fresh chat. “New conversation” does the same without leaving the drawer.