From bf2ea1be06ebd20ab32cfcb8a583738618d6a7a9 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 13:24:22 -0400 Subject: [PATCH 1/2] failover: retry the same host when no fallback origin exists A retryable failure with no untried origin left used to be surfaced after a single attempt. Retry it against the same origin instead, bounded by the existing attempt count and backoff. This matches the cross-region path, which already retries both transport errors and 5xx responses. --- .../livekit-server-sdk/src/TwirpRPC.test.ts | 63 +++++++++++++++++++ packages/livekit-server-sdk/src/TwirpRPC.ts | 3 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/livekit-server-sdk/src/TwirpRPC.test.ts b/packages/livekit-server-sdk/src/TwirpRPC.test.ts index f964465d..1be234c5 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.test.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -112,3 +112,66 @@ 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) => + ({ + ok: false, + status, + statusText: 'Bad Gateway', + headers: { get: () => 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('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); + }); +}); diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index f93a9d04..b5e03a26 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.ts @@ -231,7 +231,8 @@ export class TwirpRpc { 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; } if (!retryable || next === undefined) { From 3f51ac796fe1617a5886580473ef7de7bc6464e2 Mon Sep 17 00:00:00 2001 From: Jason Lernerman Date: Fri, 11 Sep 2026 13:24:54 -0400 Subject: [PATCH 2/2] failover: enable retries for the LiveKit Cloud API hosts --- .changeset/cloud-api-failover.md | 5 +++++ .../livekit-server-sdk/src/TwirpRPC.test.ts | 20 +++++++++++++++++++ packages/livekit-server-sdk/src/TwirpRPC.ts | 4 +++- .../livekit-server-sdk/src/failover.test.ts | 7 +++++++ packages/livekit-server-sdk/src/failover.ts | 12 ++++++++--- 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .changeset/cloud-api-failover.md diff --git a/.changeset/cloud-api-failover.md b/.changeset/cloud-api-failover.md new file mode 100644 index 00000000..c569c825 --- /dev/null +++ b/.changeset/cloud-api-failover.md @@ -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. diff --git a/packages/livekit-server-sdk/src/TwirpRPC.test.ts b/packages/livekit-server-sdk/src/TwirpRPC.test.ts index 1be234c5..0b0c5fce 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.test.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -159,6 +159,26 @@ describe('failover without a fallback origin', () => { ]); }); + 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) => { diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index b5e03a26..4338201e 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.ts @@ -7,6 +7,7 @@ import { FAILOVER_BACKOFF_BASE_MS, failoverAttempts, hostKey, + isCloudApi, pickNext, regionOrigins, sleep, @@ -196,7 +197,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) { diff --git a/packages/livekit-server-sdk/src/failover.test.ts b/packages/livekit-server-sdk/src/failover.test.ts index c528ab63..5317c15e 100644 --- a/packages/livekit-server-sdk/src/failover.test.ts +++ b/packages/livekit-server-sdk/src/failover.test.ts @@ -19,8 +19,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); diff --git a/packages/livekit-server-sdk/src/failover.ts b/packages/livekit-server-sdk/src/failover.ts index a8dbe827..1506d9bd 100644 --- a/packages/livekit-server-sdk/src/failover.ts +++ b/packages/livekit-server-sdk/src/failover.ts @@ -21,8 +21,8 @@ export const MIN_FAILOVER_TIMEOUT_SECONDS = 5; /** * 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, @@ -30,7 +30,7 @@ export function failoverAttempts( 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) { @@ -44,6 +44,12 @@ function isCloud(hostname: string): boolean { return hostname.endsWith('.livekit.cloud'); } +// The LiveKit Cloud API hosts: cloud-api.livekit.io and cloud-api..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). */ function toHttp(url: string): string { return url.startsWith('ws') ? `http${url.slice(2)}` : url;