Why does my 2nd transfer always fails. Also I atte...
# ask-ai
l
Why does my 2nd transfer always fails. Also I attempt 2nd transfer with couple of seconds Error from 1st transfer: Error transferring call: TwirpError(code=deadline_exceeded, message=twirp error unknown: request timed out, status=408)" Error from 2nd transfer: Error transferring call: TwirpError(code=invalid_argument, message=twirp error unknown: call already being transferred elsewhere, status=400)
✅ 1
t
Thanks for reaching out, Deepak. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I’m looking at the two errors you shared. Quick summary of what’s happening and what to try next. What’s happening • The first transfer times out (TwirpError code=deadline_exceeded / 408). Even though it timed out for your client, the transfer process on the server side likely started and did not fully cancel. • Because the system still considers the call "in transfer," the second attempt is rejected with invalid_argument / "call already being transferred elsewhere" (400). LiveKit prevents concurrent/overlapping transfers, so a timed-out request can leave the call in a pending-transfer state. What to try next 1. Add a short delay before retrying (e.g., 5–10s) so the server-side transfer workflow can settle. 2. Increase the transfer request timeout so the first attempt has more time to complete and is less likely to leave a pending state. 3. Check transfer state before retrying: ◦ Confirm whether the call/participant is marked as in-transfer (your API or task status for the WarmTransferTask). If it is, wait or cancel that transfer before issuing a new one. 1. Implement an idempotent flow: ◦ If a transfer request times out, poll the transfer status instead of immediately issuing a new transfer. ◦ If the status shows a pending transfer, either wait for completion or call the cancel/cleanup path (if available) before retrying. 1. If you can, capture logs around the first transfer (request + server-side task state) to see whether CreateSIPParticipant/MoveParticipant completed or errored. That helps confirm whether cleanup is needed. If you want, tell me: • which API/SDK and timeout you’re using, • whether you can read transfer/task status from your code, and I’ll suggest exact changes (where to poll, how long to wait, or how to increase the timeout). Sources: WarmTransferTask | LiveKit Documentation | The Human-in-the-Loop (HITL) Pattern for Voice Agents | LiveKit, with interactive elements
l
using Python SDK. Transfer code:
Copy code
response = await self._ctx.api.sip.transfer_sip_participant(
                transfer=TransferSIPParticipantRequest(
                    participant_identity=par_identity,
                    room_name=room_name,
                    transfer_to="tel:" + phone_number,
                )
            )
t
Thanks — that helps. You’re seeing the first request time out (deadline_exceeded / 408) while the server-side transfer workflow likely started and remained in a pending state, so the second request is rejected with "call already being transferred elsewhere" (invalid_argument / 400). Below are targeted, actionable steps you can apply in your Python code. What to do next 1. Increase the request timeout so the transfer has more time to finish (reduce chance of leaving a pending state). 2. After a timeout, don't immediately re-try. Instead: ◦ Wait a short period (5–10s) before retrying, or ◦ Poll the transfer/participant status to see if the transfer completed or is still pending. 1. Check the participant is still in the room before calling transfer: ◦ Call
get_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 elements
l
Suggest exact Python code for adding timeout, polling participant/transfer status, and exponential backoff.
t
Thanks — below is an async Python example that shows: • setting a request timeout, • verifying the participant is active before transfer, • polling participant/transfer status after a timeout, • retrying with exponential backoff and handling TwirpError codes
deadline_exceeded
and
invalid_argument
. Adjust names/paths to match your SDK client.
Copy code
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