From 080d51e148f7c98f865308a9dec35079f082c03d Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Sat, 22 Aug 2026 22:02:41 +0800 Subject: [PATCH 1/3] Generate request-id for api call relate to https://github.com/livekit/server-sdk-go/pull/954 --- .changeset/request-id-header.md | 5 ++ .../livekit-server-sdk/src/TwirpRPC.test.ts | 88 ++++++++++++++++++- packages/livekit-server-sdk/src/TwirpRPC.ts | 16 +++- .../livekit-server-sdk/src/crypto/uuid.ts | 20 +++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 .changeset/request-id-header.md 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..e38d0d51 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,87 @@ 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]); + }); + + it('preserves a caller-supplied request id', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); + + const rpc = new TwirpRpc('https://test.livekit.cloud', 'livekit', { failover: false }); + // Matched case-insensitively, as HTTP header names are. + await rpc.request('RoomService', 'CreateRoom', {}, { 'x-livekit-request-id': 'caller-123' }); + + const headers = fetchSpy.mock.calls[0]![1]!.headers as Record; + expect(headers['x-livekit-request-id']).toBe('caller-123'); + expect(headers[REQUEST_ID_HEADER]).toBeUndefined(); + }); + + // 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..74568961 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,14 @@ 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, }; + if (!hasHeader(requestHeaders, REQUEST_ID_HEADER)) { + requestHeaders[REQUEST_ID_HEADER] = await randomUUID(); + } const origin = new URL(this.host); const maxAttempts = failoverAttempts( @@ -247,6 +256,11 @@ export class TwirpRpc { } } +function hasHeader(headers: Record, name: string): boolean { + const lower = name.toLowerCase(); + return Object.keys(headers).some((k) => k.toLowerCase() === lower); +} + /** Builds a TwirpError from a non-2xx response, mirroring Twirp's JSON error shape. */ async function toTwirpError(response: Response): Promise { const isJson = response.headers.get('content-type') === 'application/json'; 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('-'); +} From 9585ea4a4e4b14f480be73be60fba40535fc8e5f Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 24 Aug 2026 17:18:01 +0800 Subject: [PATCH 2/3] always set request-id --- packages/livekit-server-sdk/src/TwirpRPC.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/livekit-server-sdk/src/TwirpRPC.ts b/packages/livekit-server-sdk/src/TwirpRPC.ts index 74568961..f93a9d04 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.ts @@ -186,9 +186,7 @@ export class TwirpRpc { 'User-Agent': USER_AGENT, ...headers, }; - if (!hasHeader(requestHeaders, REQUEST_ID_HEADER)) { - requestHeaders[REQUEST_ID_HEADER] = await randomUUID(); - } + requestHeaders[REQUEST_ID_HEADER] = await randomUUID(); const origin = new URL(this.host); const maxAttempts = failoverAttempts( @@ -256,11 +254,6 @@ export class TwirpRpc { } } -function hasHeader(headers: Record, name: string): boolean { - const lower = name.toLowerCase(); - return Object.keys(headers).some((k) => k.toLowerCase() === lower); -} - /** Builds a TwirpError from a non-2xx response, mirroring Twirp's JSON error shape. */ async function toTwirpError(response: Response): Promise { const isJson = response.headers.get('content-type') === 'application/json'; From 9df0eb5a6b3015c400dd71f2f7f6009a9936bbb6 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 24 Aug 2026 17:23:25 +0800 Subject: [PATCH 3/3] remove test --- packages/livekit-server-sdk/src/TwirpRPC.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/livekit-server-sdk/src/TwirpRPC.test.ts b/packages/livekit-server-sdk/src/TwirpRPC.test.ts index e38d0d51..1d97c54a 100644 --- a/packages/livekit-server-sdk/src/TwirpRPC.test.ts +++ b/packages/livekit-server-sdk/src/TwirpRPC.test.ts @@ -75,18 +75,6 @@ describe('request id', () => { expect(ids[0]).not.toBe(ids[1]); }); - it('preserves a caller-supplied request id', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(okResponse()); - - const rpc = new TwirpRpc('https://test.livekit.cloud', 'livekit', { failover: false }); - // Matched case-insensitively, as HTTP header names are. - await rpc.request('RoomService', 'CreateRoom', {}, { 'x-livekit-request-id': 'caller-123' }); - - const headers = fetchSpy.mock.calls[0]![1]!.headers as Record; - expect(headers['x-livekit-request-id']).toBe('caller-123'); - expect(headers[REQUEST_ID_HEADER]).toBeUndefined(); - }); - // 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 () => {