Repository navigation
failover: retry the same host when no fallback origin exists, and cover the Cloud API hosts #723
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bf2ea1b
3f51ac7
34b4605
3ae213f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, add full jitter to the failover backoff, and retry a 503 only when it carries Retry-After, waiting the indicated delay. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,8 +5,11 @@ import type { JsonValue } from '@bufbuild/protobuf'; | |
| import { randomUUID } from './crypto/uuid.js'; | ||
| import { | ||
| FAILOVER_BACKOFF_BASE_MS, | ||
| backoffMs, | ||
| failoverAttempts, | ||
| hostKey, | ||
| isCloudApi, | ||
| parseRetryAfter, | ||
| pickNext, | ||
| regionOrigins, | ||
| sleep, | ||
|
|
@@ -166,9 +169,10 @@ export class TwirpRpc { | |
|
|
||
| /** | ||
| * Issues a Twirp request, failing over to alternative regions on retryable | ||
| * errors. On any transport error or HTTP 5xx it discovers regions via | ||
| * errors. On any transport error or HTTP 5xx (a 503 only with Retry-After, | ||
| * which then sets the delay) it discovers regions via | ||
| * /settings/regions and replays the request — body and headers intact — | ||
| * against the next untried region, with exponential backoff. A 4xx is | ||
| * against the next untried region, with jittered exponential backoff. A 4xx is | ||
| * returned immediately. | ||
| */ | ||
| async request( | ||
|
|
@@ -196,7 +200,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) { | ||
|
|
@@ -223,15 +228,24 @@ export class TwirpRpc { | |
| return (await response.json()) as Record<string, unknown>; | ||
| } | ||
|
|
||
| // Only retryable failures (a transport error or HTTP 5xx) continue; | ||
| // a 4xx is terminal. | ||
| const retryable = transportError !== undefined || (!!response && response.status >= 500); | ||
| const retryAfter = response | ||
| ? parseRetryAfter(response.headers.get('retry-after')) | ||
| : undefined; | ||
| // Only retryable failures (a transport error or HTTP 5xx) continue; a 4xx | ||
| // is terminal. A 503 is the server shedding load, so it is retried only | ||
| // when Retry-After says when. | ||
| const retryable = | ||
| transportError !== undefined || | ||
| (!!response && | ||
| response.status >= 500 && | ||
| (response.status !== 503 || retryAfter !== undefined)); | ||
| let next: string | undefined; | ||
| if (retryable && !isLast) { | ||
| 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) { | ||
|
|
@@ -245,7 +259,7 @@ export class TwirpRpc { | |
| console.warn( | ||
| `livekit API request to ${new URL(current).host} failed (${reason}), retrying with fallback url ${next}`, | ||
| ); | ||
| await sleep(this.failoverBackoffMs * 2 ** attempt); | ||
| await sleep(retryAfter ?? backoffMs(attempt, this.failoverBackoffMs)); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Region discovery extends Retry-After delays For a project host's 503, Learn moreA project host discovers fallback regions after a retryable response. The Retry-After value is converted to milliseconds before regionOrigins runs; that lookup can await fetchRegions for up to two seconds. Sleeping for the original full delay afterward counts that lookup time twice against the response's indicated retry time. Example: A 503 includes Recommended fix: Record a retry deadline when the response arrives, then sleep only for the remaining time after region discovery. Keep jittered backoff for responses without Retry-After. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| attempted.add(hostKey(new URL(next))); | ||
| current = next; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,10 @@ | |
|
|
||
| // Region failover for the Twirp API clients. | ||
| // | ||
| // On a retryable failure (any transport error or HTTP 5xx) the client discovers | ||
| // On a retryable failure (any transport error, or an HTTP 5xx other than a 503 | ||
| // without Retry-After) the client discovers | ||
| // alternative LiveKit Cloud regions via /settings/regions and replays the | ||
| // request against the next region, with exponential backoff. 4xx responses are | ||
| // request against the next region, with jittered exponential backoff. 4xx responses are | ||
| // returned immediately. | ||
|
|
||
| // Total attempts (the original request + fallback regions) and the base retry | ||
|
|
@@ -21,16 +22,16 @@ | |
|
|
||
| /** | ||
| * 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, | ||
| hostname: string, | ||
| 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,7 +45,13 @@ | |
| return hostname.endsWith('.livekit.cloud'); | ||
| } | ||
|
|
||
| // The LiveKit Cloud API hosts: cloud-api.livekit.io and cloud-api.<env>.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). */ | ||
|
Check warning on line 54 in packages/livekit-server-sdk/src/failover.ts
|
||
| function toHttp(url: string): string { | ||
| return url.startsWith('ws') ? `http${url.slice(2)}` : url; | ||
| } | ||
|
|
@@ -68,6 +75,25 @@ | |
| return undefined; | ||
| } | ||
|
|
||
| // Full jitter: a uniform delay in [0, base * 2^attempt) so clients that failed | ||
| // together don't retry in lockstep. | ||
| export function backoffMs(attempt: number, baseMs: number): number { | ||
| return Math.random() * baseMs * 2 ** attempt; | ||
| } | ||
|
|
||
| /** Parses a Retry-After header (delay-seconds or an HTTP-date) to milliseconds. */ | ||
| export function parseRetryAfter(header: string | null): number | undefined { | ||
| const value = header?.trim(); | ||
| if (!value) { | ||
| return undefined; | ||
| } | ||
| if (/^\d+$/.test(value)) { | ||
| return Number(value) * 1000; | ||
|
Comment on lines
+90
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Long Retry-After delays trigger immediate retries For a 503 with Learn moreA Retry-After delay is measured in seconds, but JavaScript timers cannot represent every resulting millisecond duration. Node clamps a Example: A server responds with Recommended fix: Bound or split long waits in Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| const date = Date.parse(value); | ||
| return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); | ||
| } | ||
|
|
||
| export function sleep(ms: number): Promise<void> { | ||
| return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve(); | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
when there is no fallback origin, what will happen to a dead origin ? with this change, it will fail after 30s rather than 10, can we do something better here ?
As minimal, probably we should update the comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And my concern is, saying the service is already busy and return a 5xx (indicating busy traffic or whatever), with this change, we will retry 3 times, that might congest the traffic more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A dead origin, ie. connection refused, DNS failure, TLS reject will fail in milliseconds.
Three attempts cost only the backoff, so ~600ms total, not 30s. The 30s worst case (10 + 0.2 + 10 + 0.4 + 10 = 30.6s at the 10s default) only happens when the origin hangs until the per-attempt timeout on every attempt. That's exactly the failure mode this PR exists to fix: on 2026-09-11 the request was acknowledged at Cloudflare's edge and never reached any LiveKit system, and the client burned its full timeout with no response. The only way to recover that is to send it again, and the only signal we have is the timeout itself.
The alternative is a single shared deadline across attempts instead of a per-attempt timeout. I don't think it belongs here: requestTimeout is documented and used as per-request today, so changing it to a total budget is a semantic change to a user-facing option, and it would have to land in server-sdk-go (already merged) and python-sdks at the same time. Happy to open that separately if you want it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The amplification factor isn't new: FAILOVER_MAX_ATTEMPTS is 3, and cross-region failover has always retried 5xx at that same cap for .livekit.cloud hosts. This PR extends the existing 3x ceiling to single-origin hosts rather than raising it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But the real congestion risk you're pointing at is one neither SDK guards against today: the backoff has no jitter. It's failoverBackoffMs * 2 ** attempt here and backoffBase << attempt in Go, both fully deterministic, so under a shared 5xx event every client retries in lockstep at exactly 200ms and 600ms.
Should I add jitter to the backoff?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes to jitter if that is not too complicated to do.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
btw, I wonder if we should keep 5xx retry but exclude 503 and honor Retry After, if that makes more sense.