Skip to content

Commit d266988

Browse files
committed
feat: auto failover APIs with LK Cloud
retries in alternative datacenters on 5xx and transport failures
1 parent 115cca9 commit d266988

14 files changed

Lines changed: 515 additions & 41 deletions

.github/workflows/test-api.yml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
name: Test API
16+
17+
permissions:
18+
contents: read
19+
20+
on:
21+
workflow_dispatch:
22+
push:
23+
branches: [main]
24+
pull_request:
25+
branches: [main]
26+
27+
jobs:
28+
failover:
29+
runs-on: ubuntu-latest
30+
services:
31+
mock-server:
32+
image: livekit/test-server:latest
33+
ports:
34+
- 9999:9999
35+
- 10000:10000
36+
- 10001:10001
37+
- 10002:10002
38+
steps:
39+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
40+
with:
41+
submodules: true
42+
43+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
44+
with:
45+
python-version: "3.12"
46+
47+
- name: Install uv
48+
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
49+
50+
- name: Install livekit-api (without livekit-rtc)
51+
run: |
52+
uv venv .failover-venv
53+
source .failover-venv/bin/activate
54+
uv pip install ./livekit-protocol ./livekit-api pytest aiohttp
55+
56+
- name: Wait for mock server
57+
run: |
58+
for i in $(seq 1 30); do
59+
curl -sf http://127.0.0.1:9999/settings/regions >/dev/null && exit 0
60+
sleep 1
61+
done
62+
echo "mock server did not become ready" && exit 1
63+
64+
- name: Run API tests
65+
run: |
66+
source .failover-venv/bin/activate
67+
pytest tests/api -v

livekit-api/livekit/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
from .twirp_client import TwirpError, TwirpErrorCode
4040
from .livekit_api import LiveKitAPI
41+
from ._failover import FailoverConfig
4142
from .access_token import (
4243
InferenceGrants,
4344
ObservabilityGrants,
@@ -66,4 +67,5 @@
6667
"WebhookReceiver",
6768
"TwirpError",
6869
"TwirpErrorCode",
70+
"FailoverConfig",
6971
]
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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, TypedDict
28+
from urllib.parse import urlparse
29+
30+
import aiohttp
31+
32+
_DEFAULT_MAX_ATTEMPTS = 3
33+
_DEFAULT_BACKOFF_BASE = 0.2
34+
35+
36+
class FailoverConfig(TypedDict, total=False):
37+
"""Region-failover tuning, passed as the ``failover`` argument. Use it as a
38+
plain dict, e.g. ``failover={"max_attempts": 5}``. All keys are optional.
39+
40+
Keys:
41+
max_attempts: total number of attempts including the initial request —
42+
the original host plus up to ``max_attempts - 1`` fallback regions.
43+
Defaults to 3. Set to 1 to disable failover (a single attempt).
44+
backoff_base: seconds before the first retry; each subsequent retry
45+
doubles it. Defaults to 0.2.
46+
"""
47+
48+
max_attempts: int
49+
backoff_base: float
50+
51+
52+
def failover_attempts(failover: Optional[FailoverConfig], host: Optional[str]) -> int:
53+
"""Total request attempts including the initial one; 1 means no failover.
54+
55+
With no config (``None``) failover is enabled only for LiveKit Cloud hosts.
56+
An explicit config enables it for any host; ``max_attempts=1`` disables it.
57+
"""
58+
if failover is None:
59+
return _DEFAULT_MAX_ATTEMPTS if (bool(host) and is_cloud(host)) else 1 # type: ignore[arg-type]
60+
return max(1, failover.get("max_attempts", _DEFAULT_MAX_ATTEMPTS))
61+
62+
63+
def failover_backoff_base(failover: Optional[FailoverConfig]) -> float:
64+
return (failover or {}).get("backoff_base", _DEFAULT_BACKOFF_BASE)
65+
66+
67+
def is_cloud(host: str) -> bool:
68+
# Auto mode only enables failover for LiveKit Cloud project domains.
69+
return host.endswith(".livekit.cloud")
70+
71+
72+
def to_http(url: str) -> str:
73+
"""Normalizes a region URL to an http(s) scheme (ws -> http, wss -> https)."""
74+
if url.startswith("ws"):
75+
return "http" + url[2:]
76+
return url
77+
78+
79+
def origin_of(url: str) -> str:
80+
"""Returns the scheme://host[:port] origin of a URL, dropping any path."""
81+
parsed = urlparse(url)
82+
return f"{parsed.scheme}://{parsed.netloc}"
83+
84+
85+
def host_key(url: str) -> str:
86+
"""A stable key identifying a host (including port) for dedup across attempts."""
87+
return urlparse(url).netloc.lower()
88+
89+
90+
def pick_next(region_origins: List[str], attempted: set[str]) -> Optional[str]:
91+
"""Returns the first region origin whose host has not yet been attempted."""
92+
for origin in region_origins:
93+
if host_key(origin) not in attempted:
94+
return origin
95+
return None
96+
97+
98+
@dataclass
99+
class _CacheEntry:
100+
origins: List[str]
101+
fetched_at: float
102+
ttl: float
103+
104+
105+
class RegionCache:
106+
"""Process-wide cache of the LiveKit Cloud region list, keyed by host."""
107+
108+
def __init__(self) -> None:
109+
self._entries: Dict[str, _CacheEntry] = {}
110+
111+
async def region_origins(
112+
self,
113+
session: aiohttp.ClientSession,
114+
origin: str,
115+
headers: Dict[str, str],
116+
) -> List[str]:
117+
"""Returns alternative region origins for ``origin``, fetching
118+
``/settings/regions`` if the cache is stale. Best-effort: on a fetch
119+
failure it serves a stale cached list when available, otherwise an empty
120+
list. Forwards ``headers`` so a valid token — and any test directives —
121+
reach the discovery endpoint."""
122+
key = host_key(origin)
123+
entry = self._entries.get(key)
124+
if entry is not None and (time.monotonic() - entry.fetched_at) < entry.ttl:
125+
return entry.origins
126+
127+
try:
128+
origins, ttl = await self._fetch(session, origin, headers)
129+
except Exception:
130+
return entry.origins if entry is not None else []
131+
132+
# A zero TTL (e.g. Cache-Control: max-age=0) means "do not cache".
133+
if ttl > 0:
134+
self._entries[key] = _CacheEntry(origins, time.monotonic(), ttl)
135+
return origins
136+
137+
async def _fetch(
138+
self,
139+
session: aiohttp.ClientSession,
140+
origin: str,
141+
headers: Dict[str, str],
142+
) -> tuple[List[str], float]:
143+
fetch_headers = {
144+
k: v for k, v in headers.items() if k.lower() not in ("content-type", "content-length")
145+
}
146+
async with session.get(f"{origin}/settings/regions", headers=fetch_headers) as resp:
147+
if resp.status != 200:
148+
raise RuntimeError(f"region discovery failed: {resp.status}")
149+
ttl = _parse_max_age(resp.headers.get("Cache-Control"))
150+
body = await resp.json()
151+
origins = [origin_of(to_http(r["url"])) for r in body.get("regions", []) if r.get("url")]
152+
return origins, ttl
153+
154+
155+
def _parse_max_age(cache_control: Optional[str]) -> float:
156+
if not cache_control:
157+
return 0.0
158+
for directive in cache_control.split(","):
159+
directive = directive.strip().lower()
160+
if directive.startswith("max-age="):
161+
try:
162+
return float(int(directive[len("max-age=") :]))
163+
except ValueError:
164+
return 0.0
165+
return 0.0

livekit-api/livekit/api/_service.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,23 @@
33
import aiohttp
44
from abc import ABC
55
from .twirp_client import TwirpClient
6+
from ._failover import FailoverConfig
7+
from typing import Optional
68
from .access_token import AccessToken, VideoGrants, SIPGrants
79

810
AUTHORIZATION = "authorization"
911

1012

1113
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+
def __init__(
15+
self,
16+
session: aiohttp.ClientSession,
17+
host: str,
18+
api_key: str,
19+
api_secret: str,
20+
failover: Optional[FailoverConfig] = None,
21+
):
22+
self._client = TwirpClient(session, host, "livekit", failover=failover)
1423
self.api_key = api_key
1524
self.api_secret = api_secret
1625

livekit-api/livekit/api/agent_dispatch_service.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ListAgentDispatchResponse,
99
)
1010
from ._service import Service
11+
from ._failover import FailoverConfig
1112
from .access_token import VideoGrants
1213

1314
SVC = "AgentDispatchService"
@@ -26,8 +27,15 @@ class AgentDispatchService(Service):
2627
```
2728
"""
2829

29-
def __init__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
30-
super().__init__(session, url, api_key, api_secret)
30+
def __init__(
31+
self,
32+
session: aiohttp.ClientSession,
33+
url: str,
34+
api_key: str,
35+
api_secret: str,
36+
failover: Optional[FailoverConfig] = None,
37+
):
38+
super().__init__(session, url, api_key, api_secret, failover=failover)
3139

3240
async def create_dispatch(self, req: CreateAgentDispatchRequest) -> AgentDispatch:
3341
"""Create an explicit dispatch for an agent to join a room.

livekit-api/livekit/api/connector_service.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
ConnectTwilioCallResponse,
1818
)
1919
from ._service import Service
20+
from ._failover import FailoverConfig
21+
from typing import Optional
2022
from .access_token import VideoGrants
2123

2224
SVC = "Connector"
@@ -35,8 +37,15 @@ class ConnectorService(Service):
3537
```
3638
"""
3739

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: Optional[FailoverConfig] = None,
47+
):
48+
super().__init__(session, url, api_key, api_secret, failover=failover)
4049

4150
async def dial_whatsapp_call(
4251
self, request: DialWhatsAppCallRequest

livekit-api/livekit/api/egress_service.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
ListEgressResponse,
1414
)
1515
from ._service import Service
16+
from ._failover import FailoverConfig
17+
from typing import Optional
1618
from .access_token import VideoGrants
1719

1820
SVC = "Egress"
@@ -33,8 +35,15 @@ class EgressService(Service):
3335
Also see https://docs.livekit.io/home/egress/overview/
3436
"""
3537

36-
def __init__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
37-
super().__init__(session, url, api_key, api_secret)
38+
def __init__(
39+
self,
40+
session: aiohttp.ClientSession,
41+
url: str,
42+
api_key: str,
43+
api_secret: str,
44+
failover: Optional[FailoverConfig] = None,
45+
):
46+
super().__init__(session, url, api_key, api_secret, failover=failover)
3847

3948
async def start_room_composite_egress(self, start: RoomCompositeEgressRequest) -> EgressInfo:
4049
"""Starts a composite recording of a room."""

livekit-api/livekit/api/ingress_service.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
ListIngressResponse,
99
)
1010
from ._service import Service
11+
from ._failover import FailoverConfig
12+
from typing import Optional
1113
from .access_token import VideoGrants
1214

1315
SVC = "Ingress"
@@ -28,8 +30,15 @@ class IngressService(Service):
2830
Also see https://docs.livekit.io/home/ingress/overview/
2931
"""
3032

31-
def __init__(self, session: aiohttp.ClientSession, url: str, api_key: str, api_secret: str):
32-
super().__init__(session, url, api_key, api_secret)
33+
def __init__(
34+
self,
35+
session: aiohttp.ClientSession,
36+
url: str,
37+
api_key: str,
38+
api_secret: str,
39+
failover: Optional[FailoverConfig] = None,
40+
):
41+
super().__init__(session, url, api_key, api_secret, failover=failover)
3342

3443
async def create_ingress(self, create: CreateIngressRequest) -> IngressInfo:
3544
return await self._client.request(

0 commit comments

Comments
 (0)