Skip to content

Commit e9024db

Browse files
committed
consistent error class ServerError, test suite
ServerError is now the error base class. we've also kept TwirpError as an alias. also added full api smoke test suite
1 parent a9a28f0 commit e9024db

9 files changed

Lines changed: 752 additions & 74 deletions

File tree

livekit-api/livekit/api/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from livekit.protocol.connector_whatsapp import *
3737
from livekit.protocol.connector_twilio import *
3838

39-
from .twirp_client import TwirpError, TwirpErrorCode
39+
from .twirp_client import ServerError, SipCallError, TwirpError, TwirpErrorCode
4040
from .livekit_api import LiveKitAPI
4141
from .access_token import (
4242
InferenceGrants,
@@ -64,6 +64,8 @@
6464
"AccessToken",
6565
"TokenVerifier",
6666
"WebhookReceiver",
67+
"ServerError",
6768
"TwirpError",
6869
"TwirpErrorCode",
70+
"SipCallError",
6971
]

livekit-api/livekit/api/_dial_timeout.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22

33
from typing import Optional, Union
44

5-
from livekit.protocol.connector_whatsapp import AcceptWhatsAppCallRequest
65
from livekit.protocol.sip import CreateSIPParticipantRequest, TransferSIPParticipantRequest
76

8-
# Requests that carry wait_until_answered / ringing_timeout and share the
9-
# phone-dialing timeout behavior.
7+
# Requests that carry ringing_timeout and share the phone-dialing timeout behavior.
108
DialRequest = Union[
119
CreateSIPParticipantRequest,
1210
TransferSIPParticipantRequest,
13-
AcceptWhatsAppCallRequest,
1411
]
1512
"""@private"""
1613

livekit-api/livekit/api/_service.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,22 @@ def __init__(
2020
self._client = TwirpClient(session, host, "livekit", failover=failover)
2121
self.api_key = api_key
2222
self.api_secret = api_secret
23+
# A pre-signed token set by LiveKitAPI for token auth; sent verbatim,
24+
# skipping per-call signing. Per-service constructors stay key/secret-only.
25+
self._token: str | None = None
2326

2427
def _auth_header(
2528
self, grants: VideoGrants | None, sip: SIPGrants | None = None
2629
) -> dict[str, str]:
30+
# A pre-signed token is sent verbatim; the caller is responsible for its grants.
31+
if self._token:
32+
return {AUTHORIZATION: "Bearer {}".format(self._token)}
33+
2734
tok = AccessToken(self.api_key, self.api_secret)
2835
if grants:
2936
tok.with_grants(grants)
3037
if sip is not None:
3138
tok.with_sip_grants(sip)
3239

3340
token = tok.to_jwt()
34-
35-
headers = {}
36-
headers[AUTHORIZATION] = "Bearer {}".format(token)
37-
return headers
41+
return {AUTHORIZATION: "Bearer {}".format(token)}

livekit-api/livekit/api/connector_service.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,17 @@ async def accept_whatsapp_call(
119119
Args:
120120
request: AcceptWhatsAppCallRequest containing call parameters and SDP
121121
timeout: Optional request timeout in seconds. When the request waits
122-
for an answer (wait_until_answered), it defaults to the standard
123-
ring window; set it above the ringing_timeout passed to
124-
dial_whatsapp_call (the two calls are separate, so the SDK can't
125-
derive it).
122+
for the inbound party to join (wait_until_answered), it defaults
123+
to the standard ring window.
126124
127125
Returns:
128126
AcceptWhatsAppCallResponse with the room name
129127
"""
130128
client_timeout: Optional[aiohttp.ClientTimeout] = None
131129
if request.wait_until_answered:
132-
# Accept can block until the call is answered, so default to the
133-
# standard ring window; the caller overrides via timeout.
130+
# Waiting for the inbound party to join can block, so default the
131+
# request timeout to the standard ring window; the caller overrides
132+
# via timeout.
134133
client_timeout = aiohttp.ClientTimeout(
135134
total=timeout if timeout else DEFAULT_RINGING_TIMEOUT
136135
)

livekit-api/livekit/api/livekit_api.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,35 @@ def __init__(
2929
api_key: Optional[str] = None,
3030
api_secret: Optional[str] = None,
3131
*,
32+
token: Optional[str] = None,
3233
timeout: Optional[aiohttp.ClientTimeout] = None,
3334
session: Optional[aiohttp.ClientSession] = None,
3435
failover: bool = True,
3536
):
3637
"""Create a new LiveKitAPI instance.
3738
39+
Authenticate with an API key and secret (recommended for backend use).
40+
For token auth (client-side use, where the API secret must not be
41+
exposed), prefer the :meth:`with_token` constructor.
42+
3843
Args:
3944
url: LiveKit server URL (read from `LIVEKIT_URL` environment variable if not provided)
4045
api_key: API key (read from `LIVEKIT_API_KEY` environment variable if not provided)
4146
api_secret: API secret (read from `LIVEKIT_API_SECRET` environment variable if not provided)
47+
token: Pre-signed access token (read from `LIVEKIT_TOKEN` environment variable if not provided)
4248
timeout: Request timeout (default: 10 seconds)
4349
session: aiohttp.ClientSession instance to use for requests, if not provided, a new one will be created
4450
"""
4551
url = url or os.getenv("LIVEKIT_URL")
52+
token = token or os.getenv("LIVEKIT_TOKEN")
4653
api_key = api_key or os.getenv("LIVEKIT_API_KEY")
4754
api_secret = api_secret or os.getenv("LIVEKIT_API_SECRET")
4855

4956
if not url:
5057
raise ValueError("url must be set")
5158

52-
if not api_key or not api_secret:
53-
raise ValueError("api_key and api_secret must be set")
59+
if not token and (not api_key or not api_secret):
60+
raise ValueError("either token, or api_key and api_secret, must be set")
5461

5562
self._custom_session = True
5663
self._session = session
@@ -60,14 +67,51 @@ def __init__(
6067
timeout = aiohttp.ClientTimeout(total=10)
6168
self._session = aiohttp.ClientSession(timeout=timeout)
6269

63-
self._room = RoomService(self._session, url, api_key, api_secret, failover)
64-
self._ingress = IngressService(self._session, url, api_key, api_secret, failover)
65-
self._egress = EgressService(self._session, url, api_key, api_secret, failover)
66-
self._sip = SipService(self._session, url, api_key, api_secret, failover)
67-
self._agent_dispatch = AgentDispatchService(
68-
self._session, url, api_key, api_secret, failover
69-
)
70-
self._connector = ConnectorService(self._session, url, api_key, api_secret, failover)
70+
# In token mode there is no key/secret; pass empty strings and rely on
71+
# the token, injected into each service below.
72+
key = api_key or ""
73+
secret = api_secret or ""
74+
self._room = RoomService(self._session, url, key, secret, failover)
75+
self._ingress = IngressService(self._session, url, key, secret, failover)
76+
self._egress = EgressService(self._session, url, key, secret, failover)
77+
self._sip = SipService(self._session, url, key, secret, failover)
78+
self._agent_dispatch = AgentDispatchService(self._session, url, key, secret, failover)
79+
self._connector = ConnectorService(self._session, url, key, secret, failover)
80+
81+
if token:
82+
for svc in (
83+
self._room,
84+
self._ingress,
85+
self._egress,
86+
self._sip,
87+
self._agent_dispatch,
88+
self._connector,
89+
):
90+
svc._token = token
91+
92+
@classmethod
93+
def with_token(
94+
cls,
95+
token: str,
96+
url: Optional[str] = None,
97+
*,
98+
timeout: Optional[aiohttp.ClientTimeout] = None,
99+
session: Optional[aiohttp.ClientSession] = None,
100+
failover: bool = True,
101+
) -> "LiveKitAPI":
102+
"""Create a LiveKitAPI authenticated with a pre-signed token.
103+
104+
The token is sent verbatim and must already carry the grants for the calls
105+
it's used with. Since it needs no secret, this is suitable for client-side
106+
use. `url` falls back to the `LIVEKIT_URL` environment variable.
107+
108+
Args:
109+
token: Pre-signed access token
110+
url: LiveKit server URL (read from `LIVEKIT_URL` if not provided)
111+
timeout: Request timeout (default: 10 seconds)
112+
session: aiohttp.ClientSession to use; a new one is created if omitted
113+
"""
114+
return cls(url, token=token, timeout=timeout, session=session, failover=failover)
71115

72116
@property
73117
def agent_dispatch(self) -> AgentDispatchService:

livekit-api/livekit/api/sip_service.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
SIPMediaConfig,
3737
)
3838
from ._service import Service
39+
from .twirp_client import SipCallError, ServerError
3940
from ._dial_timeout import (
4041
dial_timeout as _dial_timeout,
4142
pin_ringing_timeout as _pin_ringing_timeout,
@@ -46,6 +47,14 @@
4647
"""@private"""
4748

4849

50+
def _as_sip_error(err: ServerError) -> ServerError:
51+
"""Surface a SIP dialing failure as a SipCallError so callers can branch on
52+
the SIP status; other failures (auth, validation) are returned unchanged."""
53+
if "sip_status_code" in err.metadata:
54+
return SipCallError.from_server_error(err)
55+
return err
56+
57+
4958
class SipService(Service):
5059
"""Client for LiveKit SIP Service API
5160
@@ -806,14 +815,17 @@ async def create_sip_participant(
806815
if outbound_trunk_config:
807816
create.trunk = outbound_trunk_config
808817

809-
return await self._client.request(
810-
SVC,
811-
"CreateSIPParticipant",
812-
create,
813-
self._auth_header(VideoGrants(), sip=SIPGrants(call=True)),
814-
SIPParticipantInfo,
815-
timeout=client_timeout,
816-
)
818+
try:
819+
return await self._client.request(
820+
SVC,
821+
"CreateSIPParticipant",
822+
create,
823+
self._auth_header(VideoGrants(), sip=SIPGrants(call=True)),
824+
SIPParticipantInfo,
825+
timeout=client_timeout,
826+
)
827+
except ServerError as e:
828+
raise _as_sip_error(e) from None
817829

818830
async def transfer_sip_participant(
819831
self,
@@ -837,20 +849,23 @@ async def transfer_sip_participant(
837849
# timeout doesn't depend on the server's default.
838850
_pin_ringing_timeout(transfer)
839851
client_timeout = aiohttp.ClientTimeout(total=_dial_timeout(timeout, transfer))
840-
return await self._client.request(
841-
SVC,
842-
"TransferSIPParticipant",
843-
transfer,
844-
self._auth_header(
845-
VideoGrants(
846-
room_admin=True,
847-
room=transfer.room_name,
852+
try:
853+
return await self._client.request(
854+
SVC,
855+
"TransferSIPParticipant",
856+
transfer,
857+
self._auth_header(
858+
VideoGrants(
859+
room_admin=True,
860+
room=transfer.room_name,
861+
),
862+
sip=SIPGrants(call=True),
848863
),
849-
sip=SIPGrants(call=True),
850-
),
851-
SIPParticipantInfo,
852-
timeout=client_timeout,
853-
)
864+
SIPParticipantInfo,
865+
timeout=client_timeout,
866+
)
867+
except ServerError as e:
868+
raise _as_sip_error(e) from None
854869

855870
def _admin_headers(self) -> dict[str, str]:
856871
return self._auth_header(VideoGrants(), sip=SIPGrants(admin=True))

livekit-api/livekit/api/twirp_client.py

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,20 @@
2828
origin_of,
2929
pick_next,
3030
)
31+
from .version import __version__
3132

3233
DEFAULT_PREFIX = "twirp"
3334

3435
logger = logging.getLogger("livekit")
3536

37+
# Identifies the SDK and version to the server on every request.
38+
_USER_AGENT = f"livekit-server-sdk-python/{__version__}"
39+
3640
# Shared across all clients in the process so the region list is fetched once.
3741
_REGION_CACHE = RegionCache()
3842

3943

40-
class TwirpError(Exception):
44+
class ServerError(Exception):
4145
def __init__(
4246
self,
4347
code: str,
@@ -66,17 +70,62 @@ def status(self) -> int:
6670

6771
@property
6872
def metadata(self) -> Dict[str, str]:
69-
"""Twirp metadata"""
73+
"""Server-provided error metadata"""
7074
return self._metadata
7175

7276
def __str__(self) -> str:
73-
result = f"TwirpError(code={self.code}, message={self.message}, status={self.status}"
77+
result = f"ServerError(code={self.code}, message={self.message}, status={self.status}"
7478
if self.metadata:
7579
result += f", metadata={self.metadata}"
7680
result += ")"
7781
return result
7882

7983

84+
class SipCallError(ServerError):
85+
"""A :class:`ServerError` from a SIP dialing call (``create_sip_participant`` /
86+
``transfer_sip_participant``) that failed with a SIP response status. The SIP
87+
code and reason are exposed as properties; any other error metadata remains
88+
available via :attr:`metadata`."""
89+
90+
@property
91+
def sip_status_code(self) -> Optional[int]:
92+
"""The SIP response code of the failed call, e.g. 486 (Busy Here)."""
93+
raw = self.metadata.get("sip_status_code")
94+
return int(raw) if raw is not None else None
95+
96+
@property
97+
def sip_status(self) -> Optional[str]:
98+
"""The SIP reason phrase of the failed call, e.g. "Busy Here"."""
99+
return self.metadata.get("sip_status")
100+
101+
@classmethod
102+
def from_server_error(cls, err: ServerError) -> "SipCallError":
103+
return cls(err.code, err.message, status=err.status, metadata=err.metadata)
104+
105+
def __str__(self) -> str:
106+
code = self.metadata.get("sip_status_code")
107+
if code is None:
108+
return super().__str__()
109+
# A clear, SIP-specific representation, including any extra metadata.
110+
reason = self.metadata.get("sip_status")
111+
result = f"SIP call failed: {code}"
112+
if reason:
113+
result += f" {reason}"
114+
result += f" ({self.code})"
115+
extra = {
116+
k: v
117+
for k, v in self.metadata.items()
118+
if k not in ("sip_status_code", "sip_status", "error_details")
119+
}
120+
if extra:
121+
result += " [" + ", ".join(f"{k}={v}" for k, v in extra.items()) + "]"
122+
return result
123+
124+
125+
# Deprecated alias for :class:`ServerError`, kept for backwards compatibility.
126+
TwirpError = ServerError
127+
128+
80129
class TwirpErrorCode:
81130
CANCELED = "canceled"
82131
UNKNOWN = "unknown"
@@ -145,8 +194,9 @@ async def request(
145194
headers intact — against the next untried region, with exponential
146195
backoff. A 4xx is returned immediately."""
147196
path = f"{self.prefix}/{self.pkg}.{service}/{method}"
148-
forward_headers = dict(headers) # for the discovery fetch (no content-type yet)
149197
headers = dict(headers)
198+
headers["User-Agent"] = _USER_AGENT
199+
forward_headers = dict(headers) # for the discovery fetch (no content-type yet)
150200
headers["Content-Type"] = "application/protobuf"
151201
serialized_data = data.SerializeToString()
152202

@@ -183,7 +233,7 @@ async def request(
183233
error_data = {}
184234
if resp.status < 500:
185235
# 4xx is terminal.
186-
raise self._twirp_error(error_data, resp.status)
236+
raise self._server_error(error_data, resp.status)
187237
retryable_status = resp.status
188238
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
189239
transport_exc = e
@@ -200,7 +250,7 @@ async def request(
200250
if next_origin is None:
201251
if transport_exc is not None:
202252
raise transport_exc
203-
raise self._twirp_error(error_data, retryable_status or 500)
253+
raise self._server_error(error_data, retryable_status or 500)
204254

205255
reason = transport_exc if transport_exc is not None else f"status {retryable_status}"
206256
logger.warning(
@@ -216,8 +266,8 @@ async def request(
216266
raise RuntimeError("failover loop exited without returning") # unreachable
217267

218268
@staticmethod
219-
def _twirp_error(error_data: Dict, status: int) -> "TwirpError":
220-
return TwirpError(
269+
def _server_error(error_data: Dict, status: int) -> "ServerError":
270+
return ServerError(
221271
error_data.get("code", "unknown"),
222272
error_data.get("msg", ""),
223273
status=status,

0 commit comments

Comments
 (0)