diff --git a/.changeset/request-id-header.md b/.changeset/request-id-header.md new file mode 100644 index 00000000..0e56dfbd --- /dev/null +++ b/.changeset/request-id-header.md @@ -0,0 +1,5 @@ +--- +'livekit-server-sdk': minor +--- + +Send an `X-Livekit-Request-Id` idempotency key on every server API request. The same id is replayed on each region failover attempt, so the server can identify and deduplicate a retried request. diff --git a/packages/livekit-server-sdk/src/TwirpRPC.test.ts b/packages/livekit-server-sdk/src/TwirpRPC.test.ts index cc51f171..1d97c54a 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.test.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: 2026 LiveKit, Inc. // // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; -import { ServerError, SipCallError } from './TwirpRPC.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { REQUEST_ID_HEADER, ServerError, SipCallError, TwirpRpc } from './TwirpRPC.js'; describe('SipCallError', () => { it('renders the SIP status, Twirp code, and extra metadata', () => { @@ -40,3 +40,75 @@ describe('SipCallError', () => { expect(err.message).toBe('boom'); }); }); + +describe('request id', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const okResponse = () => + ({ ok: true, status: 200, json: async () => ({}) }) as unknown as Response; + + const errorResponse = (status: number) => + ({ + ok: false, + status, + statusText: 'Service Unavailable', + headers: { get: () => null }, + text: async () => 'unavailable', + }) as unknown as Response; + + // The header lets the server dedup a request that the SDK replayed. + it('stamps a request id on every call', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); + + const rpc = new TwirpRpc('https://test.livekit.cloud', 'livekit', { failover: false }); + await rpc.request('RoomService', 'CreateRoom', {}, {}); + await rpc.request('RoomService', 'CreateRoom', {}, {}); + + const ids = fetchSpy.mock.calls.map( + ([, init]) => (init!.headers as Record)[REQUEST_ID_HEADER], + ); + expect(ids[0]).toBeTruthy(); + expect(ids[1]).toBeTruthy(); + // A new logical call is a new request, so it gets its own id. + expect(ids[0]).not.toBe(ids[1]); + }); + + // The id is generated once per logical call, so every failover attempt must + // carry the same value. + it('keeps the same request id across failover attempts', async () => { + let attempt = 0; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + // Region discovery, not a replay of the request itself. + if (`${input}`.endsWith('/settings/regions')) { + return { + ok: true, + status: 200, + headers: { get: () => 'max-age=0' }, + json: async () => ({ + regions: [ + { url: 'https://r1.retryid.livekit.cloud' }, + { url: 'https://r2.retryid.livekit.cloud' }, + ], + }), + } as unknown as Response; + } + attempt += 1; + return attempt < 3 ? errorResponse(503) : okResponse(); + }); + + const rpc = new TwirpRpc('https://primary.retryid.livekit.cloud', 'livekit', { + failoverBackoffMs: 0, + }); + await rpc.request('RoomService', 'CreateRoom', {}, {}); + + const ids = fetchSpy.mock.calls + .filter(([input]) => !`${input}`.endsWith('/settings/regions')) + .map(([, init]) => (init!.headers as Record)[REQUEST_ID_HEADER]); + + expect(ids).toHaveLength(3); + expect(ids[0]).toBeTruthy(); + expect(new Set(ids).size).toBe(1); + }); +}); diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index e3dac2b8..f93a9d04 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: Apache-2.0 import type { JsonValue } from '@bufbuild/protobuf'; +import { randomUUID } from './crypto/uuid.js'; import { FAILOVER_BACKOFF_BASE_MS, failoverAttempts, @@ -16,6 +17,11 @@ import { SDK_VERSION } from './version.js'; // setting User-Agent via fetch and silently drop it; Node honors it. const USER_AGENT = `livekit-server-sdk-node/${SDK_VERSION}`; +// Carries a per-request idempotency key. The SDK's auto-retries (see failover) +// keep the same key across attempts, so the server can identify and deduplicate +// repeated requests. +export const REQUEST_ID_HEADER = 'X-Livekit-Request-Id'; + // twirp RPC adapter for client implementation type Options = { @@ -175,11 +181,12 @@ export class TwirpRpc { ): Promise { const path = `${this.prefix}/${this.pkg}.${service}/${method}`; const body = JSON.stringify(data); - const requestHeaders = { + const requestHeaders: Record = { 'Content-Type': 'application/json;charset=UTF-8', 'User-Agent': USER_AGENT, ...headers, }; + requestHeaders[REQUEST_ID_HEADER] = await randomUUID(); const origin = new URL(this.host); const maxAttempts = failoverAttempts( diff --git a/packages/livekit-server-sdk/src/crypto/uuid.ts b/packages/livekit-server-sdk/src/crypto/uuid.ts index 9ff8abd4..9ce6bb19 100644 --- a/packages/livekit-server-sdk/src/crypto/uuid.ts +++ b/packages/livekit-server-sdk/src/crypto/uuid.ts @@ -11,3 +11,23 @@ export async function getRandomBytes(size: number = 16): Promise { return nodeCrypto.getRandomValues(new Uint8Array(size)); } } + +// A random RFC 4122 v4 UUID. Prefers the platform's randomUUID (Node 19+, edge +// runtimes, browsers in a secure context) and otherwise formats random bytes, +// so it works everywhere getRandomBytes does. +export async function randomUUID(): Promise { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return crypto.randomUUID(); + } + const bytes = await getRandomBytes(16); + bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 1 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20), + ].join('-'); +}