Skip to content

Commit 5110422

Browse files
committed
failover: enable retries for the LiveKit Cloud API hosts
1 parent ef763df commit 5110422

3 files changed

Lines changed: 93 additions & 12 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: 3 additions & 1 deletion
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
)
@@ -226,7 +227,8 @@ async def request(
226227
self._failover, host, self._failover_force, effective_timeout
227228
)
228229
attempted = {host_key(self._origin)}
229-
region_origins: Optional[List[str]] = None
230+
# A Cloud API host has a single origin; region discovery is never consulted.
231+
region_origins: Optional[List[str]] = [] if host and is_cloud_api(host) else None
230232
current_origin = self._origin
231233

232234
for attempt in range(max_attempts):

‎tests/api/test_failover_unit.py‎

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,59 @@
1717
regions (``/settings/regions`` is 404, as it is for cloud-api)."""
1818

1919
import asyncio
20-
from typing import Callable, List
20+
import socket
21+
from typing import Callable, List, Optional
2122

2223
import aiohttp
24+
import pytest
2325
from aiohttp import web
2426
from aiohttp.test_utils import TestServer
2527

2628
from livekit.api import CreateRoomRequest, Room
29+
from livekit.api._failover import FAILOVER_MAX_ATTEMPTS, failover_attempts
2730
from livekit.api.twirp_client import TwirpClient
2831

2932
Handler = Callable[[int, web.Request], "web.StreamResponse | None"]
3033

3134

32-
async def _call_single_host(behave: Handler, attempts: List[int]) -> Room:
33-
"""Runs one CreateRoom against a server whose only origin is itself and
34-
appends each attempt index to ``attempts``. ``behave(attempt, request)``
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)``
3565
returns a response, or None to drop the connection (a transport error with
3666
no HTTP response)."""
3767

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+
3873
async def twirp(request: web.Request) -> web.StreamResponse:
3974
attempt = len(attempts)
4075
attempts.append(attempt)
@@ -47,13 +82,15 @@ async def twirp(request: web.Request) -> web.StreamResponse:
4782

4883
app = web.Application()
4984
app.router.add_post("/twirp/livekit.RoomService/CreateRoom", twirp)
85+
app.router.add_get("/settings/regions", regions)
5086
async with TestServer(app) as server:
51-
async with aiohttp.ClientSession() as session:
87+
connector = aiohttp.TCPConnector(resolver=_StaticResolver())
88+
async with aiohttp.ClientSession(connector=connector) as session:
5289
client = TwirpClient(
5390
session,
54-
str(server.make_url("")),
91+
f"http://{host}:{server.port}",
5592
"livekit",
56-
_failover_force=True,
93+
_failover_force=host == "127.0.0.1",
5794
_failover_backoff=0.001,
5895
)
5996
return await client.request("RoomService", "CreateRoom", CreateRoomRequest(), {}, Room)
@@ -75,6 +112,22 @@ def behave(attempt: int, request: web.Request):
75112
assert len(attempts) == 2
76113

77114

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+
78131
def test_retries_same_host_on_5xx():
79132
"""Without a fallback origin, a 5xx retries the same host."""
80133

@@ -87,3 +140,23 @@ def behave(attempt: int, request: web.Request):
87140
room = asyncio.run(_call_single_host(behave, attempts))
88141
assert room.name == "r"
89142
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)