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.
Files changed (23) hide show
  1. {livekit_api-1.1.1 → livekit_api-1.2.0}/PKG-INFO +2 -2
  2. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/__init__.py +10 -1
  3. livekit_api-1.2.0/livekit/api/_dial_timeout.py +51 -0
  4. livekit_api-1.2.0/livekit/api/_failover.py +162 -0
  5. livekit_api-1.2.0/livekit/api/_service.py +41 -0
  6. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/access_token.py +3 -2
  7. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/agent_dispatch_service.py +9 -2
  8. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/connector_service.py +30 -3
  9. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/egress_service.py +9 -2
  10. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/ingress_service.py +9 -2
  11. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/livekit_api.py +65 -12
  12. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/room_service.py +9 -2
  13. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/sip_service.py +74 -33
  14. livekit_api-1.2.0/livekit/api/twirp_client.py +284 -0
  15. livekit_api-1.2.0/livekit/api/version.py +1 -0
  16. {livekit_api-1.1.1 → livekit_api-1.2.0}/pyproject.toml +1 -1
  17. livekit_api-1.1.1/livekit/api/_service.py +0 -30
  18. livekit_api-1.1.1/livekit/api/twirp_client.py +0 -134
  19. livekit_api-1.1.1/livekit/api/version.py +0 -1
  20. {livekit_api-1.1.1 → livekit_api-1.2.0}/.gitignore +0 -0
  21. {livekit_api-1.1.1 → livekit_api-1.2.0}/README.md +0 -0
  22. {livekit_api-1.1.1 → livekit_api-1.2.0}/livekit/api/py.typed +0 -0
  23. {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.1.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.17
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 TwirpError, TwirpErrorCode
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
- if claims.get("roomConfig"):
274
+ room_config = claims.get("roomConfig")
275
+ if room_config:
275
276
  grant_claims.room_config = ParseDict(
276
- claims.get("roomConfig"),
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__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
30
- super().__init__(session, url, api_key, api_secret)
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__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
39
- super().__init__(session, url, api_key, api_secret)
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, request: AcceptWhatsAppCallRequest
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__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
37
- super().__init__(session, url, api_key, api_secret)
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__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
32
- super().__init__(session, url, api_key, api_secret)
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
- timeout: Request timeout (default: 60 seconds)
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
- api_key = api_key or os.getenv("LIVEKIT_API_KEY")
46
- 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")
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=60)
73
+ timeout = aiohttp.ClientTimeout(total=10)
60
74
  self._session = aiohttp.ClientSession(timeout=timeout)
61
75
 
62
- self._room = RoomService(self._session, url, api_key, api_secret)
63
- self._ingress = IngressService(self._session, url, api_key, api_secret)
64
- self._egress = EgressService(self._session, url, api_key, api_secret)
65
- self._sip = SipService(self._session, url, api_key, api_secret)
66
- self._agent_dispatch = AgentDispatchService(self._session, url, api_key, api_secret)
67
- self._connector = ConnectorService(self._session, url, api_key, api_secret)
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__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
49
- super().__init__(session, url, api_key, api_secret)
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__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
57
- super().__init__(session, url, api_key, api_secret)
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 timeout:
778
- # obay user specified timeout
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
- return await self._client.request(
796
- SVC,
797
- "CreateSIPParticipant",
798
- create,
799
- self._auth_header(VideoGrants(), sip=SIPGrants(call=True)),
800
- SIPParticipantInfo,
801
- timeout=client_timeout,
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, transfer: TransferSIPParticipantRequest
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
- return await self._client.request(
816
- SVC,
817
- "TransferSIPParticipant",
818
- transfer,
819
- self._auth_header(
820
- VideoGrants(
821
- room_admin=True,
822
- room=transfer.room_name,
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
- sip=SIPGrants(call=True),
825
- ),
826
- SIPParticipantInfo,
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"
@@ -32,7 +32,7 @@ dependencies = [
32
32
  "aiohttp>=3.9.0",
33
33
  "protobuf>=4",
34
34
  "types-protobuf>=4",
35
- "livekit-protocol>=1.1.17,<2.0.0",
35
+ "livekit-protocol>=1.1.19,<2.0.0",
36
36
  ]
37
37
 
38
38
  [project.urls]
@@ -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