Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions livekit-api/livekit/api/sip_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@


def _as_sip_error(err: ServerError) -> ServerError:
"""Surface a SIP dialing failure as a SipCallError so callers can branch on
the SIP status; other failures (auth, validation) are returned unchanged."""
if "sip_status_code" in err.metadata:
"""Surface a SIP dialing or transfer failure as a SipCallError so callers can
branch on the SIP status or the transfer reason; other failures (auth,
validation) are returned unchanged."""
if "sip_status_code" in err.metadata or "sip_transfer_reason" in err.metadata:
return SipCallError.from_server_error(err)
return err

Expand Down
47 changes: 34 additions & 13 deletions livekit-api/livekit/api/twirp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,20 @@ def __str__(self) -> str:
return result


_SIP_META_KEYS = (
"sip_status_code",
"sip_status",
"sip_transfer_reason",
"sip_transfer_id",
"error_details",
)


class SipCallError(ServerError):
"""A :class:`ServerError` from a SIP dialing call (``create_sip_participant`` /
``transfer_sip_participant``) that failed with a SIP response status. The SIP
code and reason are exposed as properties; any other error metadata remains
``transfer_sip_participant``) that failed with a SIP response status, or a
transfer that failed for any other reason. The SIP code and reason, and the
transfer reason, are exposed as properties; any other error metadata remains
available via :attr:`metadata`."""

@property
Expand All @@ -109,25 +119,36 @@ def sip_status(self) -> Optional[str]:
"""The SIP reason phrase of the failed call, e.g. "Busy Here"."""
return self.metadata.get("sip_status")

@property
def sip_transfer_reason(self) -> Optional[str]:
"""Why a transfer failed, e.g. "STR_RINGING_TIMEOUT". Only set for
``transfer_sip_participant``."""
return self.metadata.get("sip_transfer_reason")

@property
def sip_transfer_id(self) -> Optional[str]:
"""The id of the failed transfer, for matching against SIP transfer logs."""
return self.metadata.get("sip_transfer_id")

@classmethod
def from_server_error(cls, err: ServerError) -> "SipCallError":
return cls(err.code, err.message, status=err.status, metadata=err.metadata)

def __str__(self) -> str:
code = self.metadata.get("sip_status_code")
if code is None:
transfer_reason = self.metadata.get("sip_transfer_reason")
if code is None and transfer_reason is None:
return super().__str__()
# A clear, SIP-specific representation, including any extra metadata.
reason = self.metadata.get("sip_status")
result = f"SIP call failed: {code}"
if reason:
result += f" {reason}"
result += f" ({self.code})"
extra = {
k: v
for k, v in self.metadata.items()
if k not in ("sip_status_code", "sip_status", "error_details")
}
parts = []
if transfer_reason is not None:
parts.append(transfer_reason)
if code is not None:
reason = self.metadata.get("sip_status")
parts.append(f"{code} {reason}" if reason else str(code))
what = "SIP transfer failed" if transfer_reason is not None else "SIP call failed"
result = f"{what}: {', '.join(parts)} ({self.code})"
extra = {k: v for k, v in self.metadata.items() if k not in _SIP_META_KEYS}
if extra:
result += " [" + ", ".join(f"{k}={v}" for k, v in extra.items()) + "]"
return result
Expand Down
42 changes: 42 additions & 0 deletions tests/api/test_livekitapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

import livekit.api as api
from livekit.api import SipCallError, ServerError
from livekit.api.sip_service import _as_sip_error
from livekit.protocol.rtc import SessionDescription

BASE = os.getenv("LK_TEST_SERVER_URL", "http://127.0.0.1:9999")
Expand Down Expand Up @@ -564,6 +565,47 @@ def test_sip_no_answer():
assert err.sip_status_code == 408


# -- transfer failures surface the reason -------------------------------------


def _transfer_error(**metadata: str) -> ServerError:
return _as_sip_error(
ServerError(
"deadline_exceeded", "call transfer failed", status=408, metadata=metadata
)
)


def test_sip_transfer_reason():
err = _transfer_error(
sip_transfer_reason="STR_RINGING_TIMEOUT", sip_transfer_id="STR_abc"
)
assert isinstance(err, SipCallError)
assert err.sip_transfer_reason == "STR_RINGING_TIMEOUT"
assert err.sip_transfer_id == "STR_abc"
# No SIP response was involved in this failure.
assert err.sip_status_code is None
assert "STR_RINGING_TIMEOUT" in str(err)


def test_sip_transfer_rejected_reports_reason_and_status():
err = _transfer_error(
sip_transfer_reason="STR_REJECTED",
sip_status_code="486",
sip_status="Busy Here",
)
assert err.sip_transfer_reason == "STR_REJECTED"
assert err.sip_status_code == 486
assert err.sip_status == "Busy Here"
assert "STR_REJECTED" in str(err)
assert "486" in str(err) and "Busy Here" in str(err)


def test_non_sip_error_is_unchanged():
err = ServerError("unauthenticated", "bad token", status=401)
assert _as_sip_error(err) is err


# -- cross-cutting: client-side dial timeout ----------------------------------


Expand Down
Loading