Skip to content

failover: retry the same host when no fallback region exists, and cover the Cloud API hosts - #1002

Merged
u9g merged 2 commits into
mainfrom
jason/cloud-api-retry
Sep 11, 2026
Merged

failover: retry the same host when no fallback region exists, and cover the Cloud API hosts#1002
u9g merged 2 commits into
mainfrom
jason/cloud-api-retry

Conversation

@u9g

@u9g u9g commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Why

A CLI call to cloud-api.livekit.io hung for 10 seconds and timed out on 2026-09-11 at 14:44Z. The request reached Cloudflare's edge and was acknowledged there, but no LiveKit system ever saw it: traefik's per-backend counters, the OCI load balancer's metrics, and cloud-api's request spans were all clean for that minute. Every other call in the surrounding minutes succeeded. The failure was a single lost request between Cloudflare and the origin.

The SDK's failover transport could not help, for two reasons:

  • isCloud only matches *.livekit.cloud, so a cloud-api.livekit.io request always got exactly one attempt.
  • Even with failover engaged, the loop gives up as soon as no untried region exists, which is always the case for cloud-api because it has a single origin and /settings/regions returns 404.

What

Two commits, each test-first:

  1. Retry the same host when no fallback region exists. A retryable failure with no untried region used to be surfaced after one attempt. It now retries against the same host, bounded by the existing attempt count and backoff. This matches the cross-region path, which already retries both transport errors and 5xx responses. TestAPI_RegionDiscoveryUnreachable still holds: a region that fails deterministically is surfaced once attempts are exhausted.
  2. Enable retries for the LiveKit Cloud API hosts. A new isCloudAPI check matches cloud-api.<env>.livekit.io case-insensitively and joins isCloud in the attempts policy. Cloud API hosts skip /settings/regions discovery entirely, since they have a single origin and the endpoint 404s; otherwise every retry would pay the independent 2-second discovery timeout. isCloud itself is unchanged since it also drives region-URL parsing for project hosts.

The existing minFailoverTimeout gate still applies, so requests with a budget under 5 seconds get a single attempt as before.

Behavior change

For cloud-api calls with a budget of 5 seconds or more, a lost request now costs up to three attempts with backoff. A truly dead origin takes up to three times the per-attempt budget to surface instead of one. A single lost request becomes a sub-second blip.

Cross-SDK parity

Same change in the other server SDKs: livekit/python-sdks#811 and livekit/node-sdks#723.

Testing

  • go test ./ -run 'Failover|PrepareContext|DialContext' passes.
  • New tests: TestFailoverRetriesSameHostOnTransportError, TestFailoverRetriesSameHostOn5xx, TestFailoverCloudAPISkipsRegionDiscovery, plus cloud-api rows in TestFailoverAttempts. Each was confirmed failing before its implementation commit.
  • The root package's integration_test.go cases need a running LiveKit server and fail identically on main.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread failover.go
// or Cloud API domain (or force is set).
func (c failoverConfig) attempts(hostname string) int {
if c.enabled && (c.force || isCloud(hostname)) {
if c.enabled && (c.force || isCloud(hostname) || isCloudAPI(hostname)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cloud API failures trigger futile discovery

A retryable Cloud API failure runs unsupported region discovery before failover retries or returns. A nil result triggers discovery again after another transport error, adding up to four seconds.

Learn more

Cloud project hosts use /settings/regions to choose another region. Cloud API hosts have one origin and return 404 from that endpoint. Enabling their failover enters the same discovery branch after every retryable response. A failed fetch leaves regions nil, so the next transport failure fetches again. Each fetch uses the independent two-second region discovery timeout, outside the attempt timeout.

Example: A dead Cloud API origin consumes a 10-second attempt, a 2-second discovery timeout, another 10-second attempt, and another 2-second discovery timeout before its final attempt. A fast 503 still waits for the unsupported discovery request before being returned.

Recommended fix: Skip regionCache.get for isCloudAPI(req.URL.Hostname()) and proceed directly to the same-host transport-error branch. Preserve immediate return for Cloud API 5xx responses.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to avoid this call to regions?

Comment thread failover.go
Comment on lines +83 to +85
func isCloudAPI(hostname string) bool {
return strings.HasPrefix(hostname, "cloud-api.") && strings.HasSuffix(hostname, ".livekit.io")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Mixed-case Cloud API hosts lose retries

A mixed-case Cloud API hostname makes isCloudAPI return false. Valid URLs such as https://CLOUD-API.LIVEKIT.IO therefore retain one attempt.

Suggested change
func isCloudAPI(hostname string) bool {
return strings.HasPrefix(hostname, "cloud-api.") && strings.HasSuffix(hostname, ".livekit.io")
}
func isCloudAPI(hostname string) bool {
hostname = strings.ToLower(hostname)
return strings.HasPrefix(hostname, "cloud-api.") && strings.HasSuffix(hostname, ".livekit.io")
}
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread failover.go Outdated
nextScheme, nextHost, ok := nextRegion(regions, tried)
if !ok {
return terminate(resp, err, cancel) // no untried region left
// With no fallback region, a 5xx is final; only a transport error is

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't 5xx retried?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two reasons. First, scope: the incident this fixes was a request that got no response at all, and a transport error is the only signal that the origin never saw it.

A 5xx means the origin, or traefik in front of it, processed it and answered, so a same-host retry against the same origin is much less likely to change the outcome.

Second, safety: most twirp calls here are not idempotent, and re-sending one that the server already executed and then 500'd on is a double-execution risk.

The X-Livekit-Request-Id header is meant for server-side dedup, but cloud-api doesn't dedup on it today.

This also preserves the existing contract that TestAPI_RegionDiscoveryUnreachable pins: a 5xx with no fallback is surfaced.

If you'd rather retry the proxy-class codes, 502/503/504 from traefik do mean the request most likely never reached the app, so retrying those same-host while surfacing 500 would be a reasonable middle ground. Happy to do that in this PR if you prefer it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue that the non idempotence issue already exists in case of transport error: the request may make its way to the server, but the response may never make it back. Unless Cloudflare synthesizes an HTTP error at the proxy level in case of timeout (before the SDK http timeout fires)?

Comment thread failover.go
// or Cloud API domain (or force is set).
func (c failoverConfig) attempts(hostname string) int {
if c.enabled && (c.force || isCloud(hostname)) {
if c.enabled && (c.force || isCloud(hostname) || isCloudAPI(hostname)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to avoid this call to regions?

@u9g
u9g force-pushed the jason/cloud-api-retry branch from 4d7185e to d64aa1c Compare September 11, 2026 17:35
@u9g

u9g commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Is there a way to avoid this call to regions?

Yes, and fixed in the latest push.

@u9g
u9g force-pushed the jason/cloud-api-retry branch from d64aa1c to a05ab5a Compare September 11, 2026 17:57
u9g added 2 commits September 11, 2026 13:58
A retryable failure with no untried region used to be surfaced after a
single attempt. Retry it against the same host instead, bounded by the
existing attempt count and backoff. This matches the cross-region path,
which already retries both transport errors and 5xx responses.
cloud-api.livekit.io has a single origin and no region list, so a lost
request was surfaced after one attempt. Treat cloud-api.<env>.livekit.io
like a cloud project host so the transport's same-host retry applies.
@u9g
u9g force-pushed the jason/cloud-api-retry branch from a05ab5a to 1621d8b Compare September 11, 2026 17:58
@u9g u9g changed the title failover: retry a lost request on the same host, and cover the Cloud API hosts failover: retry the same host when no fallback region exists, and cover the Cloud API hosts Sep 11, 2026
@u9g
u9g merged commit 7b2b037 into main Sep 11, 2026
8 checks passed
@u9g
u9g deleted the jason/cloud-api-retry branch September 11, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants