From 8e9da6646bc8834ac2866891a422f2bf0e5bd221 Mon Sep 17 00:00:00 2001 From: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:30:37 +0530 Subject: [PATCH] fix(api): keep the base path of the server URL in Twirp requests Since region failover (#733) the request URL is built from the bare origin, so a server URL with a path (reverse proxy) lost it. Use the configured host for the first attempt; fallback regions stay bare origins. Also drop the doubled slash in TwirpClient.host. --- livekit-api/livekit/api/twirp_client.py | 8 ++- tests/api/test_twirp_client.py | 69 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 tests/api/test_twirp_client.py diff --git a/livekit-api/livekit/api/twirp_client.py b/livekit-api/livekit/api/twirp_client.py index 9981368b..f162f1a4 100644 --- a/livekit-api/livekit/api/twirp_client.py +++ b/livekit-api/livekit/api/twirp_client.py @@ -202,7 +202,7 @@ def __init__( if scheme.startswith("ws"): scheme = scheme.replace("ws", "http") - host = f"{scheme}://{parse_res.netloc}/{parse_res.path}" + host = f"{scheme}://{parse_res.netloc}{parse_res.path}" self.host = host.rstrip("/") self.pkg = pkg self.prefix = prefix @@ -248,10 +248,13 @@ async def request( attempted = {host_key(self._origin)} region_origins: Optional[List[str]] = None current_origin = self._origin + # The configured host may carry a base path (e.g. behind a reverse + # proxy); fallback regions are bare origins. + current_base = self.host for attempt in range(max_attempts): is_last = attempt + 1 >= max_attempts - url = f"{current_origin}/{path}" + url = f"{current_base}/{path}" transport_exc: Optional[BaseException] = None retryable_status: Optional[int] = None @@ -298,6 +301,7 @@ async def request( await asyncio.sleep(self._failover_backoff * (2**attempt)) attempted.add(host_key(next_origin)) current_origin = next_origin + current_base = next_origin raise RuntimeError("failover loop exited without returning") # unreachable diff --git a/tests/api/test_twirp_client.py b/tests/api/test_twirp_client.py new file mode 100644 index 00000000..1d5b8faf --- /dev/null +++ b/tests/api/test_twirp_client.py @@ -0,0 +1,69 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from typing import List + +import pytest + +from livekit.api.twirp_client import TwirpClient +from livekit.protocol.room import ListRoomsRequest, ListRoomsResponse + + +class _Response: + status = 200 + + async def read(self) -> bytes: + return ListRoomsResponse().SerializeToString() + + +class _Post: + async def __aenter__(self) -> _Response: + return _Response() + + async def __aexit__(self, *exc) -> None: + return None + + +class _RecordingSession: + timeout = None + + def __init__(self) -> None: + self.urls: List[str] = [] + + def post(self, url: str, **kwargs) -> _Post: + self.urls.append(url) + return _Post() + + +def _requested_url(host: str) -> str: + session = _RecordingSession() + client = TwirpClient(session, host, "livekit") # type: ignore[arg-type] + asyncio.run( + client.request("RoomService", "ListRooms", ListRoomsRequest(), {}, ListRoomsResponse) + ) + return session.urls[0] + + +@pytest.mark.parametrize( + ("host", "expected"), + [ + ("wss://example.livekit.cloud", "https://example.livekit.cloud"), + ("http://localhost:7880/", "http://localhost:7880"), + ("https://proxy.example.com/livekit", "https://proxy.example.com/livekit"), + ("wss://proxy.example.com/livekit/", "https://proxy.example.com/livekit"), + ], +) +def test_request_url_keeps_the_base_path(host: str, expected: str) -> None: + assert _requested_url(host) == f"{expected}/twirp/livekit.RoomService/ListRooms"