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 f964465d..0b0c5fce 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.test.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -112,3 +112,86 @@ 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('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); + }); +}); diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index f93a9d04..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) { @@ -231,7 +233,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) { 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;