Skip to content

Commit 35dc7eb

Browse files
improve timeout behavior (#738)
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 3f92f3c commit 35dc7eb

6 files changed

Lines changed: 131 additions & 23 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional, Union
4+
5+
from livekit.protocol.connector_whatsapp import AcceptWhatsAppCallRequest
6+
from livekit.protocol.sip import CreateSIPParticipantRequest, TransferSIPParticipantRequest
7+
8+
# Requests that carry wait_until_answered / ringing_timeout and share the
9+
# phone-dialing timeout behavior.
10+
DialRequest = Union[
11+
CreateSIPParticipantRequest,
12+
TransferSIPParticipantRequest,
13+
AcceptWhatsAppCallRequest,
14+
]
15+
"""@private"""
16+
17+
# Ring window (seconds) assumed when a request doesn't set ringing_timeout;
18+
# matches the server default. A dialing request must outlast it.
19+
DEFAULT_RINGING_TIMEOUT = 30.0
20+
"""@private"""
21+
22+
# A dialing request must outlast the ringing window, or it would abort before
23+
# the call can be answered. Keep the request timeout at least this many seconds
24+
# above the ringing timeout.
25+
RINGING_TIMEOUT_MARGIN = 2.0
26+
"""@private"""
27+
28+
29+
def pin_ringing_timeout(request: DialRequest) -> None:
30+
"""Set the ring window explicitly on a dialing request when the caller left it
31+
unset, so the derived request timeout doesn't depend on the server's default
32+
(which could change out from under us).
33+
34+
@private
35+
"""
36+
if not request.HasField("ringing_timeout"):
37+
request.ringing_timeout.seconds = int(DEFAULT_RINGING_TIMEOUT)
38+
39+
40+
def dial_timeout(user_timeout: Optional[float], request: DialRequest) -> float:
41+
"""Request timeout (seconds) for a phone-dialing call: the ring window plus a
42+
margin, so the request doesn't abort before the call can be answered. The
43+
ring window is the request's ringing_timeout when set, else
44+
DEFAULT_RINGING_TIMEOUT. A longer user_timeout is honored; a shorter one is
45+
raised to the floor.
46+
47+
@private
48+
"""
49+
if request.HasField("ringing_timeout"):
50+
ring: float = request.ringing_timeout.seconds
51+
else:
52+
ring = DEFAULT_RINGING_TIMEOUT
53+
floor = ring + RINGING_TIMEOUT_MARGIN
54+
return max(user_timeout if user_timeout else floor, floor)

livekit-api/livekit/api/_failover.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,28 @@
3131

3232
FAILOVER_MAX_ATTEMPTS = 3
3333
FAILOVER_BACKOFF_BASE = 0.2 # seconds
34-
35-
36-
def failover_attempts(enabled: bool, host: Optional[str], force: bool = False) -> int:
34+
# Below this per-request timeout (seconds) a retry is unlikely to help and many
35+
# clients would retry in lockstep across regions, so a short request gets a
36+
# single attempt (thundering-herd guard).
37+
MIN_FAILOVER_TIMEOUT = 5.0
38+
39+
40+
def failover_attempts(
41+
enabled: bool,
42+
host: Optional[str],
43+
force: bool = False,
44+
timeout: Optional[float] = None,
45+
) -> int:
3746
"""Total request attempts for a host; 1 means no failover. Failover only
38-
engages when enabled and the host is a LiveKit Cloud domain. ``force``
39-
bypasses the cloud-host check and is for internal testing only.
47+
engages when enabled, the host is a LiveKit Cloud domain, and the request
48+
timeout is long enough to retry. ``force`` bypasses the cloud-host check and
49+
is for internal testing only.
4050
"""
41-
if enabled and (force or (host is not None and is_cloud(host))):
42-
return FAILOVER_MAX_ATTEMPTS
43-
return 1
51+
if not (enabled and (force or (host is not None and is_cloud(host)))):
52+
return 1
53+
if timeout is not None and 0 < timeout < MIN_FAILOVER_TIMEOUT:
54+
return 1
55+
return FAILOVER_MAX_ATTEMPTS
4456

4557

4658
def is_cloud(host: str) -> bool:

livekit-api/livekit/api/connector_service.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import aiohttp
4+
from typing import Optional
45

56
from livekit.protocol.connector_whatsapp import (
67
DialWhatsAppCallRequest,
@@ -17,6 +18,7 @@
1718
ConnectTwilioCallResponse,
1819
)
1920
from ._service import Service
21+
from ._dial_timeout import DEFAULT_RINGING_TIMEOUT
2022
from .access_token import VideoGrants
2123

2224
SVC = "Connector"
@@ -106,23 +108,42 @@ async def connect_whatsapp_call(
106108
)
107109

108110
async def accept_whatsapp_call(
109-
self, request: AcceptWhatsAppCallRequest
111+
self,
112+
request: AcceptWhatsAppCallRequest,
113+
*,
114+
timeout: Optional[float] = None,
110115
) -> AcceptWhatsAppCallResponse:
111116
"""
112117
Accept an inbound WhatsApp call
113118
114119
Args:
115120
request: AcceptWhatsAppCallRequest containing call parameters and SDP
121+
timeout: Optional request timeout in seconds. When the request waits
122+
for an answer (wait_until_answered), it defaults to the standard
123+
ring window; set it above the ringing_timeout passed to
124+
dial_whatsapp_call (the two calls are separate, so the SDK can't
125+
derive it).
116126
117127
Returns:
118128
AcceptWhatsAppCallResponse with the room name
119129
"""
130+
client_timeout: Optional[aiohttp.ClientTimeout] = None
131+
if request.wait_until_answered:
132+
# Accept can block until the call is answered, so default to the
133+
# standard ring window; the caller overrides via timeout.
134+
client_timeout = aiohttp.ClientTimeout(
135+
total=timeout if timeout else DEFAULT_RINGING_TIMEOUT
136+
)
137+
elif timeout:
138+
client_timeout = aiohttp.ClientTimeout(total=timeout)
139+
120140
return await self._client.request(
121141
SVC,
122142
"AcceptWhatsAppCall",
123143
request,
124144
self._auth_header(VideoGrants(room_create=True)),
125145
AcceptWhatsAppCallResponse,
146+
timeout=client_timeout,
126147
)
127148

128149
async def connect_twilio_call(

livekit-api/livekit/api/livekit_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def __init__(
3939
url: LiveKit server URL (read from `LIVEKIT_URL` environment variable if not provided)
4040
api_key: API key (read from `LIVEKIT_API_KEY` environment variable if not provided)
4141
api_secret: API secret (read from `LIVEKIT_API_SECRET` environment variable if not provided)
42-
timeout: Request timeout (default: 60 seconds)
42+
timeout: Request timeout (default: 10 seconds)
4343
session: aiohttp.ClientSession instance to use for requests, if not provided, a new one will be created
4444
"""
4545
url = url or os.getenv("LIVEKIT_URL")
@@ -57,7 +57,7 @@ def __init__(
5757
if not self._session:
5858
self._custom_session = False
5959
if not timeout:
60-
timeout = aiohttp.ClientTimeout(total=60)
60+
timeout = aiohttp.ClientTimeout(total=10)
6161
self._session = aiohttp.ClientSession(timeout=timeout)
6262

6363
self._room = RoomService(self._session, url, api_key, api_secret, failover)

livekit-api/livekit/api/sip_service.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
SIPMediaConfig,
3737
)
3838
from ._service import Service
39+
from ._dial_timeout import (
40+
dial_timeout as _dial_timeout,
41+
pin_ringing_timeout as _pin_ringing_timeout,
42+
)
3943
from .access_token import VideoGrants, SIPGrants
4044

4145
SVC = "SIP"
@@ -786,17 +790,15 @@ async def create_sip_participant(
786790
SIPError: If the SIP operation fails
787791
"""
788792
client_timeout: Optional[aiohttp.ClientTimeout] = None
789-
if timeout:
790-
# obay user specified timeout
793+
if create.wait_until_answered:
794+
# Dialing a phone and waiting for an answer takes longer than a
795+
# normal call, and the request must outlast ringing. Pin the ring
796+
# window so the timeout doesn't depend on the server's default.
797+
_pin_ringing_timeout(create)
798+
client_timeout = aiohttp.ClientTimeout(total=_dial_timeout(timeout, create))
799+
elif timeout:
800+
# obey user specified timeout
791801
client_timeout = aiohttp.ClientTimeout(total=timeout)
792-
elif create.wait_until_answered:
793-
# ensure default timeout isn't too short when using sync mode
794-
if (
795-
self._client._session.timeout
796-
and self._client._session.timeout.total
797-
and self._client._session.timeout.total < 20
798-
):
799-
client_timeout = aiohttp.ClientTimeout(total=20)
800802

801803
if trunk_id:
802804
create.sip_trunk_id = trunk_id
@@ -814,16 +816,27 @@ async def create_sip_participant(
814816
)
815817

816818
async def transfer_sip_participant(
817-
self, transfer: TransferSIPParticipantRequest
819+
self,
820+
transfer: TransferSIPParticipantRequest,
821+
*,
822+
timeout: Optional[float] = None,
818823
) -> SIPParticipantInfo:
819824
"""Transfer a SIP participant to a different room.
820825
821826
Args:
822827
transfer: Request containing transfer details
828+
timeout: Optional request timeout in seconds. Transferring dials a
829+
phone, which takes longer than normal, so it defaults to a
830+
longer timeout when unset.
823831
824832
Returns:
825833
Updated SIP participant information
826834
"""
835+
# Transferring a call dials a phone, which takes longer than a normal
836+
# call, so keep the request alive past ringing. Pin the ring window so the
837+
# timeout doesn't depend on the server's default.
838+
_pin_ringing_timeout(transfer)
839+
client_timeout = aiohttp.ClientTimeout(total=_dial_timeout(timeout, transfer))
827840
return await self._client.request(
828841
SVC,
829842
"TransferSIPParticipant",
@@ -836,6 +849,7 @@ async def transfer_sip_participant(
836849
sip=SIPGrants(call=True),
837850
),
838851
SIPParticipantInfo,
852+
timeout=client_timeout,
839853
)
840854

841855
def _admin_headers(self) -> dict[str, str]:

livekit-api/livekit/api/twirp_client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,15 @@ async def request(
150150
headers["Content-Type"] = "application/protobuf"
151151
serialized_data = data.SerializeToString()
152152

153+
# The effective per-attempt timeout is the per-call override, or the
154+
# session default; used to gate failover for short requests.
155+
effective_timeout = timeout.total if timeout else None
156+
if effective_timeout is None and self._session.timeout is not None:
157+
effective_timeout = self._session.timeout.total
153158
host = urlparse(self._origin).hostname
154-
max_attempts = failover_attempts(self._failover, host, self._failover_force)
159+
max_attempts = failover_attempts(
160+
self._failover, host, self._failover_force, effective_timeout
161+
)
155162
attempted = {host_key(self._origin)}
156163
region_origins: Optional[List[str]] = None
157164
current_origin = self._origin

0 commit comments

Comments
 (0)