Skip to content

Commit 357e5db

Browse files
authored
consistent error class ServerError, test suite (#744)
1 parent b792fb7 commit 357e5db

9 files changed

Lines changed: 785 additions & 77 deletions

File tree

livekit-api/livekit/api/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@
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 (
40+
ServerError,
41+
ServerErrorCode,
42+
SipCallError,
43+
TwirpError,
44+
TwirpErrorCode,
45+
)
4046
from .livekit_api import LiveKitAPI
4147
from .access_token import (
4248
InferenceGrants,
@@ -64,6 +70,9 @@
6470
"AccessToken",
6571
"TokenVerifier",
6672
"WebhookReceiver",
73+
"ServerError",
74+
"ServerErrorCode",
6775
"TwirpError",
6876
"TwirpErrorCode",
77+
"SipCallError",
6978
]

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: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,28 +29,41 @@ 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")
46-
api_key = api_key or os.getenv("LIVEKIT_API_KEY")
47-
api_secret = api_secret or os.getenv("LIVEKIT_API_SECRET")
52+
53+
# Only fall back to environment credentials when none were provided
54+
# explicitly, so an ambient LIVEKIT_TOKEN can't silently override an
55+
# explicit api_key/secret (or vice versa).
56+
if not token and not api_key and not api_secret:
57+
token = os.getenv("LIVEKIT_TOKEN")
58+
if not token and not api_key and not api_secret:
59+
api_key = os.getenv("LIVEKIT_API_KEY")
60+
api_secret = os.getenv("LIVEKIT_API_SECRET")
4861

4962
if not url:
5063
raise ValueError("url must be set")
5164

52-
if not api_key or not api_secret:
53-
raise ValueError("api_key and api_secret must be set")
65+
if not token and (not api_key or not api_secret):
66+
raise ValueError("either token, or api_key and api_secret, must be set")
5467

5568
self._custom_session = True
5669
self._session = session
@@ -60,14 +73,51 @@ def __init__(
6073
timeout = aiohttp.ClientTimeout(total=10)
6174
self._session = aiohttp.ClientSession(timeout=timeout)
6275

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

72122
@property
73123
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))

0 commit comments

Comments
 (0)