Skip to content
Merged
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/request-id-header.md
Original file line number Diff line number Diff line change
@@ -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.
76 changes: 74 additions & 2 deletions packages/livekit-server-sdk/src/TwirpRPC.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<string, string>)[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<string, string>)[REQUEST_ID_HEADER]);

expect(ids).toHaveLength(3);
expect(ids[0]).toBeTruthy();
expect(new Set(ids).size).toBe(1);
});
});
9 changes: 8 additions & 1 deletion packages/livekit-server-sdk/src/TwirpRPC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -175,11 +181,12 @@ export class TwirpRpc {
): Promise<any> {
const path = `${this.prefix}/${this.pkg}.${service}/${method}`;
const body = JSON.stringify(data);
const requestHeaders = {
const requestHeaders: Record<string, string> = {
'Content-Type': 'application/json;charset=UTF-8',
'User-Agent': USER_AGENT,
...headers,
};
requestHeaders[REQUEST_ID_HEADER] = await randomUUID();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

const origin = new URL(this.host);
const maxAttempts = failoverAttempts(
Expand Down
20 changes: 20 additions & 0 deletions packages/livekit-server-sdk/src/crypto/uuid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,23 @@ export async function getRandomBytes(size: number = 16): Promise<Uint8Array> {
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<string> {
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('-');
}
Loading