Resilience & Failure Handling
← Back to Architecture Reference
How KalturaAvatarSession behaves under network failures, disconnects, and device problems. Recovery has three layers. Each escalates to the next:
| Layer | Scope | What the SDK does | Events |
|---|---|---|---|
| 1. Control socket | the websocket only | Socket.IO reconnects with backoff. If the socket state was recovered, the session goes straight back to connected. If not, the SDK does a cold reconnect (layer 3). The reconnecting state is bounded by reconnectWindowMs (default 22000). |
connectivityChanged (channel:'socket'), reconnecting, reconnected |
| 2. Media peers (ASR + STV) | one peer connection at a time | Watches ICE state, peer state and incoming video. Recovers in place first. If that fails, escalates to layer 3. | connectivityChanged, mediaRecovering, mediaRecovered |
| 3. Cold reconnect | the whole conversation | Opens a new socket, re-joins, and rebuilds both media peers. Replays threadId so the conversation memory continues. Tries twice before it gives up. |
reconnecting, reconnected |
A non-recoverable failure ends the session cleanly (ended, or an error such as reconnect_failed or reconnect_timeout). It never hangs.
A custom client that does not use KalturaAvatarSession must implement these layers itself. The rest of this page describes what the SDK does.
Control socket
maxReconnectAttempts(default 5) caps Socket.IO's own attempts. It also appears asattempt/maxAttemptsonreconnectingandconnectivityChanged.- Recoverable disconnect reasons:
transport error,transport close,forced close,ping timeout. Any other reason ends the session. reconnectWindowMs(default 22000) bounds the wholereconnectingstate. If nothing recovers in that time, the session ends withreconnect_timeout. If Socket.IO runs out of attempts first, it ends withreconnect_failed.- After a reconnect where the socket state was not recovered, the old session is gone. The SDK runs a cold reconnect (layer 3).
- After a reconnect where it was recovered, the SDK also checks both media peers. A peer stuck in a down ICE state goes through layer 2.
Media peers (ICE watch)
| ICE state | SDK action |
|---|---|
disconnected |
Waits 1.5 s. If it has not returned to connected, recovers. |
failed |
Recovers at once. |
new or checking for 10 s, or gathering ends with no candidates |
Recovers. |
connected / completed |
Clears the recovery flag. |
The SDK emits mediaRecovering { channel, state, cause } when recovery starts and mediaRecovered { channel, method } when it works. Every ICE state change also emits connectivityChanged { channel, state }.
| Channel | In-place recovery (method) |
Details |
|---|---|---|
| ASR (mic uplink) | ice-restart |
Restarts ICE on the same peer and re-offers over the socket with is_reconnect: true. Mute state is kept. Waits up to 30 s for the answer. |
| STV (avatar video) | re-subscribe |
Waits for the DELETE of the old WHEP resource, then sends a new WHEP offer. A 404 or 409 first asks for a new avatar session on the live socket (details). Then it resumes playback if the browser paused the element. |
If in-place recovery fails, the SDK emits connectivityChanged with state:'recover_failed' and does a cold reconnect. timeouts.recover (15 s) bounds the STV re-subscribe. The ASR restart waits up to 30 s for its answer. Each channel has its own grace timer, so the two peers recover independently.
The same recovery runs when the browser fires online after an offline and a peer is still in a down ICE state. Both events also emit connectivityChanged with channel:'network'. If the control socket is down while reconnecting, online makes it retry at once instead of waiting out its backoff. Turn this off with networkAware:false.
ICE diagnostics
Each peer keeps a safe summary of what it gathered and what its STUN and TURN servers answered. No address, port or credential is stored. cause names the most likely reason a path is not up:
cause |
Meaning | What to do |
|---|---|---|
connected |
The media path is up | Nothing |
turn_credentials_rejected |
The relay refused the credentials and no relay address was allocated | Check the TURN settings the session was given |
turn_unreachable |
No relay address could be allocated | Allow outbound UDP and TCP on ports 80 and 443 to the relay host, or use another network |
no_candidates |
The browser found no network address | Check the device is online, a VPN or privacy setting is not hiding its interfaces, and outbound UDP and TCP on ports 80 and 443 to the relay host are allowed |
no_route |
Addresses exist but the media server was not reached | Try again, or use another network |
closed |
The peer was closed (for example after disconnect()) |
Nothing |
still_gathering |
The browser was still looking for a path | Try again on a faster network |
await session.getIceReport() returns { asr, stv }. Each is null before that peer exists, or an IceReport: channel, the three peer states, candidates by type (host, srflx, prflx, relay), errors (code, text, scheme), cause, hint and selectedPair (localType, remoteType, protocol, or null). It never rejects and is safe to log or attach to a support ticket.
connect() fails with ice_failed (phase: 'ice', retryable: true, error.ice is the same summary) when the avatar peer never reaches ICE connected. This holds with or without a <video> element and with or without a frame callback: connect() never resolves while ICE is down. ICE failed ends the wait at once. Otherwise the wait ends at timeouts.firstFrame. The first miss re-subscribes once. The second miss rejects. A peer that is connected but shows no frame is a different case: connect() resolves with media_no_video. An ASR peer that fails while connect() is still running is recovered as soon as the session is connected.
Peer health watchdog
A peer can die without an ICE event: closing it from outside fires nothing, and a stalled sender keeps ICE connected while no video arrives. While the session is connected (not paused or released), a check runs every timeouts.healthTick (1 s):
| Check | Recovers when |
|---|---|
| Peer state | A peer is failed or closed |
| Video flow (STV) | The incoming video bytes have not grown for timeouts.videoStall (4 s) |
Both go through the same recovery as an ICE failure and emit the same events. A peer that reports no video receiver is not judged. A sender that stays stalled gets two re-subscribes in a row. If video still does not flow, the next stall does a cold reconnect. The watchdog stops on disconnect().
Cold reconnect retries
A cold reconnect makes up to timeouts.coldAttempts (2) attempts, timeouts.coldBackoff (500 ms) apart. A failed first attempt emits connectivityChanged { channel:'socket', state:'reconnect_retry' }. When the last attempt fails, the session emits error and then ended, both with reconnect_failed (phase: 'reconnect', retryable: true). error.cause holds the last attempt's error. disconnect() during the backoff cancels the retry.
Recovery events
| Event | Payload |
|---|---|
reconnecting |
{reason, attempt, maxAttempts}; cold: true when the SDK rebuilds the session |
reconnected |
{recovered}: true when the socket kept its state, false after a cold reconnect |
connectivityChanged |
{channel, state} plus reason, attempt, maxAttempts (socket drop) or detail (recover_failed, reconnect_retry). channel is asr, stv, socket or network. state is the ICE state, connected, disconnected, offline, online, recover_failed or reconnect_retry |
Timeouts
All keys go in cfg.timeouts of a KalturaAvatarSession. In a KalturaAgentSession, put it inside the avatar option. Unset keys keep the default.
| Key | Default | Meaning |
|---|---|---|
whepTry, whepTries, whepBackoff |
5000 ms, 3, 1000 ms | WHEP POST per-try deadline, tries, wait between tries |
whepRelease |
3000 ms | WHEP DELETE deadline |
recover |
15000 ms | One media recovery |
healthTick, videoStall |
1000 ms, 4000 ms | Watchdog interval, video stall limit |
iceStuck |
10000 ms | A peer still in ICE new or checking this long is recovered |
coldAttempts, coldBackoff |
2, 500 ms | Cold reconnect attempts, wait between them |
The connect-time keys (overall, joinRoom, and the rest) are in Connection & Handshake.
Device permissions (mic)
connect() starts getUserMedia({audio}) in the background (micStartMode: 'immediate', the default). It asks for audio only. The avatar does not need your camera.
- A denied, missing or busy mic does not fail
connect(). The SDK emits onewarningand connects without a mic.speak()still works, andstartMic()retries. startMic()throws the same codes.micStartMode:'deferred'skips the automatic prompt. CallstartMic()from a user gesture.
| Browser error | SDK code |
|---|---|
NotAllowedError, SecurityError |
mic_permission_denied |
NotFoundError, OverconstrainedError |
mic_not_found |
NotReadableError, AbortError |
mic_in_use |
| anything else | devices_permission_denied |
Failure-mode matrix
| Failure | Detected by | SDK handling |
|---|---|---|
| User denies mic permission | getUserMedia rejects |
warning with mic_permission_denied. The session connects without a mic. |
| No mic / mic busy | getUserMedia rejects |
warning with mic_not_found or mic_in_use. |
| ASR/STV peer drops | ICE state | In-place recovery, then cold reconnect. |
| Peer closed or failed with no ICE event, or video stops arriving | Health watchdog | In-place recovery, then cold reconnect. |
STV session gone (WHEP 404 or 409) |
WHEP status | One new avatar session on the live socket, then a new POST. A second 404 or 409 is stv_session_gone. |
| WHEP POST gets no answer | Per-try deadline | Up to 3 tries. Then whep_timeout or whep_failed. |
| Cold reconnect attempt fails | The attempt rejects | A second attempt. Then reconnect_failed. |
| Tab closed or navigated away | pagehide |
Page exit. |
| Control socket transient drop | Socket.IO disconnect (recoverable reason) |
reconnecting, then reconnected or cold reconnect, within reconnectWindowMs. |
| Control socket permanent drop | Socket.IO disconnect (other reason), reconnect_failed |
Session ends. |
| All agent slots busy | throwToNoAgent |
Availability poll. See Scale & Sticky Sessions. |
| Plan/tier exceeded | throwToExceededTier |
Fails with tier_exceeded. |
| Connect hangs | Per-step timeouts and the 30 s deadline | connect() rejects with timeout and error.phase. See the connect sequence. |
| Tool reply gets no answer | 15 s deadline | respondToTool() returns {ok:false, reason:'timeout'}. The call stays pending, so you can retry. The first reply may still have arrived, so make the tool's side effect safe to repeat. |
| Agent stalls mid-conversation | Watchdog | brainStalled, repeating every brainStallMs (default 12000) until output lands. |
| Tool-call spiral (same command retried with no narration) | Two-tier circuit breaker | Soft signal (toolSpiralDetected), then a hard cold reconnect. See Tool-call spiral. |
| Tab backgrounded / network change | online/offline/visibilitychange listeners |
Media recovery as above. See Session completion. |
Tool-call spiral
An agent can retry the same client command again and again instead of narrating. KalturaAvatarSession breaks the loop in two steps. KalturaChatSession has the first step only, because a chat turn has no socket to rebuild.
| Step | Trigger | Event | SDK action |
|---|---|---|---|
| Soft | toolSpiralLimit (default 10) tool segments in one turn |
toolSpiralDetected {count, limit} |
Signal only. The SDK does not interrupt the turn. |
| Hard | hardToolSpiralLimit (default toolSpiralLimit * 3) tool segments since the last spoken or GenUI output |
toolSpiralRecovering {count, limit, lastTurnText} |
Cold reconnect. threadId is replayed, so the conversation memory continues. |
| Resend | The cold reconnect succeeds and recoverFromSpiral is true (default) |
spiralRecovered {text} |
The SDK resends the abandoned turn once, with a short nudge prefix. |
Set recoverFromSpiral:false to resend the turn yourself from lastTurnText. Set any of the three limits to 0 to turn that step off. Options and a code sample: README § Resilience: agent stalls and tool-call spirals. Prompt-side prevention: Client-Side Commands.
Session completion
All three session classes send session_completed when a conversation truly ends: disconnect(), idle logoff, tab close, or a long background. Triggers, options and the cross-tab rule: README § Ending a conversation cleanly. Request shape: Conversation & Analytics § Session-Completion Signal.
Page exit
A pagehide that is not a bfcache freeze runs disconnect(). The session sends session_completed first, then DELETEs the WHEP resource with keepalive so the request outlives the page, closes both peers and closes the socket. It is installed when connect() starts, so a tab closed mid-connect is cleaned up too. A request already in flight is aborted, and a DELETE can only name a resource whose answer has arrived. The browser decides whether a request sent while a page closes completes, so the release is best effort. A pagehide with persisted:true (bfcache freeze) leaves the session alone. Turn the whole page-lifecycle handling off with pageLifecycleAware:false.
Related docs
| Doc | Covers |
|---|---|
| Architecture Reference · Scale and Sticky Sessions | throwToNoAgent/throwToExceededTier and the availability queue |
| Architecture Reference · Connection and Handshake | Endpoints & Credentials, TURN/relay policy |
| Architecture Reference | Back to the index |