1717regions (``/settings/regions`` is 404, as it is for cloud-api)."""
1818
1919import asyncio
20- from typing import Callable , List
20+ import socket
21+ from typing import Callable , List , Optional
2122
2223import aiohttp
24+ import pytest
2325from aiohttp import web
2426from aiohttp .test_utils import TestServer
2527
2628from livekit .api import CreateRoomRequest , Room
29+ from livekit .api ._failover import FAILOVER_MAX_ATTEMPTS , failover_attempts
2730from livekit .api .twirp_client import TwirpClient
2831
2932Handler = 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+
78131def 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