livekit-api 1.1.1__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {livekit_api-1.1.1 → livekit_api-1.2.0}/PKG-INFO +2 -2
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/__init__.py +10 -1
- livekit_api-1.2.0/livekit/api/_dial_timeout.py +51 -0
- livekit_api-1.2.0/livekit/api/_failover.py +162 -0
- livekit_api-1.2.0/livekit/api/_service.py +41 -0
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/access_token.py +3 -2
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/agent_dispatch_service.py +9 -2
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/connector_service.py +30 -3
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/egress_service.py +9 -2
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/ingress_service.py +9 -2
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/livekit_api.py +65 -12
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/room_service.py +9 -2
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/sip_service.py +74 -33
- livekit_api-1.2.0/livekit/api/twirp_client.py +284 -0
- livekit_api-1.2.0/livekit/api/version.py +1 -0
- {livekit_api-1.1.1 → livekit_api-1.2.0}/pyproject.toml +1 -1
- livekit_api-1.1.1/livekit/api/_service.py +0 -30
- livekit_api-1.1.1/livekit/api/twirp_client.py +0 -134
- livekit_api-1.1.1/livekit/api/version.py +0 -1
- {livekit_api-1.1.1 → livekit_api-1.2.0}/.gitignore +0 -0
- {livekit_api-1.1.1 → livekit_api-1.2.0}/README.md +0 -0
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/py.typed +0 -0
- {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/webhook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: livekit-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python Server API for LiveKit
|
|
5
5
|
Project-URL: Documentation, https://docs.livekit.io
|
|
6
6
|
Project-URL: Website, https://livekit.io/
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Multimedia :: Video
|
|
|
22
22
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
23
|
Requires-Python: >=3.9.0
|
|
24
24
|
Requires-Dist: aiohttp>=3.9.0
|
|
25
|
-
Requires-Dist: livekit-protocol<2.0.0,>=1.1.
|
|
25
|
+
Requires-Dist: livekit-protocol<2.0.0,>=1.1.19
|
|
26
26
|
Requires-Dist: protobuf>=4
|
|
27
27
|
Requires-Dist: pyjwt>=2.0.0
|
|
28
28
|
Requires-Dist: types-protobuf>=4
|
|
@@ -36,7 +36,13 @@ from livekit.protocol.sip import *
|
|
|
36
36
|
from livekit.protocol.connector_whatsapp import *
|
|
37
37
|
from livekit.protocol.connector_twilio import *
|
|
38
38
|
|
|
39
|
-
from .twirp_client import
|
|
39
|
+
from .twirp_client import (
|
|
40
|
+
ServerError,
|
|
41
|
+
ServerErrorCode,
|
|
42
|
+
SipCallError,
|
|
43
|
+
TwirpError,
|
|
44
|
+
TwirpErrorCode,
|
|
45
|
+
)
|
|
40
46
|
from .livekit_api import LiveKitAPI
|
|
41
47
|
from .access_token import (
|
|
42
48
|
InferenceGrants,
|
|
@@ -64,6 +70,9 @@ __all__ = [
|
|
|
64
70
|
"AccessToken",
|
|
65
71
|
"TokenVerifier",
|
|
66
72
|
"WebhookReceiver",
|
|
73
|
+
"ServerError",
|
|
74
|
+
"ServerErrorCode",
|
|
67
75
|
"TwirpError",
|
|
68
76
|
"TwirpErrorCode",
|
|
77
|
+
"SipCallError",
|
|
69
78
|
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional, Union
|
|
4
|
+
|
|
5
|
+
from livekit.protocol.sip import CreateSIPParticipantRequest, TransferSIPParticipantRequest
|
|
6
|
+
|
|
7
|
+
# Requests that carry ringing_timeout and share the phone-dialing timeout behavior.
|
|
8
|
+
DialRequest = Union[
|
|
9
|
+
CreateSIPParticipantRequest,
|
|
10
|
+
TransferSIPParticipantRequest,
|
|
11
|
+
]
|
|
12
|
+
"""@private"""
|
|
13
|
+
|
|
14
|
+
# Ring window (seconds) assumed when a request doesn't set ringing_timeout;
|
|
15
|
+
# matches the server default. A dialing request must outlast it.
|
|
16
|
+
DEFAULT_RINGING_TIMEOUT = 30.0
|
|
17
|
+
"""@private"""
|
|
18
|
+
|
|
19
|
+
# A dialing request must outlast the ringing window, or it would abort before
|
|
20
|
+
# the call can be answered. Keep the request timeout at least this many seconds
|
|
21
|
+
# above the ringing timeout.
|
|
22
|
+
RINGING_TIMEOUT_MARGIN = 2.0
|
|
23
|
+
"""@private"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def pin_ringing_timeout(request: DialRequest) -> None:
|
|
27
|
+
"""Set the ring window explicitly on a dialing request when the caller left it
|
|
28
|
+
unset, so the derived request timeout doesn't depend on the server's default
|
|
29
|
+
(which could change out from under us).
|
|
30
|
+
|
|
31
|
+
@private
|
|
32
|
+
"""
|
|
33
|
+
if not request.HasField("ringing_timeout"):
|
|
34
|
+
request.ringing_timeout.seconds = int(DEFAULT_RINGING_TIMEOUT)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dial_timeout(user_timeout: Optional[float], request: DialRequest) -> float:
|
|
38
|
+
"""Request timeout (seconds) for a phone-dialing call: the ring window plus a
|
|
39
|
+
margin, so the request doesn't abort before the call can be answered. The
|
|
40
|
+
ring window is the request's ringing_timeout when set, else
|
|
41
|
+
DEFAULT_RINGING_TIMEOUT. A longer user_timeout is honored; a shorter one is
|
|
42
|
+
raised to the floor.
|
|
43
|
+
|
|
44
|
+
@private
|
|
45
|
+
"""
|
|
46
|
+
if request.HasField("ringing_timeout"):
|
|
47
|
+
ring: float = request.ringing_timeout.seconds
|
|
48
|
+
else:
|
|
49
|
+
ring = DEFAULT_RINGING_TIMEOUT
|
|
50
|
+
floor = ring + RINGING_TIMEOUT_MARGIN
|
|
51
|
+
return max(user_timeout if user_timeout else floor, floor)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# Copyright 2026 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Region failover for the Twirp API clients.
|
|
16
|
+
|
|
17
|
+
On a retryable failure (any transport error or HTTP 5xx) the client discovers
|
|
18
|
+
alternative LiveKit Cloud regions via ``/settings/regions`` and replays the
|
|
19
|
+
request against the next region, with exponential backoff. 4xx responses are
|
|
20
|
+
returned immediately.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Dict, List, Optional
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
import aiohttp
|
|
31
|
+
|
|
32
|
+
FAILOVER_MAX_ATTEMPTS = 3
|
|
33
|
+
FAILOVER_BACKOFF_BASE = 0.2 # seconds
|
|
34
|
+
# Below this per-request timeout (seconds) a retry is unlikely to help and many
|
|
35
|
+
# clients would retry in lockstep across regions, so a short request gets a
|
|
36
|
+
# single attempt (thundering-herd guard).
|
|
37
|
+
MIN_FAILOVER_TIMEOUT = 5.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def failover_attempts(
|
|
41
|
+
enabled: bool,
|
|
42
|
+
host: Optional[str],
|
|
43
|
+
force: bool = False,
|
|
44
|
+
timeout: Optional[float] = None,
|
|
45
|
+
) -> int:
|
|
46
|
+
"""Total request attempts for a host; 1 means no failover. Failover only
|
|
47
|
+
engages when enabled, the host is a LiveKit Cloud domain, and the request
|
|
48
|
+
timeout is long enough to retry. ``force`` bypasses the cloud-host check and
|
|
49
|
+
is for internal testing only.
|
|
50
|
+
"""
|
|
51
|
+
if not (enabled and (force or (host is not None and is_cloud(host)))):
|
|
52
|
+
return 1
|
|
53
|
+
if timeout is not None and 0 < timeout < MIN_FAILOVER_TIMEOUT:
|
|
54
|
+
return 1
|
|
55
|
+
return FAILOVER_MAX_ATTEMPTS
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_cloud(host: str) -> bool:
|
|
59
|
+
# Failover only engages for LiveKit Cloud project domains.
|
|
60
|
+
return host.endswith(".livekit.cloud")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def to_http(url: str) -> str:
|
|
64
|
+
"""Normalizes a region URL to an http(s) scheme (ws -> http, wss -> https)."""
|
|
65
|
+
if url.startswith("ws"):
|
|
66
|
+
return "http" + url[2:]
|
|
67
|
+
return url
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def origin_of(url: str) -> str:
|
|
71
|
+
"""Returns the scheme://host[:port] origin of a URL, dropping any path."""
|
|
72
|
+
parsed = urlparse(url)
|
|
73
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def host_key(url: str) -> str:
|
|
77
|
+
"""A stable key identifying a host (including port) for dedup across attempts."""
|
|
78
|
+
return urlparse(url).netloc.lower()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pick_next(region_origins: List[str], attempted: set[str]) -> Optional[str]:
|
|
82
|
+
"""Returns the first region origin whose host has not yet been attempted."""
|
|
83
|
+
for origin in region_origins:
|
|
84
|
+
if host_key(origin) not in attempted:
|
|
85
|
+
return origin
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class _CacheEntry:
|
|
91
|
+
origins: List[str]
|
|
92
|
+
fetched_at: float
|
|
93
|
+
ttl: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RegionCache:
|
|
97
|
+
"""Process-wide cache of the LiveKit Cloud region list, keyed by host."""
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._entries: Dict[str, _CacheEntry] = {}
|
|
101
|
+
|
|
102
|
+
async def region_origins(
|
|
103
|
+
self,
|
|
104
|
+
session: aiohttp.ClientSession,
|
|
105
|
+
origin: str,
|
|
106
|
+
headers: Dict[str, str],
|
|
107
|
+
) -> List[str]:
|
|
108
|
+
"""Returns alternative region origins for ``origin``, fetching
|
|
109
|
+
``/settings/regions`` if the cache is stale. Best-effort: on a fetch
|
|
110
|
+
failure it serves a stale cached list when available, otherwise an empty
|
|
111
|
+
list. Forwards ``headers`` so a valid token — and any test directives —
|
|
112
|
+
reach the discovery endpoint."""
|
|
113
|
+
key = host_key(origin)
|
|
114
|
+
entry = self._entries.get(key)
|
|
115
|
+
if entry is not None and (time.monotonic() - entry.fetched_at) < entry.ttl:
|
|
116
|
+
return entry.origins
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
origins, ttl = await self._fetch(session, origin, headers)
|
|
120
|
+
except Exception:
|
|
121
|
+
return entry.origins if entry is not None else []
|
|
122
|
+
|
|
123
|
+
# A zero TTL (e.g. Cache-Control: max-age=0) means "do not cache".
|
|
124
|
+
if ttl > 0:
|
|
125
|
+
self._entries[key] = _CacheEntry(origins, time.monotonic(), ttl)
|
|
126
|
+
return origins
|
|
127
|
+
|
|
128
|
+
async def _fetch(
|
|
129
|
+
self,
|
|
130
|
+
session: aiohttp.ClientSession,
|
|
131
|
+
origin: str,
|
|
132
|
+
headers: Dict[str, str],
|
|
133
|
+
) -> tuple[List[str], float]:
|
|
134
|
+
fetch_headers = {
|
|
135
|
+
k: v for k, v in headers.items() if k.lower() not in ("content-type", "content-length")
|
|
136
|
+
}
|
|
137
|
+
# Short timeout so a slow/unreachable discovery endpoint doesn't stall
|
|
138
|
+
# the failover path.
|
|
139
|
+
async with session.get(
|
|
140
|
+
f"{origin}/settings/regions",
|
|
141
|
+
headers=fetch_headers,
|
|
142
|
+
timeout=aiohttp.ClientTimeout(total=2),
|
|
143
|
+
) as resp:
|
|
144
|
+
if resp.status != 200:
|
|
145
|
+
raise RuntimeError(f"region discovery failed: {resp.status}")
|
|
146
|
+
ttl = _parse_max_age(resp.headers.get("Cache-Control"))
|
|
147
|
+
body = await resp.json()
|
|
148
|
+
origins = [origin_of(to_http(r["url"])) for r in body.get("regions", []) if r.get("url")]
|
|
149
|
+
return origins, ttl
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_max_age(cache_control: Optional[str]) -> float:
|
|
153
|
+
if not cache_control:
|
|
154
|
+
return 0.0
|
|
155
|
+
for directive in cache_control.split(","):
|
|
156
|
+
directive = directive.strip().lower()
|
|
157
|
+
if directive.startswith("max-age="):
|
|
158
|
+
try:
|
|
159
|
+
return float(int(directive[len("max-age=") :]))
|
|
160
|
+
except ValueError:
|
|
161
|
+
return 0.0
|
|
162
|
+
return 0.0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
from abc import ABC
|
|
5
|
+
from .twirp_client import TwirpClient
|
|
6
|
+
from .access_token import AccessToken, VideoGrants, SIPGrants
|
|
7
|
+
|
|
8
|
+
AUTHORIZATION = "authorization"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Service(ABC):
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
session: aiohttp.ClientSession,
|
|
15
|
+
host: str,
|
|
16
|
+
api_key: str,
|
|
17
|
+
api_secret: str,
|
|
18
|
+
failover: bool = True,
|
|
19
|
+
):
|
|
20
|
+
self._client = TwirpClient(session, host, "livekit", failover=failover)
|
|
21
|
+
self.api_key = api_key
|
|
22
|
+
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
|
|
26
|
+
|
|
27
|
+
def _auth_header(
|
|
28
|
+
self, grants: VideoGrants | None, sip: SIPGrants | None = None
|
|
29
|
+
) -> 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
|
+
|
|
34
|
+
tok = AccessToken(self.api_key, self.api_secret)
|
|
35
|
+
if grants:
|
|
36
|
+
tok.with_grants(grants)
|
|
37
|
+
if sip is not None:
|
|
38
|
+
tok.with_sip_grants(sip)
|
|
39
|
+
|
|
40
|
+
token = tok.to_jwt()
|
|
41
|
+
return {AUTHORIZATION: "Bearer {}".format(token)}
|
|
@@ -271,9 +271,10 @@ class TokenVerifier:
|
|
|
271
271
|
|
|
272
272
|
if claims.get("roomPreset"):
|
|
273
273
|
grant_claims.room_preset = claims.get("roomPreset")
|
|
274
|
-
|
|
274
|
+
room_config = claims.get("roomConfig")
|
|
275
|
+
if room_config:
|
|
275
276
|
grant_claims.room_config = ParseDict(
|
|
276
|
-
|
|
277
|
+
room_config,
|
|
277
278
|
RoomConfiguration(),
|
|
278
279
|
ignore_unknown_fields=True,
|
|
279
280
|
)
|
|
@@ -26,8 +26,15 @@ class AgentDispatchService(Service):
|
|
|
26
26
|
```
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
def __init__(
|
|
30
|
-
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
session: aiohttp.ClientSession,
|
|
32
|
+
url: str,
|
|
33
|
+
api_key: str,
|
|
34
|
+
api_secret: str,
|
|
35
|
+
failover: bool = True,
|
|
36
|
+
):
|
|
37
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
31
38
|
|
|
32
39
|
async def create_dispatch(self, req: CreateAgentDispatchRequest) -> AgentDispatch:
|
|
33
40
|
"""Create an explicit dispatch for an agent to join a room.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import aiohttp
|
|
4
|
+
from typing import Optional
|
|
4
5
|
|
|
5
6
|
from livekit.protocol.connector_whatsapp import (
|
|
6
7
|
DialWhatsAppCallRequest,
|
|
@@ -17,6 +18,7 @@ from livekit.protocol.connector_twilio import (
|
|
|
17
18
|
ConnectTwilioCallResponse,
|
|
18
19
|
)
|
|
19
20
|
from ._service import Service
|
|
21
|
+
from ._dial_timeout import DEFAULT_RINGING_TIMEOUT
|
|
20
22
|
from .access_token import VideoGrants
|
|
21
23
|
|
|
22
24
|
SVC = "Connector"
|
|
@@ -35,8 +37,15 @@ class ConnectorService(Service):
|
|
|
35
37
|
```
|
|
36
38
|
"""
|
|
37
39
|
|
|
38
|
-
def __init__(
|
|
39
|
-
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
session: aiohttp.ClientSession,
|
|
43
|
+
url: str,
|
|
44
|
+
api_key: str,
|
|
45
|
+
api_secret: str,
|
|
46
|
+
failover: bool = True,
|
|
47
|
+
):
|
|
48
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
40
49
|
|
|
41
50
|
async def dial_whatsapp_call(
|
|
42
51
|
self, request: DialWhatsAppCallRequest
|
|
@@ -99,23 +108,41 @@ class ConnectorService(Service):
|
|
|
99
108
|
)
|
|
100
109
|
|
|
101
110
|
async def accept_whatsapp_call(
|
|
102
|
-
self,
|
|
111
|
+
self,
|
|
112
|
+
request: AcceptWhatsAppCallRequest,
|
|
113
|
+
*,
|
|
114
|
+
timeout: Optional[float] = None,
|
|
103
115
|
) -> AcceptWhatsAppCallResponse:
|
|
104
116
|
"""
|
|
105
117
|
Accept an inbound WhatsApp call
|
|
106
118
|
|
|
107
119
|
Args:
|
|
108
120
|
request: AcceptWhatsAppCallRequest containing call parameters and SDP
|
|
121
|
+
timeout: Optional request timeout in seconds. When the request waits
|
|
122
|
+
for the inbound party to join (wait_until_answered), it defaults
|
|
123
|
+
to the standard ring window.
|
|
109
124
|
|
|
110
125
|
Returns:
|
|
111
126
|
AcceptWhatsAppCallResponse with the room name
|
|
112
127
|
"""
|
|
128
|
+
client_timeout: Optional[aiohttp.ClientTimeout] = None
|
|
129
|
+
if request.wait_until_answered:
|
|
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.
|
|
133
|
+
client_timeout = aiohttp.ClientTimeout(
|
|
134
|
+
total=timeout if timeout else DEFAULT_RINGING_TIMEOUT
|
|
135
|
+
)
|
|
136
|
+
elif timeout:
|
|
137
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
138
|
+
|
|
113
139
|
return await self._client.request(
|
|
114
140
|
SVC,
|
|
115
141
|
"AcceptWhatsAppCall",
|
|
116
142
|
request,
|
|
117
143
|
self._auth_header(VideoGrants(room_create=True)),
|
|
118
144
|
AcceptWhatsAppCallResponse,
|
|
145
|
+
timeout=client_timeout,
|
|
119
146
|
)
|
|
120
147
|
|
|
121
148
|
async def connect_twilio_call(
|
|
@@ -33,8 +33,15 @@ class EgressService(Service):
|
|
|
33
33
|
Also see https://docs.livekit.io/home/egress/overview/
|
|
34
34
|
"""
|
|
35
35
|
|
|
36
|
-
def __init__(
|
|
37
|
-
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
session: aiohttp.ClientSession,
|
|
39
|
+
url: str,
|
|
40
|
+
api_key: str,
|
|
41
|
+
api_secret: str,
|
|
42
|
+
failover: bool = True,
|
|
43
|
+
):
|
|
44
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
38
45
|
|
|
39
46
|
async def start_room_composite_egress(self, start: RoomCompositeEgressRequest) -> EgressInfo:
|
|
40
47
|
"""Starts a composite recording of a room."""
|
|
@@ -28,8 +28,15 @@ class IngressService(Service):
|
|
|
28
28
|
Also see https://docs.livekit.io/home/ingress/overview/
|
|
29
29
|
"""
|
|
30
30
|
|
|
31
|
-
def __init__(
|
|
32
|
-
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
session: aiohttp.ClientSession,
|
|
34
|
+
url: str,
|
|
35
|
+
api_key: str,
|
|
36
|
+
api_secret: str,
|
|
37
|
+
failover: bool = True,
|
|
38
|
+
):
|
|
39
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
33
40
|
|
|
34
41
|
async def create_ingress(self, create: CreateIngressRequest) -> IngressInfo:
|
|
35
42
|
return await self._client.request(
|
|
@@ -29,42 +29,95 @@ class LiveKitAPI:
|
|
|
29
29
|
api_key: Optional[str] = None,
|
|
30
30
|
api_secret: Optional[str] = None,
|
|
31
31
|
*,
|
|
32
|
+
token: Optional[str] = None,
|
|
32
33
|
timeout: Optional[aiohttp.ClientTimeout] = None,
|
|
33
34
|
session: Optional[aiohttp.ClientSession] = None,
|
|
35
|
+
failover: bool = True,
|
|
34
36
|
):
|
|
35
37
|
"""Create a new LiveKitAPI instance.
|
|
36
38
|
|
|
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
|
+
|
|
37
43
|
Args:
|
|
38
44
|
url: LiveKit server URL (read from `LIVEKIT_URL` environment variable if not provided)
|
|
39
45
|
api_key: API key (read from `LIVEKIT_API_KEY` environment variable if not provided)
|
|
40
46
|
api_secret: API secret (read from `LIVEKIT_API_SECRET` environment variable if not provided)
|
|
41
|
-
|
|
47
|
+
token: Pre-signed access token (read from `LIVEKIT_TOKEN` environment variable if not provided)
|
|
48
|
+
timeout: Request timeout (default: 10 seconds)
|
|
42
49
|
session: aiohttp.ClientSession instance to use for requests, if not provided, a new one will be created
|
|
43
50
|
"""
|
|
44
51
|
url = url or os.getenv("LIVEKIT_URL")
|
|
45
|
-
|
|
46
|
-
|
|
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")
|
|
47
61
|
|
|
48
62
|
if not url:
|
|
49
63
|
raise ValueError("url must be set")
|
|
50
64
|
|
|
51
|
-
if not api_key or not api_secret:
|
|
52
|
-
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")
|
|
53
67
|
|
|
54
68
|
self._custom_session = True
|
|
55
69
|
self._session = session
|
|
56
70
|
if not self._session:
|
|
57
71
|
self._custom_session = False
|
|
58
72
|
if not timeout:
|
|
59
|
-
timeout = aiohttp.ClientTimeout(total=
|
|
73
|
+
timeout = aiohttp.ClientTimeout(total=10)
|
|
60
74
|
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
self.
|
|
67
|
-
self.
|
|
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)
|
|
68
121
|
|
|
69
122
|
@property
|
|
70
123
|
def agent_dispatch(self) -> AgentDispatchService:
|
|
@@ -45,8 +45,15 @@ class RoomService(Service):
|
|
|
45
45
|
Also see https://docs.livekit.io/home/server/managing-rooms/ and https://docs.livekit.io/home/server/managing-participants/
|
|
46
46
|
"""
|
|
47
47
|
|
|
48
|
-
def __init__(
|
|
49
|
-
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
session: aiohttp.ClientSession,
|
|
51
|
+
url: str,
|
|
52
|
+
api_key: str,
|
|
53
|
+
api_secret: str,
|
|
54
|
+
failover: bool = True,
|
|
55
|
+
):
|
|
56
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
50
57
|
|
|
51
58
|
async def create_room(
|
|
52
59
|
self,
|
|
@@ -33,14 +33,28 @@ from livekit.protocol.sip import (
|
|
|
33
33
|
TransferSIPParticipantRequest,
|
|
34
34
|
SIPParticipantInfo,
|
|
35
35
|
SIPTransport,
|
|
36
|
+
SIPMediaConfig,
|
|
36
37
|
)
|
|
37
38
|
from ._service import Service
|
|
39
|
+
from .twirp_client import SipCallError, ServerError
|
|
40
|
+
from ._dial_timeout import (
|
|
41
|
+
dial_timeout as _dial_timeout,
|
|
42
|
+
pin_ringing_timeout as _pin_ringing_timeout,
|
|
43
|
+
)
|
|
38
44
|
from .access_token import VideoGrants, SIPGrants
|
|
39
45
|
|
|
40
46
|
SVC = "SIP"
|
|
41
47
|
"""@private"""
|
|
42
48
|
|
|
43
49
|
|
|
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
|
+
|
|
44
58
|
class SipService(Service):
|
|
45
59
|
"""Client for LiveKit SIP Service API
|
|
46
60
|
|
|
@@ -53,8 +67,15 @@ class SipService(Service):
|
|
|
53
67
|
```
|
|
54
68
|
"""
|
|
55
69
|
|
|
56
|
-
def __init__(
|
|
57
|
-
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
session: aiohttp.ClientSession,
|
|
73
|
+
url: str,
|
|
74
|
+
api_key: str,
|
|
75
|
+
api_secret: str,
|
|
76
|
+
failover: bool = True,
|
|
77
|
+
):
|
|
78
|
+
super().__init__(session, url, api_key, api_secret, failover=failover)
|
|
58
79
|
|
|
59
80
|
async def create_inbound_trunk(
|
|
60
81
|
self, create: CreateSIPInboundTrunkRequest
|
|
@@ -156,6 +177,7 @@ class SipService(Service):
|
|
|
156
177
|
auth_password: Optional[str] = None,
|
|
157
178
|
name: Optional[str] = None,
|
|
158
179
|
metadata: Optional[str] = None,
|
|
180
|
+
media: Optional[SIPMediaConfig] = None,
|
|
159
181
|
) -> SIPInboundTrunkInfo:
|
|
160
182
|
"""Updates specific fields of an existing SIP inbound trunk.
|
|
161
183
|
|
|
@@ -166,6 +188,7 @@ class SipService(Service):
|
|
|
166
188
|
auth_password=auth_password,
|
|
167
189
|
name=name,
|
|
168
190
|
metadata=metadata,
|
|
191
|
+
media=media,
|
|
169
192
|
)
|
|
170
193
|
if numbers is not None:
|
|
171
194
|
if isinstance(numbers, ListUpdate):
|
|
@@ -335,6 +358,7 @@ class SipService(Service):
|
|
|
335
358
|
auth_password: str | None = None,
|
|
336
359
|
name: str | None = None,
|
|
337
360
|
metadata: str | None = None,
|
|
361
|
+
media: Optional[SIPMediaConfig] = None,
|
|
338
362
|
) -> SIPOutboundTrunkInfo:
|
|
339
363
|
"""Updates specific fields of an existing SIP outbound trunk.
|
|
340
364
|
|
|
@@ -347,6 +371,7 @@ class SipService(Service):
|
|
|
347
371
|
auth_password=auth_password,
|
|
348
372
|
name=name,
|
|
349
373
|
metadata=metadata,
|
|
374
|
+
media=media,
|
|
350
375
|
)
|
|
351
376
|
if numbers is not None:
|
|
352
377
|
if isinstance(numbers, ListUpdate):
|
|
@@ -774,17 +799,15 @@ class SipService(Service):
|
|
|
774
799
|
SIPError: If the SIP operation fails
|
|
775
800
|
"""
|
|
776
801
|
client_timeout: Optional[aiohttp.ClientTimeout] = None
|
|
777
|
-
if
|
|
778
|
-
#
|
|
802
|
+
if create.wait_until_answered:
|
|
803
|
+
# Dialing a phone and waiting for an answer takes longer than a
|
|
804
|
+
# normal call, and the request must outlast ringing. Pin the ring
|
|
805
|
+
# window so the timeout doesn't depend on the server's default.
|
|
806
|
+
_pin_ringing_timeout(create)
|
|
807
|
+
client_timeout = aiohttp.ClientTimeout(total=_dial_timeout(timeout, create))
|
|
808
|
+
elif timeout:
|
|
809
|
+
# obey user specified timeout
|
|
779
810
|
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
780
|
-
elif create.wait_until_answered:
|
|
781
|
-
# ensure default timeout isn't too short when using sync mode
|
|
782
|
-
if (
|
|
783
|
-
self._client._session.timeout
|
|
784
|
-
and self._client._session.timeout.total
|
|
785
|
-
and self._client._session.timeout.total < 20
|
|
786
|
-
):
|
|
787
|
-
client_timeout = aiohttp.ClientTimeout(total=20)
|
|
788
811
|
|
|
789
812
|
if trunk_id:
|
|
790
813
|
create.sip_trunk_id = trunk_id
|
|
@@ -792,39 +815,57 @@ class SipService(Service):
|
|
|
792
815
|
if outbound_trunk_config:
|
|
793
816
|
create.trunk = outbound_trunk_config
|
|
794
817
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
|
803
829
|
|
|
804
830
|
async def transfer_sip_participant(
|
|
805
|
-
self,
|
|
831
|
+
self,
|
|
832
|
+
transfer: TransferSIPParticipantRequest,
|
|
833
|
+
*,
|
|
834
|
+
timeout: Optional[float] = None,
|
|
806
835
|
) -> SIPParticipantInfo:
|
|
807
836
|
"""Transfer a SIP participant to a different room.
|
|
808
837
|
|
|
809
838
|
Args:
|
|
810
839
|
transfer: Request containing transfer details
|
|
840
|
+
timeout: Optional request timeout in seconds. Transferring dials a
|
|
841
|
+
phone, which takes longer than normal, so it defaults to a
|
|
842
|
+
longer timeout when unset.
|
|
811
843
|
|
|
812
844
|
Returns:
|
|
813
845
|
Updated SIP participant information
|
|
814
846
|
"""
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
847
|
+
# Transferring a call dials a phone, which takes longer than a normal
|
|
848
|
+
# call, so keep the request alive past ringing. Pin the ring window so the
|
|
849
|
+
# timeout doesn't depend on the server's default.
|
|
850
|
+
_pin_ringing_timeout(transfer)
|
|
851
|
+
client_timeout = aiohttp.ClientTimeout(total=_dial_timeout(timeout, transfer))
|
|
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),
|
|
823
863
|
),
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
864
|
+
SIPParticipantInfo,
|
|
865
|
+
timeout=client_timeout,
|
|
866
|
+
)
|
|
867
|
+
except ServerError as e:
|
|
868
|
+
raise _as_sip_error(e) from None
|
|
828
869
|
|
|
829
870
|
def _admin_headers(self) -> dict[str, str]:
|
|
830
871
|
return self._auth_header(VideoGrants(), sip=SIPGrants(admin=True))
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# Copyright 2023 LiveKit, Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Dict, List, Optional, Type, TypeVar
|
|
18
|
+
|
|
19
|
+
import aiohttp
|
|
20
|
+
from google.protobuf.message import Message
|
|
21
|
+
from urllib.parse import urlparse
|
|
22
|
+
|
|
23
|
+
from ._failover import (
|
|
24
|
+
FAILOVER_BACKOFF_BASE,
|
|
25
|
+
RegionCache,
|
|
26
|
+
failover_attempts,
|
|
27
|
+
host_key,
|
|
28
|
+
origin_of,
|
|
29
|
+
pick_next,
|
|
30
|
+
)
|
|
31
|
+
from .version import __version__
|
|
32
|
+
|
|
33
|
+
DEFAULT_PREFIX = "twirp"
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("livekit")
|
|
36
|
+
|
|
37
|
+
# Identifies the SDK and version to the server on every request.
|
|
38
|
+
_USER_AGENT = f"livekit-server-sdk-python/{__version__}"
|
|
39
|
+
|
|
40
|
+
# Shared across all clients in the process so the region list is fetched once.
|
|
41
|
+
_REGION_CACHE = RegionCache()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServerError(Exception):
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
code: str,
|
|
48
|
+
msg: str,
|
|
49
|
+
*,
|
|
50
|
+
status: int,
|
|
51
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._code = code
|
|
54
|
+
self._msg = msg
|
|
55
|
+
self._status = status
|
|
56
|
+
self._metadata = metadata or {}
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def code(self) -> str:
|
|
60
|
+
return self._code
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def message(self) -> str:
|
|
64
|
+
return self._msg
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def status(self) -> int:
|
|
68
|
+
"""HTTP status code"""
|
|
69
|
+
return self._status
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def metadata(self) -> Dict[str, str]:
|
|
73
|
+
"""Server-provided error metadata"""
|
|
74
|
+
return self._metadata
|
|
75
|
+
|
|
76
|
+
def __str__(self) -> str:
|
|
77
|
+
result = f"ServerError(code={self.code}, message={self.message}, status={self.status}"
|
|
78
|
+
if self.metadata:
|
|
79
|
+
result += f", metadata={self.metadata}"
|
|
80
|
+
result += ")"
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
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
|
+
if raw is None:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return int(raw)
|
|
98
|
+
except ValueError:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def sip_status(self) -> Optional[str]:
|
|
103
|
+
"""The SIP reason phrase of the failed call, e.g. "Busy Here"."""
|
|
104
|
+
return self.metadata.get("sip_status")
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_server_error(cls, err: ServerError) -> "SipCallError":
|
|
108
|
+
return cls(err.code, err.message, status=err.status, metadata=err.metadata)
|
|
109
|
+
|
|
110
|
+
def __str__(self) -> str:
|
|
111
|
+
code = self.metadata.get("sip_status_code")
|
|
112
|
+
if code is None:
|
|
113
|
+
return super().__str__()
|
|
114
|
+
# A clear, SIP-specific representation, including any extra metadata.
|
|
115
|
+
reason = self.metadata.get("sip_status")
|
|
116
|
+
result = f"SIP call failed: {code}"
|
|
117
|
+
if reason:
|
|
118
|
+
result += f" {reason}"
|
|
119
|
+
result += f" ({self.code})"
|
|
120
|
+
extra = {
|
|
121
|
+
k: v
|
|
122
|
+
for k, v in self.metadata.items()
|
|
123
|
+
if k not in ("sip_status_code", "sip_status", "error_details")
|
|
124
|
+
}
|
|
125
|
+
if extra:
|
|
126
|
+
result += " [" + ", ".join(f"{k}={v}" for k, v in extra.items()) + "]"
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Deprecated alias for :class:`ServerError`, kept for backwards compatibility.
|
|
131
|
+
TwirpError = ServerError
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ServerErrorCode:
|
|
135
|
+
CANCELED = "canceled"
|
|
136
|
+
UNKNOWN = "unknown"
|
|
137
|
+
INVALID_ARGUMENT = "invalid_argument"
|
|
138
|
+
MALFORMED = "malformed"
|
|
139
|
+
DEADLINE_EXCEEDED = "deadline_exceeded"
|
|
140
|
+
NOT_FOUND = "not_found"
|
|
141
|
+
BAD_ROUTE = "bad_route"
|
|
142
|
+
ALREADY_EXISTS = "already_exists"
|
|
143
|
+
PERMISSION_DENIED = "permission_denied"
|
|
144
|
+
UNAUTHENTICATED = "unauthenticated"
|
|
145
|
+
RESOURCE_EXHAUSTED = "resource_exhausted"
|
|
146
|
+
FAILED_PRECONDITION = "failed_precondition"
|
|
147
|
+
ABORTED = "aborted"
|
|
148
|
+
OUT_OF_RANGE = "out_of_range"
|
|
149
|
+
UNIMPLEMENTED = "unimplemented"
|
|
150
|
+
INTERNAL = "internal"
|
|
151
|
+
UNAVAILABLE = "unavailable"
|
|
152
|
+
DATA_LOSS = "dataloss"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Deprecated alias for :class:`ServerErrorCode`, kept for backwards compatibility.
|
|
156
|
+
TwirpErrorCode = ServerErrorCode
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
T = TypeVar("T", bound=Message)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TwirpClient:
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
session: aiohttp.ClientSession,
|
|
166
|
+
host: str,
|
|
167
|
+
pkg: str,
|
|
168
|
+
prefix: str = DEFAULT_PREFIX,
|
|
169
|
+
failover: bool = True,
|
|
170
|
+
*,
|
|
171
|
+
_failover_force: bool = False,
|
|
172
|
+
_failover_backoff: float = FAILOVER_BACKOFF_BASE,
|
|
173
|
+
) -> None:
|
|
174
|
+
parse_res = urlparse(host)
|
|
175
|
+
scheme = parse_res.scheme
|
|
176
|
+
if scheme.startswith("ws"):
|
|
177
|
+
scheme = scheme.replace("ws", "http")
|
|
178
|
+
|
|
179
|
+
host = f"{scheme}://{parse_res.netloc}/{parse_res.path}"
|
|
180
|
+
self.host = host.rstrip("/")
|
|
181
|
+
self.pkg = pkg
|
|
182
|
+
self.prefix = prefix
|
|
183
|
+
self._session = session
|
|
184
|
+
# _failover_force / _failover_backoff are internal test-only knobs.
|
|
185
|
+
self._failover = failover
|
|
186
|
+
self._failover_force = _failover_force
|
|
187
|
+
self._failover_backoff = _failover_backoff
|
|
188
|
+
self._origin = origin_of(self.host)
|
|
189
|
+
|
|
190
|
+
async def request(
|
|
191
|
+
self,
|
|
192
|
+
service: str,
|
|
193
|
+
method: str,
|
|
194
|
+
data: Message,
|
|
195
|
+
headers: Dict[str, str],
|
|
196
|
+
response_class: Type[T],
|
|
197
|
+
*,
|
|
198
|
+
timeout: Optional[aiohttp.ClientTimeout] = None,
|
|
199
|
+
) -> T:
|
|
200
|
+
"""Issues a Twirp request, failing over to alternative regions on
|
|
201
|
+
retryable errors. On any transport error or HTTP 5xx it discovers
|
|
202
|
+
regions via ``/settings/regions`` and replays the request — body and
|
|
203
|
+
headers intact — against the next untried region, with exponential
|
|
204
|
+
backoff. A 4xx is returned immediately."""
|
|
205
|
+
path = f"{self.prefix}/{self.pkg}.{service}/{method}"
|
|
206
|
+
headers = dict(headers)
|
|
207
|
+
headers["User-Agent"] = _USER_AGENT
|
|
208
|
+
forward_headers = dict(headers) # for the discovery fetch (no content-type yet)
|
|
209
|
+
headers["Content-Type"] = "application/protobuf"
|
|
210
|
+
serialized_data = data.SerializeToString()
|
|
211
|
+
|
|
212
|
+
# The effective per-attempt timeout is the per-call override, or the
|
|
213
|
+
# session default; used to gate failover for short requests.
|
|
214
|
+
effective_timeout = timeout.total if timeout else None
|
|
215
|
+
if effective_timeout is None and self._session.timeout is not None:
|
|
216
|
+
effective_timeout = self._session.timeout.total
|
|
217
|
+
host = urlparse(self._origin).hostname
|
|
218
|
+
max_attempts = failover_attempts(
|
|
219
|
+
self._failover, host, self._failover_force, effective_timeout
|
|
220
|
+
)
|
|
221
|
+
attempted = {host_key(self._origin)}
|
|
222
|
+
region_origins: Optional[List[str]] = None
|
|
223
|
+
current_origin = self._origin
|
|
224
|
+
|
|
225
|
+
for attempt in range(max_attempts):
|
|
226
|
+
is_last = attempt + 1 >= max_attempts
|
|
227
|
+
url = f"{current_origin}/{path}"
|
|
228
|
+
|
|
229
|
+
transport_exc: Optional[BaseException] = None
|
|
230
|
+
retryable_status: Optional[int] = None
|
|
231
|
+
error_data: Dict = {}
|
|
232
|
+
try:
|
|
233
|
+
async with self._session.post(
|
|
234
|
+
url, headers=headers, data=serialized_data, timeout=timeout
|
|
235
|
+
) as resp:
|
|
236
|
+
if resp.status == 200:
|
|
237
|
+
return response_class.FromString(await resp.read())
|
|
238
|
+
# Twirp encodes errors as JSON regardless of content type.
|
|
239
|
+
try:
|
|
240
|
+
error_data = await resp.json()
|
|
241
|
+
except Exception:
|
|
242
|
+
error_data = {}
|
|
243
|
+
if resp.status < 500:
|
|
244
|
+
# 4xx is terminal.
|
|
245
|
+
raise self._server_error(error_data, resp.status)
|
|
246
|
+
retryable_status = resp.status
|
|
247
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
248
|
+
transport_exc = e
|
|
249
|
+
|
|
250
|
+
# Only retryable failures (5xx or transport error) reach this point.
|
|
251
|
+
next_origin = None
|
|
252
|
+
if not is_last:
|
|
253
|
+
if region_origins is None:
|
|
254
|
+
region_origins = await _REGION_CACHE.region_origins(
|
|
255
|
+
self._session, self._origin, forward_headers
|
|
256
|
+
)
|
|
257
|
+
next_origin = pick_next(region_origins, attempted)
|
|
258
|
+
|
|
259
|
+
if next_origin is None:
|
|
260
|
+
if transport_exc is not None:
|
|
261
|
+
raise transport_exc
|
|
262
|
+
raise self._server_error(error_data, retryable_status or 500)
|
|
263
|
+
|
|
264
|
+
reason = transport_exc if transport_exc is not None else f"status {retryable_status}"
|
|
265
|
+
logger.warning(
|
|
266
|
+
"livekit API request to %s failed (%s), retrying with fallback url %s",
|
|
267
|
+
current_origin,
|
|
268
|
+
reason,
|
|
269
|
+
next_origin,
|
|
270
|
+
)
|
|
271
|
+
await asyncio.sleep(self._failover_backoff * (2**attempt))
|
|
272
|
+
attempted.add(host_key(next_origin))
|
|
273
|
+
current_origin = next_origin
|
|
274
|
+
|
|
275
|
+
raise RuntimeError("failover loop exited without returning") # unreachable
|
|
276
|
+
|
|
277
|
+
@staticmethod
|
|
278
|
+
def _server_error(error_data: Dict, status: int) -> "ServerError":
|
|
279
|
+
return ServerError(
|
|
280
|
+
error_data.get("code", "unknown"),
|
|
281
|
+
error_data.get("msg", ""),
|
|
282
|
+
status=status,
|
|
283
|
+
metadata=error_data.get("meta"),
|
|
284
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.0"
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import aiohttp
|
|
4
|
-
from abc import ABC
|
|
5
|
-
from .twirp_client import TwirpClient
|
|
6
|
-
from .access_token import AccessToken, VideoGrants, SIPGrants
|
|
7
|
-
|
|
8
|
-
AUTHORIZATION = "authorization"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class Service(ABC):
|
|
12
|
-
def __init__(self, session: aiohttp.ClientSession, host: str, api_key: str, api_secret: str):
|
|
13
|
-
self._client = TwirpClient(session, host, "livekit")
|
|
14
|
-
self.api_key = api_key
|
|
15
|
-
self.api_secret = api_secret
|
|
16
|
-
|
|
17
|
-
def _auth_header(
|
|
18
|
-
self, grants: VideoGrants | None, sip: SIPGrants | None = None
|
|
19
|
-
) -> dict[str, str]:
|
|
20
|
-
tok = AccessToken(self.api_key, self.api_secret)
|
|
21
|
-
if grants:
|
|
22
|
-
tok.with_grants(grants)
|
|
23
|
-
if sip is not None:
|
|
24
|
-
tok.with_sip_grants(sip)
|
|
25
|
-
|
|
26
|
-
token = tok.to_jwt()
|
|
27
|
-
|
|
28
|
-
headers = {}
|
|
29
|
-
headers[AUTHORIZATION] = "Bearer {}".format(token)
|
|
30
|
-
return headers
|
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
# Copyright 2023 LiveKit, Inc.
|
|
2
|
-
#
|
|
3
|
-
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
-
# you may not use this file except in compliance with the License.
|
|
5
|
-
# You may obtain a copy of the License at
|
|
6
|
-
#
|
|
7
|
-
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
-
#
|
|
9
|
-
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
-
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
-
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
-
# See the License for the specific language governing permissions and
|
|
13
|
-
# limitations under the License.
|
|
14
|
-
|
|
15
|
-
from typing import Dict, Type, TypeVar, Optional
|
|
16
|
-
|
|
17
|
-
import aiohttp
|
|
18
|
-
from google.protobuf.message import Message
|
|
19
|
-
from urllib.parse import urlparse
|
|
20
|
-
|
|
21
|
-
DEFAULT_PREFIX = "twirp"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class TwirpError(Exception):
|
|
25
|
-
def __init__(
|
|
26
|
-
self,
|
|
27
|
-
code: str,
|
|
28
|
-
msg: str,
|
|
29
|
-
*,
|
|
30
|
-
status: int,
|
|
31
|
-
metadata: Optional[Dict[str, str]] = None,
|
|
32
|
-
) -> None:
|
|
33
|
-
self._code = code
|
|
34
|
-
self._msg = msg
|
|
35
|
-
self._status = status
|
|
36
|
-
self._metadata = metadata or {}
|
|
37
|
-
|
|
38
|
-
@property
|
|
39
|
-
def code(self) -> str:
|
|
40
|
-
return self._code
|
|
41
|
-
|
|
42
|
-
@property
|
|
43
|
-
def message(self) -> str:
|
|
44
|
-
return self._msg
|
|
45
|
-
|
|
46
|
-
@property
|
|
47
|
-
def status(self) -> int:
|
|
48
|
-
"""HTTP status code"""
|
|
49
|
-
return self._status
|
|
50
|
-
|
|
51
|
-
@property
|
|
52
|
-
def metadata(self) -> Dict[str, str]:
|
|
53
|
-
"""Twirp metadata"""
|
|
54
|
-
return self._metadata
|
|
55
|
-
|
|
56
|
-
def __str__(self) -> str:
|
|
57
|
-
result = f"TwirpError(code={self.code}, message={self.message}, status={self.status}"
|
|
58
|
-
if self.metadata:
|
|
59
|
-
result += f", metadata={self.metadata}"
|
|
60
|
-
result += ")"
|
|
61
|
-
return result
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class TwirpErrorCode:
|
|
65
|
-
CANCELED = "canceled"
|
|
66
|
-
UNKNOWN = "unknown"
|
|
67
|
-
INVALID_ARGUMENT = "invalid_argument"
|
|
68
|
-
MALFORMED = "malformed"
|
|
69
|
-
DEADLINE_EXCEEDED = "deadline_exceeded"
|
|
70
|
-
NOT_FOUND = "not_found"
|
|
71
|
-
BAD_ROUTE = "bad_route"
|
|
72
|
-
ALREADY_EXISTS = "already_exists"
|
|
73
|
-
PERMISSION_DENIED = "permission_denied"
|
|
74
|
-
UNAUTHENTICATED = "unauthenticated"
|
|
75
|
-
RESOURCE_EXHAUSTED = "resource_exhausted"
|
|
76
|
-
FAILED_PRECONDITION = "failed_precondition"
|
|
77
|
-
ABORTED = "aborted"
|
|
78
|
-
OUT_OF_RANGE = "out_of_range"
|
|
79
|
-
UNIMPLEMENTED = "unimplemented"
|
|
80
|
-
INTERNAL = "internal"
|
|
81
|
-
UNAVAILABLE = "unavailable"
|
|
82
|
-
DATA_LOSS = "dataloss"
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
T = TypeVar("T", bound=Message)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
class TwirpClient:
|
|
89
|
-
def __init__(
|
|
90
|
-
self,
|
|
91
|
-
session: aiohttp.ClientSession,
|
|
92
|
-
host: str,
|
|
93
|
-
pkg: str,
|
|
94
|
-
prefix: str = DEFAULT_PREFIX,
|
|
95
|
-
) -> None:
|
|
96
|
-
parse_res = urlparse(host)
|
|
97
|
-
scheme = parse_res.scheme
|
|
98
|
-
if scheme.startswith("ws"):
|
|
99
|
-
scheme = scheme.replace("ws", "http")
|
|
100
|
-
|
|
101
|
-
host = f"{scheme}://{parse_res.netloc}/{parse_res.path}"
|
|
102
|
-
self.host = host.rstrip("/")
|
|
103
|
-
self.pkg = pkg
|
|
104
|
-
self.prefix = prefix
|
|
105
|
-
self._session = session
|
|
106
|
-
|
|
107
|
-
async def request(
|
|
108
|
-
self,
|
|
109
|
-
service: str,
|
|
110
|
-
method: str,
|
|
111
|
-
data: Message,
|
|
112
|
-
headers: Dict[str, str],
|
|
113
|
-
response_class: Type[T],
|
|
114
|
-
*,
|
|
115
|
-
timeout: Optional[aiohttp.ClientTimeout] = None,
|
|
116
|
-
) -> T:
|
|
117
|
-
url = f"{self.host}/{self.prefix}/{self.pkg}.{service}/{method}"
|
|
118
|
-
headers["Content-Type"] = "application/protobuf"
|
|
119
|
-
|
|
120
|
-
serialized_data = data.SerializeToString()
|
|
121
|
-
async with self._session.post(
|
|
122
|
-
url, headers=headers, data=serialized_data, timeout=timeout
|
|
123
|
-
) as resp:
|
|
124
|
-
if resp.status == 200:
|
|
125
|
-
return response_class.FromString(await resp.read())
|
|
126
|
-
else:
|
|
127
|
-
# when we have an error, Twirp always encode it in json
|
|
128
|
-
error_data = await resp.json()
|
|
129
|
-
raise TwirpError(
|
|
130
|
-
error_data.get("code", "unknown"),
|
|
131
|
-
error_data.get("msg", ""),
|
|
132
|
-
status=resp.status,
|
|
133
|
-
metadata=error_data.get("meta"),
|
|
134
|
-
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.1.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|