Skip to content

Commit 4c92664

Browse files
committed
Generate request-id for api call
relate to livekit/server-sdk-go#954
1 parent 8d73982 commit 4c92664

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

livekit-api/livekit/api/twirp_client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import asyncio
1616
import logging
17+
import uuid
1718
from typing import Dict, List, Optional, Type, TypeVar
1819

1920
import aiohttp
@@ -37,6 +38,11 @@
3738
# Identifies the SDK and version to the server on every request.
3839
_USER_AGENT = f"livekit-server-sdk-python/{__version__}"
3940

41+
# Carries a per-request idempotency key. The SDK's auto-retries (see _failover)
42+
# keep the same key across attempts, so the server can identify and deduplicate
43+
# repeated requests.
44+
REQUEST_ID_HEADER = "X-Livekit-Request-Id"
45+
4046
# Shared across all clients in the process so the region list is fetched once.
4147
_REGION_CACHE = RegionCache()
4248

@@ -207,6 +213,8 @@ async def request(
207213
headers["User-Agent"] = _USER_AGENT
208214
forward_headers = dict(headers) # for the discovery fetch (no content-type yet)
209215
headers["Content-Type"] = "application/protobuf"
216+
if not any(h.lower() == REQUEST_ID_HEADER.lower() for h in headers):
217+
headers[REQUEST_ID_HEADER] = str(uuid.uuid4())
210218
serialized_data = data.SerializeToString()
211219

212220
# The effective per-attempt timeout is the per-call override, or the

tests/api/test_request_id.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
"""Tests for the per-request idempotency key the client stamps on every API
16+
request. These drive TwirpClient.request() against a fake aiohttp session so no
17+
server is needed, and use the internal test-only failover knobs
18+
(_failover_force/_failover_backoff) to exercise the retry path.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import pytest
24+
25+
from livekit.api import CreateRoomRequest, Room
26+
from livekit.api.twirp_client import REQUEST_ID_HEADER, TwirpClient
27+
28+
HOST = "https://primary.example.livekit.cloud"
29+
30+
31+
class _FakeResponse:
32+
def __init__(
33+
self,
34+
status: int,
35+
*,
36+
body: bytes = b"",
37+
json_data: dict | None = None,
38+
headers: dict[str, str] | None = None,
39+
) -> None:
40+
self.status = status
41+
self._body = body
42+
self._json = json_data if json_data is not None else {}
43+
self.headers = headers or {}
44+
45+
async def read(self) -> bytes:
46+
return self._body
47+
48+
async def json(self) -> dict:
49+
return self._json
50+
51+
async def __aenter__(self) -> _FakeResponse:
52+
return self
53+
54+
async def __aexit__(self, *exc) -> None:
55+
return None
56+
57+
58+
class _FakeSession:
59+
"""Records the headers of every request; replays ``statuses`` in order for
60+
the Twirp POSTs and serves ``regions`` from /settings/regions."""
61+
62+
timeout = None
63+
64+
def __init__(self, statuses: list[int], regions: list[str] | None = None) -> None:
65+
self.post_headers: list[dict[str, str]] = []
66+
self._statuses = list(statuses)
67+
self._regions = regions or []
68+
69+
def post(self, url, headers=None, data=None, timeout=None) -> _FakeResponse:
70+
self.post_headers.append(dict(headers or {}))
71+
status = self._statuses.pop(0) if self._statuses else 200
72+
return _FakeResponse(status)
73+
74+
def get(self, url, headers=None, timeout=None) -> _FakeResponse:
75+
return _FakeResponse(
76+
200,
77+
json_data={"regions": [{"url": u} for u in self._regions]},
78+
# Never cache, so each test discovers its own region list.
79+
headers={"Cache-Control": "max-age=0"},
80+
)
81+
82+
83+
async def _call(client: TwirpClient, headers: dict[str, str]) -> Room:
84+
return await client.request("RoomService", "CreateRoom", CreateRoomRequest(), headers, Room)
85+
86+
87+
def _request_ids(session: _FakeSession) -> list[str | None]:
88+
return [h.get(REQUEST_ID_HEADER) for h in session.post_headers]
89+
90+
91+
# The header lets the server dedup a request that the SDK replayed.
92+
async def test_stamps_a_request_id():
93+
session = _FakeSession([200, 200])
94+
client = TwirpClient(session, HOST, "livekit", failover=False) # type: ignore[arg-type]
95+
96+
await _call(client, {})
97+
await _call(client, {})
98+
99+
ids = _request_ids(session)
100+
assert all(ids)
101+
# A new logical call is a new request, so it gets its own id.
102+
assert ids[0] != ids[1]
103+
104+
105+
async def test_preserves_caller_request_id():
106+
session = _FakeSession([200])
107+
client = TwirpClient(session, HOST, "livekit", failover=False) # type: ignore[arg-type]
108+
109+
# Matched case-insensitively, as HTTP header names are.
110+
await _call(client, {"x-livekit-request-id": "caller-123"})
111+
112+
assert session.post_headers[0]["x-livekit-request-id"] == "caller-123"
113+
assert REQUEST_ID_HEADER not in session.post_headers[0]
114+
115+
116+
# The id is generated once per logical call, so every failover attempt must
117+
# carry the same value.
118+
async def test_same_request_id_across_failover_attempts():
119+
session = _FakeSession(
120+
[503, 503, 200],
121+
regions=["wss://r1.example.livekit.cloud", "wss://r2.example.livekit.cloud"],
122+
)
123+
# _failover_force bypasses the cloud-host check; a zero backoff keeps it fast.
124+
client = TwirpClient(
125+
session, # type: ignore[arg-type]
126+
HOST,
127+
"livekit",
128+
_failover_force=True,
129+
_failover_backoff=0,
130+
)
131+
132+
await _call(client, {})
133+
134+
ids = _request_ids(session)
135+
assert len(ids) == 3
136+
assert ids[0]
137+
assert len(set(ids)) == 1
138+
139+
140+
if __name__ == "__main__":
141+
pytest.main([__file__])

0 commit comments

Comments
 (0)