Skip to content

Commit 32ad07a

Browse files
committed
failover: retry the same host once when a transport error has no fallback origin
1 parent 21a4542 commit 32ad07a

2 files changed

Lines changed: 95 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,9 +264,13 @@ 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:
267+
# With no fallback origin, a 5xx is final; only a transport
268+
# error is retried, against the same host.
269+
if transport_exc is None:
270+
raise self._server_error(error_data, retryable_status or 500)
271+
if is_last:
268272
raise transport_exc
269-
raise self._server_error(error_data, retryable_status or 500)
273+
next_origin = current_origin
270274

271275
reason = transport_exc if transport_exc is not None else f"status {retryable_status}"
272276
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+
import pytest
24+
from aiohttp import web
25+
from aiohttp.test_utils import TestServer
26+
27+
from livekit.api import CreateRoomRequest, Room, ServerError
28+
from livekit.api.twirp_client import TwirpClient
29+
30+
Handler = Callable[[int, web.Request], "web.StreamResponse | None"]
31+
32+
33+
async def _call_single_host(behave: Handler, attempts: List[int]) -> Room:
34+
"""Runs one CreateRoom against a server whose only origin is itself and
35+
appends each attempt index to ``attempts``. ``behave(attempt, request)``
36+
returns a response, or None to drop the connection (a transport error with
37+
no HTTP response)."""
38+
39+
async def twirp(request: web.Request) -> web.StreamResponse:
40+
attempt = len(attempts)
41+
attempts.append(attempt)
42+
resp = behave(attempt, request)
43+
if resp is None:
44+
assert request.transport is not None
45+
request.transport.close()
46+
raise web.HTTPServiceUnavailable()
47+
return resp
48+
49+
app = web.Application()
50+
app.router.add_post("/twirp/livekit.RoomService/CreateRoom", twirp)
51+
async with TestServer(app) as server:
52+
async with aiohttp.ClientSession() as session:
53+
client = TwirpClient(
54+
session,
55+
str(server.make_url("")),
56+
"livekit",
57+
_failover_force=True,
58+
_failover_backoff=0.001,
59+
)
60+
return await client.request("RoomService", "CreateRoom", CreateRoomRequest(), {}, Room)
61+
62+
63+
def _ok(request: web.Request) -> web.Response:
64+
return web.Response(body=Room(name="r").SerializeToString())
65+
66+
67+
def test_retries_same_host_on_transport_error():
68+
"""Without a fallback origin, a transport error retries the same host."""
69+
70+
def behave(attempt: int, request: web.Request):
71+
return None if attempt == 0 else _ok(request)
72+
73+
attempts: List[int] = []
74+
room = asyncio.run(_call_single_host(behave, attempts))
75+
assert room.name == "r"
76+
assert len(attempts) == 2
77+
78+
79+
def test_no_regions_does_not_retry_5xx():
80+
"""Without a fallback origin, a 5xx is surfaced, never retried."""
81+
82+
def behave(attempt: int, request: web.Request):
83+
return web.json_response({"code": "unavailable", "msg": "down"}, status=502)
84+
85+
attempts: List[int] = []
86+
with pytest.raises(ServerError) as exc:
87+
asyncio.run(_call_single_host(behave, attempts))
88+
assert exc.value.status == 502
89+
assert len(attempts) == 1

0 commit comments

Comments
 (0)