Skip to content

Commit 2ff1df3

Browse files
committed
failover: retry the same host when no fallback origin exists
A retryable failure with no untried origin used to be surfaced after a single attempt. Retry it against the same origin instead, bounded by the existing attempt count and backoff. This matches the cross-region path, which already retries both transport errors and 5xx responses.
1 parent 21a4542 commit 2ff1df3

2 files changed

Lines changed: 95 additions & 3 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,12 @@ async def request(
264264
next_origin = pick_next(region_origins, attempted)
265265

266266
if next_origin is None:
267-
if transport_exc is not None:
268-
raise transport_exc
269-
raise self._server_error(error_data, retryable_status or 500)
267+
if is_last:
268+
if transport_exc is not None:
269+
raise transport_exc
270+
raise self._server_error(error_data, retryable_status or 500)
271+
# With no fallback origin, a retryable failure is retried against the same host.
272+
next_origin = current_origin
270273

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

‎tests/api/test_failover_unit.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
from typing import Callable, List
21+
22+
import aiohttp
23+
from aiohttp import web
24+
from aiohttp.test_utils import TestServer
25+
26+
from livekit.api import CreateRoomRequest, Room
27+
from livekit.api.twirp_client import TwirpClient
28+
29+
Handler = Callable[[int, web.Request], "web.StreamResponse | None"]
30+
31+
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+
returns a response, or None to drop the connection (a transport error with
36+
no HTTP response)."""
37+
38+
async def twirp(request: web.Request) -> web.StreamResponse:
39+
attempt = len(attempts)
40+
attempts.append(attempt)
41+
resp = behave(attempt, request)
42+
if resp is None:
43+
assert request.transport is not None
44+
request.transport.close()
45+
raise web.HTTPServiceUnavailable()
46+
return resp
47+
48+
app = web.Application()
49+
app.router.add_post("/twirp/livekit.RoomService/CreateRoom", twirp)
50+
async with TestServer(app) as server:
51+
async with aiohttp.ClientSession() as session:
52+
client = TwirpClient(
53+
session,
54+
str(server.make_url("")),
55+
"livekit",
56+
_failover_force=True,
57+
_failover_backoff=0.001,
58+
)
59+
return await client.request("RoomService", "CreateRoom", CreateRoomRequest(), {}, Room)
60+
61+
62+
def _ok(request: web.Request) -> web.Response:
63+
return web.Response(body=Room(name="r").SerializeToString())
64+
65+
66+
def test_retries_same_host_on_transport_error():
67+
"""Without a fallback origin, a transport error retries the same host."""
68+
69+
def behave(attempt: int, request: web.Request):
70+
return None if attempt == 0 else _ok(request)
71+
72+
attempts: List[int] = []
73+
room = asyncio.run(_call_single_host(behave, attempts))
74+
assert room.name == "r"
75+
assert len(attempts) == 2
76+
77+
78+
def test_retries_same_host_on_5xx():
79+
"""Without a fallback origin, a 5xx retries the same host."""
80+
81+
def behave(attempt: int, request: web.Request):
82+
if attempt == 0:
83+
return web.json_response({"code": "unavailable", "msg": "down"}, status=502)
84+
return _ok(request)
85+
86+
attempts: List[int] = []
87+
room = asyncio.run(_call_single_host(behave, attempts))
88+
assert room.name == "r"
89+
assert len(attempts) == 2

0 commit comments

Comments
 (0)