Skip to content

Commit b0730f9

Browse files
authored
failover: retry the same host when no fallback origin exists, and cover the Cloud API hosts (#811)
1 parent 95690ca commit b0730f9

3 files changed

Lines changed: 181 additions & 8 deletions

File tree

‎livekit-api/livekit/api/_failover.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ def failover_attempts(
4444
timeout: Optional[float] = None,
4545
) -> int:
4646
"""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.
47+
engages when enabled, the host is a LiveKit Cloud project or Cloud API
48+
domain, and the request timeout is long enough to retry. ``force`` bypasses
49+
the cloud-host check and is for internal testing only.
5050
"""
51-
if not (enabled and (force or (host is not None and is_cloud(host)))):
51+
if not (enabled and (force or (host is not None and (is_cloud(host) or is_cloud_api(host))))):
5252
return 1
5353
if timeout is not None and 0 < timeout < MIN_FAILOVER_TIMEOUT:
5454
return 1
@@ -60,6 +60,12 @@ def is_cloud(host: str) -> bool:
6060
return host.endswith(".livekit.cloud")
6161

6262

63+
def is_cloud_api(host: str) -> bool:
64+
# cloud-api.livekit.io or a cloud-api.<env>.livekit.io variant; hostnames are case-insensitive.
65+
host = host.lower()
66+
return host.startswith("cloud-api.") and host.endswith(".livekit.io")
67+
68+
6369
def to_http(url: str) -> str:
6470
"""Normalizes a region URL to an http(s) scheme (ws -> http, wss -> https)."""
6571
if url.startswith("ws"):

‎livekit-api/livekit/api/twirp_client.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
RegionCache,
2727
failover_attempts,
2828
host_key,
29+
is_cloud_api,
2930
origin_of,
3031
pick_next,
3132
)
@@ -246,7 +247,8 @@ async def request(
246247
self._failover, host, self._failover_force, effective_timeout
247248
)
248249
attempted = {host_key(self._origin)}
249-
region_origins: Optional[List[str]] = None
250+
# A Cloud API host has a single origin; region discovery is never consulted.
251+
region_origins: Optional[List[str]] = [] if host and is_cloud_api(host) else None
250252
current_origin = self._origin
251253
# The configured host may carry a base path (e.g. behind a reverse
252254
# proxy); fallback regions are bare origins.
@@ -287,9 +289,12 @@ async def request(
287289
next_origin = pick_next(region_origins, attempted)
288290

289291
if next_origin is None:
290-
if transport_exc is not None:
291-
raise transport_exc
292-
raise self._server_error(error_data, retryable_status or 500)
292+
if is_last:
293+
if transport_exc is not None:
294+
raise transport_exc
295+
raise self._server_error(error_data, retryable_status or 500)
296+
# With no fallback origin, a retryable failure is retried against the same host.
297+
next_origin = current_origin
293298

294299
reason = transport_exc if transport_exc is not None else f"status {retryable_status}"
295300
logger.warning(

‎tests/api/test_failover_unit.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
"""Failover tests that need no external mock server: the attempts policy, and
16+
the retry loop against an in-process aiohttp server that has no fallback
17+
regions (``/settings/regions`` is 404, as it is for cloud-api)."""
18+
19+
import asyncio
20+
import socket
21+
from typing import Callable, List, Optional
22+
23+
import aiohttp
24+
import pytest
25+
from aiohttp import web
26+
from aiohttp.test_utils import TestServer
27+
28+
from livekit.api import CreateRoomRequest, Room
29+
from livekit.api._failover import FAILOVER_MAX_ATTEMPTS, failover_attempts
30+
from livekit.api.twirp_client import TwirpClient
31+
32+
Handler = Callable[[int, web.Request], "web.StreamResponse | None"]
33+
34+
35+
class _StaticResolver(aiohttp.abc.AbstractResolver):
36+
"""Resolves every hostname to the loopback address so a test server can be
37+
reached under an arbitrary name."""
38+
39+
async def resolve(self, host: str, port: int = 0, family: int = socket.AF_INET) -> list:
40+
return [
41+
{
42+
"hostname": host,
43+
"host": "127.0.0.1",
44+
"port": port,
45+
"family": socket.AF_INET,
46+
"proto": 0,
47+
"flags": 0,
48+
}
49+
]
50+
51+
async def close(self) -> None:
52+
pass
53+
54+
55+
async def _call_single_host(
56+
behave: Handler,
57+
attempts: List[int],
58+
*,
59+
host: str = "127.0.0.1",
60+
discovery_hits: Optional[List[None]] = None,
61+
) -> Room:
62+
"""Runs one CreateRoom against a server, reached as ``host``, whose only
63+
origin is itself; appends each attempt index to ``attempts`` and each
64+
``/settings/regions`` hit to ``discovery_hits``. ``behave(attempt, request)``
65+
returns a response, or None to drop the connection (a transport error with
66+
no HTTP response)."""
67+
68+
async def regions(request: web.Request) -> web.StreamResponse:
69+
if discovery_hits is not None:
70+
discovery_hits.append(None)
71+
raise web.HTTPNotFound()
72+
73+
async def twirp(request: web.Request) -> web.StreamResponse:
74+
attempt = len(attempts)
75+
attempts.append(attempt)
76+
resp = behave(attempt, request)
77+
if resp is None:
78+
assert request.transport is not None
79+
request.transport.close()
80+
raise web.HTTPServiceUnavailable()
81+
return resp
82+
83+
app = web.Application()
84+
app.router.add_post("/twirp/livekit.RoomService/CreateRoom", twirp)
85+
app.router.add_get("/settings/regions", regions)
86+
async with TestServer(app) as server:
87+
connector = aiohttp.TCPConnector(resolver=_StaticResolver())
88+
async with aiohttp.ClientSession(connector=connector) as session:
89+
client = TwirpClient(
90+
session,
91+
f"http://{host}:{server.port}",
92+
"livekit",
93+
_failover_force=host == "127.0.0.1",
94+
_failover_backoff=0.001,
95+
)
96+
return await client.request("RoomService", "CreateRoom", CreateRoomRequest(), {}, Room)
97+
98+
99+
def _ok(request: web.Request) -> web.Response:
100+
return web.Response(body=Room(name="r").SerializeToString())
101+
102+
103+
def test_retries_same_host_on_transport_error():
104+
"""Without a fallback origin, a transport error retries the same host."""
105+
106+
def behave(attempt: int, request: web.Request):
107+
return None if attempt == 0 else _ok(request)
108+
109+
attempts: List[int] = []
110+
room = asyncio.run(_call_single_host(behave, attempts))
111+
assert room.name == "r"
112+
assert len(attempts) == 2
113+
114+
115+
def test_cloud_api_host_never_consults_region_discovery():
116+
"""A Cloud API host retries the same host without any /settings/regions request."""
117+
118+
def behave(attempt: int, request: web.Request):
119+
return None if attempt == 0 else _ok(request)
120+
121+
attempts: List[int] = []
122+
hits: List[None] = []
123+
room = asyncio.run(
124+
_call_single_host(behave, attempts, host="cloud-api.livekit.io", discovery_hits=hits)
125+
)
126+
assert room.name == "r"
127+
assert len(attempts) == 2
128+
assert hits == []
129+
130+
131+
def test_retries_same_host_on_5xx():
132+
"""Without a fallback origin, a 5xx retries the same host."""
133+
134+
def behave(attempt: int, request: web.Request):
135+
if attempt == 0:
136+
return web.json_response({"code": "unavailable", "msg": "down"}, status=502)
137+
return _ok(request)
138+
139+
attempts: List[int] = []
140+
room = asyncio.run(_call_single_host(behave, attempts))
141+
assert room.name == "r"
142+
assert len(attempts) == 2
143+
144+
145+
@pytest.mark.parametrize(
146+
"host, expected",
147+
[
148+
("myproject.livekit.cloud", FAILOVER_MAX_ATTEMPTS),
149+
("myproject.region.livekit.cloud", FAILOVER_MAX_ATTEMPTS),
150+
("myproject.livekit.io", 1),
151+
# The LiveKit Cloud API hosts fail over too (same-host retry).
152+
("cloud-api.livekit.io", FAILOVER_MAX_ATTEMPTS),
153+
("cloud-api.staging.livekit.io", FAILOVER_MAX_ATTEMPTS),
154+
("CLOUD-API.LIVEKIT.IO", FAILOVER_MAX_ATTEMPTS),
155+
("cloud-api.example.com", 1),
156+
("example.com", 1),
157+
("127.0.0.1", 1),
158+
("notlivekit.cloud", 1),
159+
],
160+
)
161+
def test_failover_attempts(host: str, expected: int):
162+
assert failover_attempts(True, host) == expected

0 commit comments

Comments
 (0)