Skip to content

Commit 0016ecd

Browse files
authored
feat: auto failover APIs with LK Cloud (#686)
* feat: auto failover APIs with LK Cloud retries in alternative datacenters on 5xx and transport failures * Add auto failover APIs for LK Cloud Add auto failover APIs for LK Cloud in livekit-server-sdk. * fixed formatting * simplified API * fmt * remove failover options * sip perms check on failover test * sane default timeouts: 10s for API, 30s for SIP dials * include dial buffer for SIP * address comments * handle whatsapp dial timeout as well * explicitly set default ringing time * correctly handle whatsapp dial/accept flow
1 parent ee6036c commit 0016ecd

18 files changed

Lines changed: 767 additions & 105 deletions

.changeset/wet-dryers-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"livekit-server-sdk": patch
3+
---
4+
5+
feat: auto failover APIs with LK Cloud

.github/workflows/test-api.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
name: Test API
6+
7+
permissions:
8+
contents: read
9+
10+
on:
11+
workflow_dispatch:
12+
push:
13+
branches: [main]
14+
pull_request:
15+
branches: [main]
16+
17+
jobs:
18+
failover:
19+
runs-on: ubuntu-latest
20+
services:
21+
mock-server:
22+
image: livekit/test-server:latest
23+
ports:
24+
- 9999:9999
25+
- 10000:10000
26+
- 10001:10001
27+
- 10002:10002
28+
steps:
29+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
30+
31+
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
32+
33+
- name: Setup Node.js
34+
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
35+
with:
36+
node-version: 24
37+
cache: pnpm
38+
39+
- name: Install dependencies
40+
run: pnpm install
41+
42+
- name: Wait for mock server
43+
run: |
44+
for i in $(seq 1 30); do
45+
curl -sf http://127.0.0.1:9999/settings/regions >/dev/null && exit 0
46+
sleep 1
47+
done
48+
echo "mock server did not become ready" && exit 1
49+
50+
- name: Run API tests
51+
run: pnpm --filter="livekit-server-sdk" exec vitest --environment node run test/api

packages/livekit-server-sdk/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
"dependencies": {
4646
"@bufbuild/protobuf": "^1.10.1",
4747
"@livekit/protocol": "^1.48.0",
48-
"camelcase-keys": "^9.0.0",
4948
"jose": "^5.1.2"
5049
},
5150
"devDependencies": {

packages/livekit-server-sdk/src/AgentDispatchClient.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,10 @@ export class AgentDispatchClient extends ServiceBase {
4040
*/
4141
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
4242
super(apiKey, secret);
43-
const rpcOptions = options?.requestTimeout
44-
? { requestTimeout: options.requestTimeout }
45-
: undefined;
46-
this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
43+
this.rpc = new TwirpRpc(host, livekitPackage, {
44+
requestTimeout: options?.requestTimeout,
45+
failover: options?.failover,
46+
});
4747
}
4848

4949
/**

packages/livekit-server-sdk/src/ClientOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ export type ClientOptions = {
1010
* Optional timeout, in seconds, for all server requests
1111
*/
1212
requestTimeout?: number;
13+
/**
14+
* Whether to fail over to alternative regions on retryable errors (LiveKit
15+
* Cloud hosts only). Defaults to true; set to false to disable.
16+
*/
17+
failover?: boolean;
1318
};

packages/livekit-server-sdk/src/ConnectorClient.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
import type { ClientOptions } from './ClientOptions.js';
2424
import { ServiceBase } from './ServiceBase.js';
2525
import { type Rpc, TwirpRpc, livekitPackage } from './TwirpRPC.js';
26+
import { DEFAULT_RINGING_TIMEOUT_SECONDS } from './dialTimeout.js';
2627

2728
const svc = 'Connector';
2829

@@ -87,6 +88,12 @@ export interface AcceptWhatsAppCallOptions {
8788
ringingTimeout?: number;
8889
/** Optional - Wait for the call to be answered before returning */
8990
waitUntilAnswered?: boolean;
91+
/**
92+
* Optional - Request timeout in seconds. When `waitUntilAnswered` is set,
93+
* defaults to a longer value (dialing takes time) and is raised, if needed,
94+
* to stay above `ringingTimeout`; otherwise the client default applies.
95+
*/
96+
timeout?: number;
9097
}
9198

9299
// Twilio types
@@ -123,10 +130,10 @@ export class ConnectorClient extends ServiceBase {
123130
*/
124131
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
125132
super(apiKey, secret);
126-
const rpcOptions = options?.requestTimeout
127-
? { requestTimeout: options.requestTimeout }
128-
: undefined;
129-
this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
133+
this.rpc = new TwirpRpc(host, livekitPackage, {
134+
requestTimeout: options?.requestTimeout,
135+
failover: options?.failover,
136+
});
130137
}
131138

132139
/**
@@ -206,11 +213,21 @@ export class ConnectorClient extends ServiceBase {
206213
waitUntilAnswered: options.waitUntilAnswered,
207214
}).toJson();
208215

216+
// Accept can block until the call is answered, so default the request timeout
217+
// to the standard ring window. The caller overrides it via `timeout` and
218+
// should set it above the ringing_timeout passed to dialWhatsAppCall; the
219+
// two calls are separate, so the SDK can't derive it. Non-waiting returns
220+
// promptly and uses the client default.
221+
const timeout = options.waitUntilAnswered
222+
? (options.timeout ?? DEFAULT_RINGING_TIMEOUT_SECONDS)
223+
: options.timeout;
224+
209225
const data = await this.rpc.request(
210226
svc,
211227
'AcceptWhatsAppCall',
212228
req,
213229
await this.authHeader({ roomCreate: true }),
230+
timeout,
214231
);
215232
return AcceptWhatsAppCallResponse.fromJson(data, { ignoreUnknownFields: true });
216233
}

packages/livekit-server-sdk/src/EgressClient.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,10 @@ export class EgressClient extends ServiceBase {
142142
*/
143143
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
144144
super(apiKey, secret);
145-
const rpcOptions = options?.requestTimeout
146-
? { requestTimeout: options.requestTimeout }
147-
: undefined;
148-
this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
145+
this.rpc = new TwirpRpc(host, livekitPackage, {
146+
requestTimeout: options?.requestTimeout,
147+
failover: options?.failover,
148+
});
149149
}
150150

151151
/**

packages/livekit-server-sdk/src/IngressClient.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,10 @@ export class IngressClient extends ServiceBase {
129129
*/
130130
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
131131
super(apiKey, secret);
132-
const rpcOptions = options?.requestTimeout
133-
? { requestTimeout: options.requestTimeout }
134-
: undefined;
135-
this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
132+
this.rpc = new TwirpRpc(host, livekitPackage, {
133+
requestTimeout: options?.requestTimeout,
134+
failover: options?.failover,
135+
});
136136
}
137137

138138
/**

packages/livekit-server-sdk/src/RoomServiceClient.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,10 @@ export class RoomServiceClient extends ServiceBase {
132132
*/
133133
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
134134
super(apiKey, secret);
135-
const rpcOptions = options?.requestTimeout
136-
? { requestTimeout: options.requestTimeout }
137-
: undefined;
138-
this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
135+
this.rpc = new TwirpRpc(host, livekitPackage, {
136+
requestTimeout: options?.requestTimeout,
137+
failover: options?.failover,
138+
});
139139
}
140140

141141
/**

packages/livekit-server-sdk/src/SipClient.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type { ClientOptions } from './ClientOptions.js';
4646
import { ServiceBase } from './ServiceBase.js';
4747
import type { Rpc } from './TwirpRPC.js';
4848
import { TwirpRpc, livekitPackage } from './TwirpRPC.js';
49+
import { DEFAULT_RINGING_TIMEOUT_SECONDS, dialRequestTimeout } from './dialTimeout.js';
4950

5051
const svc = 'SIP';
5152

@@ -170,7 +171,7 @@ export interface CreateSipParticipantOptions {
170171
krispEnabled?: boolean;
171172
/** If `true`, this will wait until the call is answered before returning. */
172173
waitUntilAnswered?: boolean;
173-
/** Optional request timeout in seconds. default 60 seconds if waitUntilAnswered is true, otherwise 10 seconds */
174+
/** Optional request timeout in seconds. Defaults to 30s when waitUntilAnswered is true (dialing takes time), otherwise the client default. */
174175
timeout?: number;
175176
media?: SIPMediaConfig;
176177
}
@@ -233,6 +234,8 @@ export interface TransferSipParticipantOptions {
233234
headers?: { [key: string]: string };
234235
/** Maximum time for the transfer destination to answer the call, in seconds. */
235236
ringingTimeout?: number;
237+
/** Optional request timeout in seconds. Defaults to 30s (dialing takes time). */
238+
timeout?: number;
236239
}
237240

238241
/**
@@ -249,10 +252,10 @@ export class SipClient extends ServiceBase {
249252
*/
250253
constructor(host: string, apiKey?: string, secret?: string, options?: ClientOptions) {
251254
super(apiKey, secret);
252-
const rpcOptions = options?.requestTimeout
253-
? { requestTimeout: options.requestTimeout }
254-
: undefined;
255-
this.rpc = new TwirpRpc(host, livekitPackage, rpcOptions);
255+
this.rpc = new TwirpRpc(host, livekitPackage, {
256+
requestTimeout: options?.requestTimeout,
257+
failover: options?.failover,
258+
});
256259
}
257260

258261
/**
@@ -764,8 +767,13 @@ export class SipClient extends ServiceBase {
764767
opts = {};
765768
}
766769

767-
if (opts.timeout === undefined) {
768-
opts.timeout = opts.waitUntilAnswered ? 60 : 10;
770+
// Dialing a phone and waiting for an answer takes longer than a normal call,
771+
// and the request must outlast ringing so the call can be answered. Pin the
772+
// ring window explicitly so our request timeout doesn't depend on the server's
773+
// default (which could change out from under us).
774+
if (opts.waitUntilAnswered) {
775+
opts.ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
776+
opts.timeout = dialRequestTimeout(opts.timeout, opts.ringingTimeout);
769777
}
770778

771779
const req = new CreateSIPParticipantRequest({
@@ -823,6 +831,12 @@ export class SipClient extends ServiceBase {
823831
opts = {};
824832
}
825833

834+
// Transferring a call dials a phone, which takes longer than a normal call,
835+
// so keep the request alive past ringing. Pin the ring window explicitly so
836+
// our request timeout doesn't depend on the server's default.
837+
opts.ringingTimeout ??= DEFAULT_RINGING_TIMEOUT_SECONDS;
838+
opts.timeout = dialRequestTimeout(opts.timeout, opts.ringingTimeout);
839+
826840
const req = new TransferSIPParticipantRequest({
827841
participantIdentity: participantIdentity,
828842
roomName: roomName,
@@ -839,6 +853,7 @@ export class SipClient extends ServiceBase {
839853
'TransferSIPParticipant',
840854
req,
841855
await this.authHeader({ roomAdmin: true, room: roomName }, { call: true }),
856+
opts.timeout,
842857
);
843858
}
844859
}

0 commit comments

Comments
 (0)