Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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}
Comment thread
genseric-ghiro marked this conversation as resolved.
if extra:
result += " [" + ", ".join(f"{k}={v}" for k, v in extra.items()) + "]"
return result
Expand Down
38 changes: 38 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,43 @@ 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)
Comment thread
genseric-ghiro marked this conversation as resolved.


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