Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cloud-api-failover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-server-sdk': patch
---

Retry LiveKit Cloud API (`cloud-api.livekit.io`) requests on transport errors instead of failing after a single attempt, add full jitter to the failover backoff, and retry a 503 only when it carries Retry-After, waiting the indicated delay.
110 changes: 109 additions & 1 deletion packages/livekit-server-sdk/src/TwirpRPC.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ describe('request id', () => {
} as unknown as Response;
}
attempt += 1;
return attempt < 3 ? errorResponse(503) : okResponse();
return attempt < 3 ? errorResponse(502) : okResponse();
});

const rpc = new TwirpRpc('https://primary.retryid.livekit.cloud', 'livekit', {
Expand All @@ -112,3 +112,111 @@ describe('request id', () => {
expect(new Set(ids).size).toBe(1);
});
});

describe('failover without a fallback origin', () => {
afterEach(() => {
vi.restoreAllMocks();
});

// No /settings/regions, so no fallback origin ever exists.
const host = 'https://cloud-api.example.com';

const okResponse = () =>
({ ok: true, status: 200, json: async () => ({}) }) as unknown as Response;

const errorResponse = (status: number, retryAfter: string | null = null) =>
({
ok: false,
status,
statusText: 'Bad Gateway',
headers: { get: (name: string) => (name === 'retry-after' ? retryAfter : null) },
text: async () => 'bad gateway',
}) as unknown as Response;

const isDiscovery = (input: unknown) => `${input}`.endsWith('/settings/regions');

it('without a fallback origin, a transport error retries the same host', async () => {
let attempt = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (isDiscovery(input)) {
return errorResponse(404);
}
attempt += 1;
if (attempt === 1) {
throw new Error('read: connection reset by peer');
}
return okResponse();
});

const rpc = new TwirpRpc(host, 'livekit', { failoverForce: true, failoverBackoffMs: 0 });
await expect(rpc.request('RoomService', 'CreateRoom', {}, {})).resolves.toEqual({});

const attempts = fetchSpy.mock.calls.filter(([input]) => !isDiscovery(input));
expect(attempts).toHaveLength(2);
expect(attempts.map(([input]) => new URL(`${input}`).host)).toEqual([
'cloud-api.example.com',
'cloud-api.example.com',
]);
});

it('a Cloud API host retries without consulting region discovery', async () => {
let attempt = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (isDiscovery(input)) {
return errorResponse(404);
}
attempt += 1;
if (attempt === 1) {
throw new Error('read: connection reset by peer');
}
return okResponse();
});

const rpc = new TwirpRpc('https://cloud-api.livekit.io', 'livekit', { failoverBackoffMs: 0 });
await expect(rpc.request('RoomService', 'CreateRoom', {}, {})).resolves.toEqual({});

expect(fetchSpy.mock.calls.filter(([input]) => isDiscovery(input))).toHaveLength(0);
expect(fetchSpy.mock.calls.filter(([input]) => !isDiscovery(input))).toHaveLength(2);
});

it('without a fallback origin, a 5xx retries the same host', async () => {
let attempt = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (isDiscovery(input)) {
return errorResponse(404);
}
attempt += 1;
return attempt === 1 ? errorResponse(502) : okResponse();
});

const rpc = new TwirpRpc(host, 'livekit', { failoverForce: true, failoverBackoffMs: 0 });
await expect(rpc.request('RoomService', 'CreateRoom', {}, {})).resolves.toEqual({});

expect(fetchSpy.mock.calls.filter(([input]) => !isDiscovery(input))).toHaveLength(2);
});

it('a 503 without Retry-After is not retried', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(errorResponse(503));

const rpc = new TwirpRpc(host, 'livekit', { failoverForce: true, failoverBackoffMs: 0 });
await expect(rpc.request('RoomService', 'CreateRoom', {}, {})).rejects.toThrow();

expect(fetchSpy.mock.calls.filter(([input]) => !isDiscovery(input))).toHaveLength(1);
});

it('a 503 with Retry-After is retried', async () => {
let attempt = 0;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (isDiscovery(input)) {
return errorResponse(404);
}
attempt += 1;
return attempt === 1 ? errorResponse(503, '0') : okResponse();
});

const rpc = new TwirpRpc(host, 'livekit', { failoverForce: true, failoverBackoffMs: 0 });
await expect(rpc.request('RoomService', 'CreateRoom', {}, {})).resolves.toEqual({});

expect(fetchSpy.mock.calls.filter(([input]) => !isDiscovery(input))).toHaveLength(2);
});
});
30 changes: 22 additions & 8 deletions packages/livekit-server-sdk/src/TwirpRPC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import type { JsonValue } from '@bufbuild/protobuf';
import { randomUUID } from './crypto/uuid.js';
import {
FAILOVER_BACKOFF_BASE_MS,
backoffMs,
failoverAttempts,
hostKey,
isCloudApi,
parseRetryAfter,
pickNext,
regionOrigins,
sleep,
Expand Down Expand Up @@ -166,9 +169,10 @@ export class TwirpRpc {

/**
* Issues a Twirp request, failing over to alternative regions on retryable
* errors. On any transport error or HTTP 5xx it discovers regions via
* errors. On any transport error or HTTP 5xx (a 503 only with Retry-After,
* which then sets the delay) it discovers regions via
* /settings/regions and replays the request — body and headers intact —
* against the next untried region, with exponential backoff. A 4xx is
* against the next untried region, with jittered exponential backoff. A 4xx is
* returned immediately.
*/
async request(
Expand Down Expand Up @@ -196,7 +200,8 @@ export class TwirpRpc {
timeout,
);
const attempted = new Set([hostKey(origin)]);
let regions: string[] | undefined;
// A Cloud API host has a single origin; region discovery is never consulted.
let regions: string[] | undefined = isCloudApi(origin.hostname) ? [] : undefined;
let current = this.host;

for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
Expand All @@ -223,15 +228,24 @@ export class TwirpRpc {
return (await response.json()) as Record<string, unknown>;
}

// Only retryable failures (a transport error or HTTP 5xx) continue;
// a 4xx is terminal.
const retryable = transportError !== undefined || (!!response && response.status >= 500);
const retryAfter = response
? parseRetryAfter(response.headers.get('retry-after'))
: undefined;
// Only retryable failures (a transport error or HTTP 5xx) continue; a 4xx
// is terminal. A 503 is the server shedding load, so it is retried only
// when Retry-After says when.
const retryable =
transportError !== undefined ||
(!!response &&
response.status >= 500 &&
(response.status !== 503 || retryAfter !== undefined));
let next: string | undefined;
if (retryable && !isLast) {
if (!regions) {
regions = await regionOrigins(origin, headers);
}
next = pickNext(regions, attempted);
// With no fallback origin, a retryable failure is retried against the same host.
next = pickNext(regions, attempted) ?? current;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when there is no fallback origin, what will happen to a dead origin ? with this change, it will fail after 30s rather than 10, can we do something better here ?

As minimal, probably we should update the comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And my concern is, saying the service is already busy and return a 5xx (indicating busy traffic or whatever), with this change, we will retry 3 times, that might congest the traffic more.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A dead origin, ie. connection refused, DNS failure, TLS reject will fail in milliseconds.

Three attempts cost only the backoff, so ~600ms total, not 30s. The 30s worst case (10 + 0.2 + 10 + 0.4 + 10 = 30.6s at the 10s default) only happens when the origin hangs until the per-attempt timeout on every attempt. That's exactly the failure mode this PR exists to fix: on 2026-09-11 the request was acknowledged at Cloudflare's edge and never reached any LiveKit system, and the client burned its full timeout with no response. The only way to recover that is to send it again, and the only signal we have is the timeout itself.

The alternative is a single shared deadline across attempts instead of a per-attempt timeout. I don't think it belongs here: requestTimeout is documented and used as per-request today, so changing it to a total budget is a semantic change to a user-facing option, and it would have to land in server-sdk-go (already merged) and python-sdks at the same time. Happy to open that separately if you want it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The amplification factor isn't new: FAILOVER_MAX_ATTEMPTS is 3, and cross-region failover has always retried 5xx at that same cap for .livekit.cloud hosts. This PR extends the existing 3x ceiling to single-origin hosts rather than raising it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the real congestion risk you're pointing at is one neither SDK guards against today: the backoff has no jitter. It's failoverBackoffMs * 2 ** attempt here and backoffBase << attempt in Go, both fully deterministic, so under a shared 5xx event every client retries in lockstep at exactly 200ms and 600ms.

Should I add jitter to the backoff?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes to jitter if that is not too complicated to do.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, I wonder if we should keep 5xx retry but exclude 503 and honor Retry After, if that makes more sense.

}

if (!retryable || next === undefined) {
Expand All @@ -245,7 +259,7 @@ export class TwirpRpc {
console.warn(
`livekit API request to ${new URL(current).host} failed (${reason}), retrying with fallback url ${next}`,
);
await sleep(this.failoverBackoffMs * 2 ** attempt);
await sleep(retryAfter ?? backoffMs(attempt, this.failoverBackoffMs));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Region discovery extends Retry-After delays

For a project host's 503, retryAfter starts counting before region discovery. regionOrigins can take two seconds, then the full delay runs, postponing the retry beyond the requested time.

Learn more

A project host discovers fallback regions after a retryable response. The Retry-After value is converted to milliseconds before regionOrigins runs; that lookup can await fetchRegions for up to two seconds. Sleeping for the original full delay afterward counts that lookup time twice against the response's indicated retry time.

Example: A 503 includes Retry-After: 3, and region discovery takes two seconds. The next attempt begins about five seconds after the response rather than after three seconds.

Recommended fix: Record a retry deadline when the response arrives, then sleep only for the remaining time after region discovery. Keep jittered backoff for responses without Retry-After.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

attempted.add(hostKey(new URL(next)));
current = next;
}
Expand Down
47 changes: 47 additions & 0 deletions packages/livekit-server-sdk/src/failover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import {
FAILOVER_MAX_ATTEMPTS,
MIN_FAILOVER_TIMEOUT_SECONDS,
backoffMs,
failoverAttempts,
hostKey,
parseMaxAge,
parseRetryAfter,
pickNext,
regionOrigins,
sleep,
Expand All @@ -19,8 +21,15 @@ describe('failoverAttempts', () => {
expect(failoverAttempts(true, 'myproject.region.livekit.cloud')).toBe(FAILOVER_MAX_ATTEMPTS);
});

it('fails over for the LiveKit Cloud API hosts', () => {
expect(failoverAttempts(true, 'cloud-api.livekit.io')).toBe(FAILOVER_MAX_ATTEMPTS);
expect(failoverAttempts(true, 'cloud-api.staging.livekit.io')).toBe(FAILOVER_MAX_ATTEMPTS);
expect(failoverAttempts(true, 'CLOUD-API.LIVEKIT.IO')).toBe(FAILOVER_MAX_ATTEMPTS);
});

it('does not fail over for non-cloud hosts', () => {
expect(failoverAttempts(true, 'myproject.livekit.io')).toBe(1);
expect(failoverAttempts(true, 'cloud-api.example.com')).toBe(1);
expect(failoverAttempts(true, 'example.com')).toBe(1);
expect(failoverAttempts(true, '127.0.0.1')).toBe(1);
expect(failoverAttempts(true, 'notlivekit.cloud')).toBe(1);
Expand Down Expand Up @@ -72,6 +81,44 @@ describe('pickNext', () => {
});
});

describe('backoffMs', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('draws uniformly from [0, base * 2^attempt)', () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
expect(backoffMs(2, 200)).toBe(0);
vi.spyOn(Math, 'random').mockReturnValue(0.5);
expect(backoffMs(0, 200)).toBe(100);
expect(backoffMs(1, 200)).toBe(200);
expect(backoffMs(2, 200)).toBe(400);
});
});

describe('parseRetryAfter', () => {
it('returns undefined when the header is absent or invalid', () => {
expect(parseRetryAfter(null)).toBeUndefined();
expect(parseRetryAfter('')).toBeUndefined();
expect(parseRetryAfter('soon')).toBeUndefined();
});

it('parses delay-seconds to milliseconds', () => {
expect(parseRetryAfter('0')).toBe(0);
expect(parseRetryAfter(' 2 ')).toBe(2000);
});

it('parses an HTTP-date relative to now, clamped at zero', () => {
vi.useFakeTimers({ now: Date.parse('Wed, 21 Oct 2026 07:28:00 GMT') });
try {
expect(parseRetryAfter('Wed, 21 Oct 2026 07:28:03 GMT')).toBe(3000);
expect(parseRetryAfter('Wed, 21 Oct 2026 07:27:00 GMT')).toBe(0);
} finally {
vi.useRealTimers();
}
});
});

describe('sleep', () => {
it('resolves immediately for non-positive durations', async () => {
await expect(sleep(0)).resolves.toBeUndefined();
Expand Down
36 changes: 31 additions & 5 deletions packages/livekit-server-sdk/src/failover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

// Region failover for the Twirp API clients.
//
// On a retryable failure (any transport error or HTTP 5xx) the client discovers
// On a retryable failure (any transport error, or an HTTP 5xx other than a 503
// without Retry-After) the client discovers
// alternative LiveKit Cloud regions via /settings/regions and replays the
// request against the next region, with exponential backoff. 4xx responses are
// request against the next region, with jittered exponential backoff. 4xx responses are
// returned immediately.

// Total attempts (the original request + fallback regions) and the base retry
Expand All @@ -21,16 +22,16 @@

/**
* Total request attempts for a host; 1 means no failover. Failover only engages
* when enabled, the host is a LiveKit Cloud domain, and the request timeout is
* long enough to retry. `force` bypasses the cloud-host check (test-only).
* when enabled, the host is a LiveKit Cloud project or Cloud API domain, and the
* request timeout is long enough to retry. `force` bypasses the cloud-host check (test-only).
*/
export function failoverAttempts(
enabled: boolean,
hostname: string,
force = false,
timeoutSeconds = 0,
): number {
if (!enabled || !(force || isCloud(hostname))) {
if (!enabled || !(force || isCloud(hostname) || isCloudApi(hostname))) {
return 1;
}
if (timeoutSeconds > 0 && timeoutSeconds < MIN_FAILOVER_TIMEOUT_SECONDS) {
Expand All @@ -44,7 +45,13 @@
return hostname.endsWith('.livekit.cloud');
}

// The LiveKit Cloud API hosts: cloud-api.livekit.io and cloud-api.<env>.livekit.io.
export function isCloudApi(hostname: string): boolean {
const host = hostname.toLowerCase();
return host.startsWith('cloud-api.') && host.endsWith('.livekit.io');
}

/** Normalizes a region URL to an http(s) scheme (ws -> http, wss -> https). */

Check warning on line 54 in packages/livekit-server-sdk/src/failover.ts

View workflow job for this annotation

GitHub Actions / Formatting

tsdoc-escape-greater-than: The ">" character should be escaped using a backslash to avoid confusion with an HTML tag

Check warning on line 54 in packages/livekit-server-sdk/src/failover.ts

View workflow job for this annotation

GitHub Actions / Formatting

tsdoc-escape-greater-than: The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
function toHttp(url: string): string {
return url.startsWith('ws') ? `http${url.slice(2)}` : url;
}
Expand All @@ -68,6 +75,25 @@
return undefined;
}

// Full jitter: a uniform delay in [0, base * 2^attempt) so clients that failed
// together don't retry in lockstep.
export function backoffMs(attempt: number, baseMs: number): number {
return Math.random() * baseMs * 2 ** attempt;
}

/** Parses a Retry-After header (delay-seconds or an HTTP-date) to milliseconds. */
export function parseRetryAfter(header: string | null): number | undefined {
const value = header?.trim();
if (!value) {
return undefined;
}
if (/^\d+$/.test(value)) {
return Number(value) * 1000;
Comment on lines +90 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Long Retry-After delays trigger immediate retries

For a 503 with Retry-After: 2147484, parseRetryAfter returns a delay beyond Node's timer limit. sleep then schedules an approximately one-millisecond timer, so the request retries immediately instead of waiting.

Learn more

A Retry-After delay is measured in seconds, but JavaScript timers cannot represent every resulting millisecond duration. Node clamps a setTimeout delay exceeding 2,147,483,647 milliseconds to roughly one millisecond. Both numeric values and HTTP-dates can exceed that range, and sleep passes the delay directly to the timer.

Example: A server responds with 503 and Retry-After: 2147484. The parsed delay is 2,147,484,000 ms. The SDK retries almost immediately instead of waiting the requested delay.

Recommended fix: Bound or split long waits in sleep, and ensure parseRetryAfter rejects non-finite values. If enforcing an upper bound, define the documented retry policy explicitly rather than accidentally retrying immediately.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
const date = Date.parse(value);
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
}

export function sleep(ms: number): Promise<void> {
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/livekit-server-sdk/test/api/failover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const call = (
{
authorization: 'Bearer test-token',
// These tests exercise failover, not authz; skip the mock's permission check.
'X-Lk-Mock': JSON.stringify({ skipAuth: true, ...mock }),
// The mock fails with a 503 by default, which is retried only with Retry-After.
'X-Lk-Mock': JSON.stringify({ skipAuth: true, failStatus: 502, ...mock }),
},
);
};
Expand Down
Loading