Skip to content

Commit 6c6540e

Browse files
davidzhaoclaude
andcommitted
rpc interceptors: keep in-chain TimeoutError an application error
asyncio.wait_for raises the same TimeoutError a handler or interceptor might raise on its own (an HTTP client timing out, say), so _run_incoming_chain reported both as RESPONSE_TIMEOUT. The pre-existing handler wrapper had the same conflation, but now that the deadline spans the chain it is cheap to get right: a TimeoutError raised inside the chain is tagged (_ChainTimeoutError) and re-raised as the original exception, which _handle_rpc_method_invocation maps to APPLICATION_ERROR like any other handler failure; only wait_for's own expiry becomes RESPONSE_TIMEOUT. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent d4e9e0f commit 6c6540e

2 files changed

Lines changed: 56 additions & 1 deletion

File tree

livekit-rtc/livekit/rtc/participant.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,17 @@ def disconnect_reason(
236236

237237

238238
RpcHandler = Callable[["RpcInvocationData"], Union[Awaitable[Optional[str]], Optional[str]]]
239+
240+
241+
class _ChainTimeoutError(Exception):
242+
"""A ``TimeoutError`` raised *inside* the incoming RPC chain (by the handler or an
243+
interceptor), wrapped so it is distinguishable from the response deadline expiring."""
244+
245+
def __init__(self, error: asyncio.TimeoutError) -> None:
246+
super().__init__(str(error))
247+
self.error = error
248+
249+
239250
F = TypeVar(
240251
"F", bound=Callable[[RpcInvocationData], Union[Awaitable[Optional[str]], Optional[str]]]
241252
)
@@ -633,10 +644,24 @@ async def _run_incoming_chain(self, invocation: RpcInvocationData) -> Optional[s
633644
``next`` counts against it; when it passes, the chain is cancelled and the caller
634645
gets ``RESPONSE_TIMEOUT``. Cancellation from outside (the room disconnecting) maps to
635646
``RECIPIENT_DISCONNECTED``, as before.
647+
648+
A ``TimeoutError`` raised by the handler or an interceptor itself (an HTTP client
649+
timing out, say) is not the response deadline: it propagates as an application
650+
error rather than being reported to the caller as ``RESPONSE_TIMEOUT``.
636651
"""
637652
handle = _chain_incoming(list(self._rpc_interceptors), self._invoke_rpc_handler)
653+
654+
async def _guarded() -> Optional[str]:
655+
try:
656+
return await handle(invocation)
657+
except asyncio.TimeoutError as e:
658+
# tag it so it cannot be mistaken for wait_for's own deadline expiry below
659+
raise _ChainTimeoutError(e) from e
660+
638661
try:
639-
return await asyncio.wait_for(handle(invocation), timeout=invocation.response_timeout)
662+
return await asyncio.wait_for(_guarded(), timeout=invocation.response_timeout)
663+
except _ChainTimeoutError as e:
664+
raise e.error from e.error.__cause__
640665
except asyncio.TimeoutError:
641666
raise RpcError._built_in(RpcError.ErrorCode.RESPONSE_TIMEOUT) from None
642667
except asyncio.CancelledError:

livekit-rtc/tests/test_rpc_interceptors.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,36 @@ async def fast(data: RpcInvocationData) -> str:
242242
assert handler_ran.is_set() == (delay_position == "after_next")
243243

244244

245+
async def test_timeout_raised_inside_the_chain_is_not_the_response_deadline() -> None:
246+
"""An interceptor (or handler) whose own I/O times out raises TimeoutError well before
247+
the response deadline. That is an application failure, not RESPONSE_TIMEOUT."""
248+
lp = _participant()
249+
250+
class UpstreamTimesOut(rtc.RpcInterceptor):
251+
async def intercept_incoming(
252+
self, invocation: RpcInvocationData, next: IncomingRpcNext
253+
) -> Optional[str]:
254+
raise asyncio.TimeoutError("upstream lookup timed out")
255+
256+
lp.add_rpc_interceptor(UpstreamTimesOut())
257+
lp._rpc_handlers["m"] = lambda data: "unused"
258+
259+
# propagates as the original TimeoutError, which _handle_rpc_method_invocation maps to
260+
# APPLICATION_ERROR like any other handler exception
261+
with pytest.raises(asyncio.TimeoutError) as info:
262+
await lp._run_incoming_chain(RpcInvocationData("r1", "alice", "{}", 5.0, method="m"))
263+
assert not isinstance(info.value, rtc.RpcError)
264+
assert str(info.value) == "upstream lookup timed out"
265+
266+
async def handler_times_out(data: RpcInvocationData) -> str:
267+
raise asyncio.TimeoutError("db timed out")
268+
269+
lp2 = _participant()
270+
lp2._rpc_handlers["m"] = handler_times_out
271+
with pytest.raises(asyncio.TimeoutError):
272+
await lp2._run_incoming_chain(RpcInvocationData("r2", "alice", "{}", 5.0, method="m"))
273+
274+
245275
async def test_outside_cancellation_maps_to_recipient_disconnected() -> None:
246276
lp = _participant()
247277
started = asyncio.Event()

0 commit comments

Comments
 (0)