lively-horse-90722
04/17/2026, 7:21 AMtall-airline-16756
04/17/2026, 7:21 AMtall-airline-16756
04/17/2026, 7:22 AMlively-horse-90722
04/17/2026, 7:26 AMresponse = await self._ctx.api.sip.transfer_sip_participant(
transfer=TransferSIPParticipantRequest(
participant_identity=par_identity,
room_name=room_name,
transfer_to="tel:" + phone_number,
)
)tall-airline-16756
04/17/2026, 7:27 AMget_participant() for participant_identity and verify active state.
1. Implement idempotent retry behavior:
◦ On timeout, poll status with exponential backoff (e.g., 1s, 2s, 4s) up to a limit.
◦ If status shows a pending transfer, either wait for completion or call the cancel/cleanup flow (if your integration exposes one) before issuing a new transfer.
1. Log the first transfer request/response and server-side task state (CreateSIPParticipant / MoveParticipant) to confirm whether cleanup is needed when timeouts occur.
If you’d like, I can:
• Suggest exact Python code for adding timeout, polling participant/transfer status, and exponential backoff.
• Review logs you can paste (redact sensitive data) to pinpoint whether the server completed the first transfer. Which would you prefer? Sources: The Human-in-the-Loop (HITL) Pattern for Voice Agents | LiveKit | Agents telephony integration | LiveKit Documentation | Python SDKs | livekit/livekit_composite | DeepWiki | server-sdk-ruby | livekit/livekit_composite | DeepWiki | Understanding the STATE_MISMATCH Disconnect Reason, with interactive elementslively-horse-90722
04/17/2026, 7:29 AMtall-airline-16756
04/17/2026, 7:29 AMdeadline_exceeded and invalid_argument.
Adjust names/paths to match your SDK client.
import asyncio
import math
from typing import Optional
from twirp import TwirpError # or where TwirpError is defined in your env
from livekit_sdk import TransferSIPParticipantRequest # adjust import to your SDK
# Configuration
INITIAL_TIMEOUT_SECONDS = 8 # request timeout for transfer
MAX_RETRIES = 4
BASE_BACKOFF_SECONDS = 1.0
MAX_BACKOFF_SECONDS = 8.0
POLL_INTERVAL_SECONDS = 2.0 # when polling transfer/participant state
POLL_TIMEOUT_SECONDS = 20.0 # max time to poll after a timeout
async def is_participant_active(api, room_name: str, participant_identity: str) -> bool:
try:
p = await api.rooms.get_participant(room_name=room_name, identity=participant_identity)
# adapt check to your participant object shape
return getattr(p, "is_active", True) # replace with real property
except TwirpError:
return False
async def poll_transfer_settled(api, room_name: str, participant_identity: str, timeout: float) -> bool:
"""Poll until participant no longer in 'in-transfer' state or until timeout."""
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
try:
p = await api.rooms.get_participant(room_name=room_name, identity=participant_identity)
# Replace these checks with your SDK's fields that indicate transfer/pending state:
in_transfer = getattr(p, "is_in_transfer", False) # example placeholder
if not in_transfer:
return True
except TwirpError:
# participant may have disappeared — treat as settled (or return False if you prefer)
return False
await asyncio.sleep(POLL_INTERVAL_SECONDS)
return False
def backoff_delay(attempt: int) -> float:
# exponential backoff with jitter
exp = min(MAX_BACKOFF_SECONDS, BASE_BACKOFF_SECONDS * (2 ** (attempt - 1)))
jitter = exp * 0.1 * (2 * (random.random() - 0.5)) # +-10% jitter
return max(0.1, exp + jitter)
import random
async def transfer_with_retries(api, room_name: str, participant_identity: str, phone_number: str):
# verify participant is present
if not await is_participant_active(api, room_name, participant_identity):
raise RuntimeError("participant not active in room")
last_error: Optional[Exception] = None
for attempt in range(1, MAX_RETRIES + 1):
try:
# wrap the transfer call with a timeout
coro = api.sip.transfer_sip_participant(
transfer=TransferSIPParticipantRequest(
participant_identity=participant_identity,
room_name=room_name,
transfer_to="tel:" + phone_number,
)
)
response = await asyncio.wait_for(coro, timeout=INITIAL_TIMEOUT_SECONDS)
# success
return response
except TwirpError as e:
last_error = e
code = getattr(e, "code", None)
msg = getattr(e, "message", str(e))
# If request timed out at transport layer, Twirp may return deadline_exceeded
if code == "deadline_exceeded":
# poll to see whether server completed the transfer despite timeout
settled = await poll_transfer_settled(api, room_name, participant_identity, POLL_TIMEOUT_SECONDS)
if settled:
# transfer completed server-side — don't retry a new transfer
raise RuntimeError("transfer completed on server despite client timeout") from e
# else, safe to retry after backoff
elif code == "invalid_argument" and "already being transferred" in msg:
# server indicates an active/pending transfer — poll until cleared or fail
settled = await poll_transfer_settled(api, room_name, participant_identity, POLL_TIMEOUT_SECONDS)
if not settled:
# still in transfer after polling — either wait longer or fail
raise RuntimeError("call is already being transferred elsewhere; still pending after polling") from e
# cleared — retry
else:
# non-transient TwirpError — abort
raise
except asyncio.TimeoutError:
# local timeout from asyncio.wait_for
last_error = asyncio.TimeoutError("local client timeout")
# poll server to see if transfer completed
settled = await poll_transfer_settled(api, room_name, participant_identity, POLL_TIMEOUT_SECONDS)
if settled:
raise RuntimeError("transfer completed on server despite client timeout")
# otherwise will proceed to retry/backoff below
# backoff before next attempt
delay = backoff_delay(attempt)
await asyncio.sleep(delay)
# exhausted retries
raise RuntimeError("transfer failed after retries") from last_error
Notes and adjustments you should make:
• Replace get_participant and participant property checks with the actual method and fields your SDK exposes to detect active/pending-transfer state.
• Tune INITIAL_TIMEOUT_SECONDS, POLL_TIMEOUT_SECONDS, and backoff settings to your environment.
• If your integration exposes an explicit cancel/cleanup API for a pending transfer, call that when you detect a stuck transfer instead of continuing to poll.
If you want, I can adapt this snippet to the exact method names and participant fields from your SDK — paste the get_participant response shape or the SDK method names you use and I’ll update it.
, with interactive elements