From 81c5dab52e3bcd68656d0ff76519f15d7ef682e6 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Tue, 30 Jun 2026 17:37:52 +0200 Subject: [PATCH 1/3] ensure request timeout is always longer than ringing_timeout to avoid the situation where a user's dial request is aborted, but still ringing --- lib/livekit/sip_service_client.rb | 39 ++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/livekit/sip_service_client.rb b/lib/livekit/sip_service_client.rb index d76850c..4e6a2d3 100644 --- a/lib/livekit/sip_service_client.rb +++ b/lib/livekit/sip_service_client.rb @@ -13,6 +13,11 @@ class SIPServiceClient < Twirp::Client # TransferSIPParticipant) take longer than a normal request. SIP_DIAL_TIMEOUT = 30 + # A dialing request must outlast the ringing window, or it would abort before + # the call can be answered. Keep the request timeout at least this many + # seconds above the ringing timeout. + RINGING_TIMEOUT_MARGIN = 2 + def initialize(base_url, api_key: nil, api_secret: nil, failover: true) super(LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @@ -205,7 +210,10 @@ def create_sip_participant( # Optional, enable Krisp for this call krisp_enabled: false, # Optional, wait for the call to be answered before returning - wait_until_answered: false + wait_until_answered: false, + # Optional, request timeout in seconds. Defaults to a longer value when + # wait_until_answered is set (dialing takes time). + timeout: nil ) request = Proto::CreateSIPParticipantRequest.new( sip_trunk_id: sip_trunk_id, @@ -224,8 +232,10 @@ def create_sip_participant( wait_until_answered: wait_until_answered ) headers = auth_header(sip_grant: SIPGrant.new(call: true)) - # Dialing a phone and waiting for an answer takes longer than a normal request. - headers[Failover::TIMEOUT_HEADER] = SIP_DIAL_TIMEOUT.to_s if wait_until_answered + # When waiting for an answer, dialing takes longer than a normal request + # and the request must outlast ringing; otherwise honor any user timeout. + effective_timeout = wait_until_answered ? sip_dial_timeout(timeout, ringing_timeout) : timeout + headers[Failover::TIMEOUT_HEADER] = effective_timeout.to_s if effective_timeout self.rpc(:CreateSIPParticipant, request, headers: headers) end @@ -233,7 +243,12 @@ def transfer_sip_participant( room_name, participant_identity, transfer_to, - play_dialtone: nil + play_dialtone: nil, + # Optional, max time for the transfer destination to answer, in seconds. + ringing_timeout: nil, + # Optional, request timeout in seconds. Defaults to a longer value since + # transferring dials a phone. + timeout: nil ) request = Proto::TransferSIPParticipantRequest.new( @@ -241,13 +256,25 @@ def transfer_sip_participant( participant_identity: participant_identity, transfer_to: transfer_to, play_dialtone: play_dialtone, + ringing_timeout: ringing_timeout, ) headers = auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name), sip_grant: SIPGrant.new(call: true)) - # Transferring a call dials a phone, which takes longer than a normal request. - headers[Failover::TIMEOUT_HEADER] = SIP_DIAL_TIMEOUT.to_s + # Transferring a call dials a phone, which takes longer than a normal + # request, so use a longer default unless the user specified a timeout, and + # keep the request alive past ringing so the destination can answer. + headers[Failover::TIMEOUT_HEADER] = sip_dial_timeout(timeout, ringing_timeout).to_s self.rpc(:TransferSIPParticipant, request, headers: headers) end + # Request timeout (seconds) for a phone-dialing call: the user value (or the + # dial default) raised to stay at least RINGING_TIMEOUT_MARGIN above the + # ringing timeout, so the request doesn't abort before the call is answered. + private def sip_dial_timeout(timeout, ringing_timeout) + effective = timeout || SIP_DIAL_TIMEOUT + effective = [effective, ringing_timeout + RINGING_TIMEOUT_MARGIN].max if ringing_timeout + effective + end + # Updates an existing SIP inbound trunk, replacing it entirely. # # @param sip_trunk_id [String] ID of the SIP inbound trunk to update From 044aeec6531ce5a559016e96877a8944e7d205b5 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Wed, 1 Jul 2026 10:23:12 +0200 Subject: [PATCH 2/3] handle connectors timeout --- lib/livekit/connector_service_client.rb | 21 +++++++++++++++------ lib/livekit/dial_timeout.rb | 24 ++++++++++++++++++++++++ lib/livekit/sip_service_client.rb | 23 +++-------------------- 3 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 lib/livekit/dial_timeout.rb diff --git a/lib/livekit/connector_service_client.rb b/lib/livekit/connector_service_client.rb index fcdca66..31cbb76 100644 --- a/lib/livekit/connector_service_client.rb +++ b/lib/livekit/connector_service_client.rb @@ -2,6 +2,7 @@ require "livekit/auth_mixin" require 'livekit/utils' require 'livekit/failover' +require 'livekit/dial_timeout' module LiveKit # Client for LiveKit's Connector service, bridging WhatsApp and Twilio calls @@ -33,13 +34,21 @@ def dial_whatsapp_call(request) # Accepts an inbound WhatsApp call. # @param request [Proto::AcceptWhatsAppCallRequest] + # @param timeout [Numeric, nil] optional request timeout in seconds. When the + # request waits for an answer, it defaults to a longer value (dialing takes + # time) and is raised, if needed, to stay above the request's ringing_timeout. # @return [Proto::AcceptWhatsAppCallResponse] - def accept_whatsapp_call(request) - self.rpc( - :AcceptWhatsAppCall, - request, - headers: auth_header(video_grant: VideoGrant.new(roomCreate: true)), - ) + def accept_whatsapp_call(request, timeout: nil) + headers = auth_header(video_grant: VideoGrant.new(roomCreate: true)) + # When waiting for an answer, dialing takes longer than a normal request and + # the request must outlast ringing; otherwise honor any user timeout. + if request.wait_until_answered + ringing = request.ringing_timeout&.seconds + headers[Failover::TIMEOUT_HEADER] = DialTimeout.resolve(timeout, ringing).to_s + elsif timeout + headers[Failover::TIMEOUT_HEADER] = timeout.to_s + end + self.rpc(:AcceptWhatsAppCall, request, headers: headers) end # Connects an established WhatsApp call (used for business-initiated calls). diff --git a/lib/livekit/dial_timeout.rb b/lib/livekit/dial_timeout.rb new file mode 100644 index 0000000..3e8c6f6 --- /dev/null +++ b/lib/livekit/dial_timeout.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module LiveKit + # Request-timeout handling shared by calls that dial a phone and wait for an + # answer (SIP CreateSIPParticipant/TransferSIPParticipant, WhatsApp + # AcceptWhatsAppCall). These take longer than a normal request, and the request + # must outlast ringing or it would abort before the call can be answered. + module DialTimeout + # Default request timeout (seconds) for calls that dial a phone. + DIAL_TIMEOUT = 30 + # Keep the request timeout at least this many seconds above the ringing + # timeout, so the request doesn't abort before the call can be answered. + RINGING_TIMEOUT_MARGIN = 2 + + # Request timeout (seconds): a user-supplied value (or the dial default) + # raised, when needed, to stay at least RINGING_TIMEOUT_MARGIN above the + # ringing timeout (also in seconds; nil when unset). + def self.resolve(timeout, ringing_timeout) + effective = timeout || DIAL_TIMEOUT + effective = [effective, ringing_timeout + RINGING_TIMEOUT_MARGIN].max if ringing_timeout + effective + end + end +end diff --git a/lib/livekit/sip_service_client.rb b/lib/livekit/sip_service_client.rb index 4e6a2d3..938d21a 100644 --- a/lib/livekit/sip_service_client.rb +++ b/lib/livekit/sip_service_client.rb @@ -2,6 +2,7 @@ require "livekit/auth_mixin" require 'livekit/utils' require 'livekit/failover' +require 'livekit/dial_timeout' module LiveKit class SIPServiceClient < Twirp::Client @@ -9,15 +10,6 @@ class SIPServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - # Calls that dial a phone (CreateSIPParticipant with wait_until_answered, - # TransferSIPParticipant) take longer than a normal request. - SIP_DIAL_TIMEOUT = 30 - - # A dialing request must outlast the ringing window, or it would abort before - # the call can be answered. Keep the request timeout at least this many - # seconds above the ringing timeout. - RINGING_TIMEOUT_MARGIN = 2 - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) super(LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @@ -234,7 +226,7 @@ def create_sip_participant( headers = auth_header(sip_grant: SIPGrant.new(call: true)) # When waiting for an answer, dialing takes longer than a normal request # and the request must outlast ringing; otherwise honor any user timeout. - effective_timeout = wait_until_answered ? sip_dial_timeout(timeout, ringing_timeout) : timeout + effective_timeout = wait_until_answered ? DialTimeout.resolve(timeout, ringing_timeout) : timeout headers[Failover::TIMEOUT_HEADER] = effective_timeout.to_s if effective_timeout self.rpc(:CreateSIPParticipant, request, headers: headers) end @@ -262,19 +254,10 @@ def transfer_sip_participant( # Transferring a call dials a phone, which takes longer than a normal # request, so use a longer default unless the user specified a timeout, and # keep the request alive past ringing so the destination can answer. - headers[Failover::TIMEOUT_HEADER] = sip_dial_timeout(timeout, ringing_timeout).to_s + headers[Failover::TIMEOUT_HEADER] = DialTimeout.resolve(timeout, ringing_timeout).to_s self.rpc(:TransferSIPParticipant, request, headers: headers) end - # Request timeout (seconds) for a phone-dialing call: the user value (or the - # dial default) raised to stay at least RINGING_TIMEOUT_MARGIN above the - # ringing timeout, so the request doesn't abort before the call is answered. - private def sip_dial_timeout(timeout, ringing_timeout) - effective = timeout || SIP_DIAL_TIMEOUT - effective = [effective, ringing_timeout + RINGING_TIMEOUT_MARGIN].max if ringing_timeout - effective - end - # Updates an existing SIP inbound trunk, replacing it entirely. # # @param sip_trunk_id [String] ID of the SIP inbound trunk to update From 4f734150937f9285f28b0256d51691d391774822 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Wed, 1 Jul 2026 16:17:29 +0200 Subject: [PATCH 3/3] handle whatsapp correctly --- lib/livekit/connector_service_client.rb | 9 +++++---- lib/livekit/dial_timeout.rb | 18 ++++++++++-------- lib/livekit/sip_service_client.rb | 9 ++++++--- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/livekit/connector_service_client.rb b/lib/livekit/connector_service_client.rb index 31cbb76..f89a718 100644 --- a/lib/livekit/connector_service_client.rb +++ b/lib/livekit/connector_service_client.rb @@ -40,11 +40,12 @@ def dial_whatsapp_call(request) # @return [Proto::AcceptWhatsAppCallResponse] def accept_whatsapp_call(request, timeout: nil) headers = auth_header(video_grant: VideoGrant.new(roomCreate: true)) - # When waiting for an answer, dialing takes longer than a normal request and - # the request must outlast ringing; otherwise honor any user timeout. + # Accept can block until the call is answered, so default the request timeout + # to the standard ring window; otherwise honor any user timeout. The caller + # overrides via +timeout+ and should set it above the ringing_timeout passed + # to dial_whatsapp_call (the two calls are separate). if request.wait_until_answered - ringing = request.ringing_timeout&.seconds - headers[Failover::TIMEOUT_HEADER] = DialTimeout.resolve(timeout, ringing).to_s + headers[Failover::TIMEOUT_HEADER] = (timeout || DialTimeout::DEFAULT_RINGING_TIMEOUT).to_s elsif timeout headers[Failover::TIMEOUT_HEADER] = timeout.to_s end diff --git a/lib/livekit/dial_timeout.rb b/lib/livekit/dial_timeout.rb index 3e8c6f6..d67101e 100644 --- a/lib/livekit/dial_timeout.rb +++ b/lib/livekit/dial_timeout.rb @@ -6,19 +6,21 @@ module LiveKit # AcceptWhatsAppCall). These take longer than a normal request, and the request # must outlast ringing or it would abort before the call can be answered. module DialTimeout - # Default request timeout (seconds) for calls that dial a phone. - DIAL_TIMEOUT = 30 + # Ring window (seconds) assumed when a request doesn't set a ringing timeout; + # matches the server default. A dialing request must outlast it. + DEFAULT_RINGING_TIMEOUT = 30 # Keep the request timeout at least this many seconds above the ringing # timeout, so the request doesn't abort before the call can be answered. RINGING_TIMEOUT_MARGIN = 2 - # Request timeout (seconds): a user-supplied value (or the dial default) - # raised, when needed, to stay at least RINGING_TIMEOUT_MARGIN above the - # ringing timeout (also in seconds; nil when unset). + # Request timeout (seconds): the ring window plus a margin, so the request + # doesn't abort before the call can be answered. The ring window is + # +ringing_timeout+ (seconds) when set, else DEFAULT_RINGING_TIMEOUT. A longer + # user-supplied +timeout+ is honored; a shorter one is raised to the floor. def self.resolve(timeout, ringing_timeout) - effective = timeout || DIAL_TIMEOUT - effective = [effective, ringing_timeout + RINGING_TIMEOUT_MARGIN].max if ringing_timeout - effective + ring = ringing_timeout || DEFAULT_RINGING_TIMEOUT + floor = ring + RINGING_TIMEOUT_MARGIN + [timeout || floor, floor].max end end end diff --git a/lib/livekit/sip_service_client.rb b/lib/livekit/sip_service_client.rb index 938d21a..a9920c9 100644 --- a/lib/livekit/sip_service_client.rb +++ b/lib/livekit/sip_service_client.rb @@ -207,6 +207,9 @@ def create_sip_participant( # wait_until_answered is set (dialing takes time). timeout: nil ) + # When waiting for an answer, pin the ring window explicitly so our request + # timeout doesn't depend on the server's default (which could change). + ringing_timeout = DialTimeout::DEFAULT_RINGING_TIMEOUT if wait_until_answered && ringing_timeout.nil? request = Proto::CreateSIPParticipantRequest.new( sip_trunk_id: sip_trunk_id, sip_call_to: sip_call_to, @@ -243,6 +246,9 @@ def transfer_sip_participant( timeout: nil ) + # Transferring a call dials a phone and must outlast ringing. Pin the ring + # window explicitly so our request timeout doesn't depend on the server default. + ringing_timeout ||= DialTimeout::DEFAULT_RINGING_TIMEOUT request = Proto::TransferSIPParticipantRequest.new( room_name: room_name, participant_identity: participant_identity, @@ -251,9 +257,6 @@ def transfer_sip_participant( ringing_timeout: ringing_timeout, ) headers = auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name), sip_grant: SIPGrant.new(call: true)) - # Transferring a call dials a phone, which takes longer than a normal - # request, so use a longer default unless the user specified a timeout, and - # keep the request alive past ringing so the destination can answer. headers[Failover::TIMEOUT_HEADER] = DialTimeout.resolve(timeout, ringing_timeout).to_s self.rpc(:TransferSIPParticipant, request, headers: headers) end