Skip to content

Commit 95b2642

Browse files
committed
fix(api): preserve base URL paths in Twirp requests
1 parent 082e83c commit 95b2642

2 files changed

Lines changed: 99 additions & 3 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,8 @@ def __init__(
202202
if scheme.startswith("ws"):
203203
scheme = scheme.replace("ws", "http")
204204

205-
host = f"{scheme}://{parse_res.netloc}/{parse_res.path}"
206-
self.host = host.rstrip("/")
205+
self._base_path = parse_res.path.rstrip("/")
206+
self.host = f"{scheme}://{parse_res.netloc}{self._base_path}"
207207
self.pkg = pkg
208208
self.prefix = prefix
209209
self._session = session
@@ -251,7 +251,7 @@ async def request(
251251

252252
for attempt in range(max_attempts):
253253
is_last = attempt + 1 >= max_attempts
254-
url = f"{current_origin}/{path}"
254+
url = f"{current_origin}{self._base_path}/{path}"
255255

256256
transport_exc: Optional[BaseException] = None
257257
retryable_status: Optional[int] = None

‎tests/api/test_twirp_client.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
import asyncio
16+
from unittest.mock import AsyncMock
17+
18+
import aiohttp
19+
import pytest
20+
from aiohttp import web
21+
from aiohttp.test_utils import TestServer
22+
23+
from livekit.api import CreateRoomRequest, LiveKitAPI, Room
24+
from livekit.api import twirp_client
25+
from livekit.api.twirp_client import REQUEST_ID_HEADER, TwirpClient
26+
27+
28+
@pytest.mark.parametrize("base_path", ["", "/", "/livekit", "/proxy/livekit/"])
29+
@pytest.mark.parametrize("scheme", ["http", "ws"])
30+
def test_api_request_preserves_base_path(base_path: str, scheme: str) -> None:
31+
async def run() -> None:
32+
async def create_room(request: web.Request) -> web.Response:
33+
message = CreateRoomRequest.FromString(await request.read())
34+
assert message.name == "test-room"
35+
assert request.headers["Authorization"] == "Bearer test-token"
36+
return web.Response(body=Room(name=message.name).SerializeToString())
37+
38+
app = web.Application()
39+
app.router.add_post(
40+
f"{base_path.rstrip('/')}/twirp/livekit.RoomService/CreateRoom", create_room
41+
)
42+
async with TestServer(app) as server:
43+
url = str(server.make_url(base_path)).replace("http://", f"{scheme}://", 1)
44+
async with LiveKitAPI.with_token("test-token", url, failover=False) as api:
45+
room = await api.room.create_room(CreateRoomRequest(name="test-room"))
46+
assert room.name == "test-room"
47+
48+
asyncio.run(run())
49+
50+
51+
@pytest.mark.parametrize("prefix", ["twirp", "custom/twirp"])
52+
def test_failover_preserves_base_path(monkeypatch: pytest.MonkeyPatch, prefix: str) -> None:
53+
async def run() -> None:
54+
requests = []
55+
56+
async def record_request(request: web.Request) -> None:
57+
requests.append(
58+
(request.path, request.headers[REQUEST_ID_HEADER], await request.read())
59+
)
60+
61+
async def primary(request: web.Request) -> web.Response:
62+
await record_request(request)
63+
return web.json_response({"code": "unavailable", "msg": "retry"}, status=503)
64+
65+
async def fallback(request: web.Request) -> web.Response:
66+
await record_request(request)
67+
return web.Response(body=Room(name="test-room").SerializeToString())
68+
69+
route = f"/proxy/livekit/{prefix}/livekit.RoomService/CreateRoom"
70+
primary_app = web.Application()
71+
primary_app.router.add_post(route, primary)
72+
fallback_app = web.Application()
73+
fallback_app.router.add_post(route, fallback)
74+
async with TestServer(primary_app) as first, TestServer(fallback_app) as second:
75+
discover = AsyncMock(return_value=[str(second.make_url("")).rstrip("/")])
76+
monkeypatch.setattr(twirp_client._REGION_CACHE, "region_origins", discover)
77+
async with aiohttp.ClientSession() as session:
78+
client = TwirpClient(
79+
session,
80+
str(first.make_url("/proxy/livekit/")),
81+
"livekit",
82+
prefix=prefix,
83+
_failover_force=True,
84+
_failover_backoff=0,
85+
)
86+
room = await client.request(
87+
"RoomService", "CreateRoom", CreateRoomRequest(name="test-room"), {}, Room
88+
)
89+
assert room.name == "test-room"
90+
assert len(requests) == 2
91+
assert requests[0] == requests[1]
92+
assert requests[0][0] == route
93+
assert requests[0][1]
94+
discover.assert_awaited_once()
95+
96+
asyncio.run(run())

0 commit comments

Comments
 (0)