2828import json
2929import os
3030import urllib .request
31+ from typing import List , Optional
3132
3233import aiohttp
3334import pytest
3435
3536from livekit .api import CreateRoomRequest , Room , ServerError
36- from livekit .api .twirp_client import TwirpClient
37+ from livekit .api .twirp_client import REQUEST_ID_HEADER , TwirpClient
3738
3839BASE = os .getenv ("LK_TEST_SERVER_URL" , "http://127.0.0.1:9999" )
3940
@@ -53,8 +54,15 @@ def _server_up() -> bool:
5354
5455# _failover_force bypasses the cloud-host check (the mock is on 127.0.0.1) and a
5556# tiny backoff keeps the tests fast — both are internal, test-only knobs.
56- async def _call (mock : dict , * , failover : bool = True , force : bool = True ) -> Room :
57- async with aiohttp .ClientSession () as session :
57+ async def _call (
58+ mock : dict ,
59+ * ,
60+ failover : bool = True ,
61+ force : bool = True ,
62+ extra_headers : Optional [dict ] = None ,
63+ trace_configs : Optional [List [aiohttp .TraceConfig ]] = None ,
64+ ) -> Room :
65+ async with aiohttp .ClientSession (trace_configs = trace_configs ) as session :
5866 client = TwirpClient (
5967 session ,
6068 BASE ,
@@ -67,6 +75,7 @@ async def _call(mock: dict, *, failover: bool = True, force: bool = True) -> Roo
6775 "authorization" : "Bearer test-token" ,
6876 # These tests exercise failover, not authz; skip the mock's permission check.
6977 "X-Lk-Mock" : json .dumps ({"skipAuth" : True , ** mock }),
78+ ** (extra_headers or {}),
7079 }
7180 return await client .request ("RoomService" , "CreateRoom" , CreateRoomRequest (), headers , Room )
7281
@@ -113,3 +122,38 @@ def test_disabled():
113122 # failover=False disables failover entirely.
114123 with pytest .raises (ServerError ):
115124 asyncio .run (_call ({"failRegions" : [0 ]}, failover = False ))
125+
126+
127+ # Records the request id header(s) the SDK put on the wire for each Twirp
128+ # attempt. Region discovery is a separate request, so it is not recorded.
129+ def _request_id_recorder (seen : List [List [str ]]) -> aiohttp .TraceConfig :
130+ trace = aiohttp .TraceConfig ()
131+
132+ async def on_request_start (_session , _ctx , params ) -> None :
133+ if not params .url .path .endswith ("/settings/regions" ):
134+ seen .append (list (params .headers .getall (REQUEST_ID_HEADER , [])))
135+
136+ trace .on_request_start .append (on_request_start )
137+ return trace
138+
139+
140+ def test_request_id_stable_across_attempts ():
141+ # The id is generated once per logical call, so a replayed request carries
142+ # the same idempotency key on every attempt and the server can dedup it.
143+ seen : List [List [str ]] = []
144+ asyncio .run (_call ({"failRegions" : [0 , 1 ]}, trace_configs = [_request_id_recorder (seen )]))
145+ assert len (seen ) == 3 # primary + two fallbacks
146+ assert all (len (ids ) == 1 for ids in seen ) # never duplicated
147+ assert seen [0 ][0 ]
148+ assert len ({ids [0 ] for ids in seen }) == 1
149+
150+
151+ def test_request_id_unique_per_call ():
152+ # A new logical call is a new request, so it gets its own id.
153+ seen : List [List [str ]] = []
154+ recorder = _request_id_recorder (seen )
155+ asyncio .run (_call ({}, trace_configs = [recorder ]))
156+ asyncio .run (_call ({}, trace_configs = [recorder ]))
157+ assert len (seen ) == 2
158+ assert seen [0 ][0 ] and seen [1 ][0 ]
159+ assert seen [0 ][0 ] != seen [1 ][0 ]
0 commit comments