Repository navigation
Expand file tree
/
Copy pathfailover.go
More file actions
404 lines (357 loc) · 14.1 KB
/
Copy pathfailover.go
File metadata and controls
404 lines (357 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package lksdk
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/livekit/protocol/livekit"
"github.com/livekit/server-sdk-go/v2/signalling"
)
// Total attempts (the original request plus fallback regions) and the base
// retry backoff are fixed, not user-configurable, so retries can't be tuned to
// values that could overwhelm the server.
const (
failoverMaxAttempts = 3
failoverBackoffBase = 200 * time.Millisecond
)
// minFailoverTimeout gates failover on the request timeout: a shorter budget
// gets a single attempt, since a retry is unlikely to help and risks
// thundering-herd retries across regions.
var minFailoverTimeout = 5 * time.Second
const (
// defaultRequestTimeout bounds a request when the caller sets no deadline.
defaultRequestTimeout = 10 * time.Second
// defaultRingingTimeout is the ring window assumed for a phone-dialing call
// (CreateSIPParticipant with WaitUntilAnswered, TransferSIPParticipant,
// AcceptWhatsAppCall) when the request doesn't set ringing_timeout; it matches
// the server default.
defaultRingingTimeout = 30 * time.Second
// ringingTimeoutMargin keeps a dialing request's deadline above the ringing
// timeout, so it doesn't abort before the call can be answered.
ringingTimeoutMargin = 2 * time.Second
)
// perAttemptTimeoutKey carries the caller's original timeout budget once its
// deadline has been detached (see withFailoverTimeout), so the transport can
// re-apply the full budget to each attempt instead of sharing one shrinking
// deadline across retries.
type perAttemptTimeoutKey struct{}
// failoverConfig is the resolved per-request region-failover configuration.
type failoverConfig struct {
enabled bool
force bool // bypass the cloud-host check (test-only)
backoffBase time.Duration // retry backoff base (test-only)
}
// attempts returns the total request attempts for a host; 1 means no failover.
// Failover engages only when enabled and the host is a LiveKit Cloud project
// or Cloud API domain (or force is set).
func (c failoverConfig) attempts(hostname string) int {
if c.enabled && (c.force || isCloud(hostname) || isCloudAPI(hostname)) {
return failoverMaxAttempts
}
return 1
}
// isCloudAPI reports whether the hostname is a LiveKit Cloud API endpoint
// (cloud-api.livekit.io or a cloud-api.<env>.livekit.io variant).
func isCloudAPI(hostname string) bool {
hostname = strings.ToLower(hostname)
return strings.HasPrefix(hostname, "cloud-api.") && strings.HasSuffix(hostname, ".livekit.io")
}
type failoverEnabledKey struct{}
type failoverForceKey struct{}
// WithFailover returns a context that enables or disables region failover for
// API requests made with it (enabled by default). Failover only engages for
// LiveKit Cloud hosts. Pass the returned context to any service client method.
func WithFailover(ctx context.Context, enabled bool) context.Context {
return context.WithValue(ctx, failoverEnabledKey{}, enabled)
}
// withFailoverForce returns a context that forces failover on regardless of
// host and overrides the retry backoff. Internal, test-only.
func withFailoverForce(ctx context.Context, backoff time.Duration) context.Context {
return context.WithValue(ctx, failoverForceKey{}, backoff)
}
func failoverConfigFromContext(ctx context.Context) failoverConfig {
cfg := failoverConfig{enabled: true, backoffBase: failoverBackoffBase}
if enabled, ok := ctx.Value(failoverEnabledKey{}).(bool); ok {
cfg.enabled = enabled
}
if backoff, ok := ctx.Value(failoverForceKey{}).(time.Duration); ok {
cfg.force = true
cfg.backoffBase = backoff
}
return cfg
}
// withFailoverTimeout prepares a context for region failover. When the request
// has a deadline long enough to fail over (>= minFailoverTimeout) and failover
// is enabled, it detaches the deadline — so it isn't enforced across retries by
// twirp or the transport — and stashes the original budget for the transport to
// re-apply per attempt. Shorter or deadline-free requests are returned
// unchanged. Called once where the context enters the API client (prepareContext).
func withFailoverTimeout(ctx context.Context) context.Context {
if !failoverConfigFromContext(ctx).enabled {
return ctx
}
deadline, ok := ctx.Deadline()
if !ok {
return ctx
}
if timeout := time.Until(deadline); timeout >= minFailoverTimeout {
return context.WithValue(detachDeadline(ctx), perAttemptTimeoutKey{}, timeout)
}
return ctx
}
// detachDeadline returns a context with parent's values that propagates parent's
// explicit cancellation but not its deadline. The failover loop resets the
// timeout per attempt, so the original deadline must not fire mid-failover.
func detachDeadline(parent context.Context) context.Context {
if parent.Done() == nil {
return parent // nothing to detach or forward
}
detached, cancel := context.WithCancel(context.WithoutCancel(parent))
go func() {
<-parent.Done()
// Forward explicit cancellation only; the original deadline is reset per
// attempt, so let it lapse.
if errors.Is(context.Cause(parent), context.DeadlineExceeded) {
return
}
cancel()
}()
return detached
}
// perAttemptBudget is the per-attempt timeout for a request: the budget stashed
// by withFailoverTimeout, the remaining time on a raw deadline (e.g. when the
// transport is exercised directly), or the default when the caller set neither.
func perAttemptBudget(ctx context.Context) time.Duration {
if d, ok := ctx.Value(perAttemptTimeoutKey{}).(time.Duration); ok {
return d
}
if deadline, ok := ctx.Deadline(); ok {
return time.Until(deadline)
}
return defaultRequestTimeout
}
// hasPerAttemptTimeout reports whether withFailoverTimeout already detached the
// deadline and stashed the budget (so the transport needn't detach again).
func hasPerAttemptTimeout(ctx context.Context) bool {
_, ok := ctx.Value(perAttemptTimeoutKey{}).(time.Duration)
return ok
}
// newAPIHTTPClient returns the *http.Client used by every API service client.
// It wraps the default transport with region-failover retries.
func newAPIHTTPClient() *http.Client {
return &http.Client{
Transport: &failoverTransport{base: http.DefaultTransport, regions: sharedAPIRegions},
}
}
// failoverTransport orchestrates region failover for a single API request. On
// a retryable failure (any transport error or HTTP 5xx) it discovers
// alternative regions via /settings/regions and replays the request — body and
// headers intact — against the next region, with exponential backoff. 4xx
// responses are returned immediately.
type failoverTransport struct {
base http.RoundTripper
regions *regionCache
}
func (t *failoverTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", userAgent)
cfg := failoverConfigFromContext(req.Context())
maxAttempts := cfg.attempts(req.URL.Hostname())
// Each attempt gets the caller's full timeout budget, reset per attempt. A
// budget shorter than minFailoverTimeout skips failover (thundering-herd
// guard); a disabled or non-cloud host already has maxAttempts == 1.
timeout := perAttemptBudget(req.Context())
if timeout > 0 && timeout < minFailoverTimeout {
maxAttempts = 1
}
// No failover: a single attempt. Re-apply the budget in case
// withFailoverTimeout detached the deadline upstream (it can't know the host
// is non-cloud), so the lone attempt is still bounded.
if maxAttempts == 1 {
ctx, cancel := withOptionalTimeout(req.Context(), timeout)
resp, err := t.base.RoundTrip(req.WithContext(ctx))
return terminate(resp, err, cancel)
}
return t.failover(req, maxAttempts, timeout, cfg.backoffBase)
}
// failover replays the request against successive regions, each with a fresh
// timeout budget and exponential backoff, until success, a non-retryable error,
// caller cancellation, or the attempts/regions are exhausted.
func (t *failoverTransport) failover(req *http.Request, maxAttempts int, timeout, backoffBase time.Duration) (*http.Response, error) {
// Buffer the body so it can be re-sent to each region.
var body []byte
if req.Body != nil {
b, err := io.ReadAll(req.Body)
_ = req.Body.Close()
if err != nil {
return nil, err
}
body = b
}
// Attempts run on a deadline-free context (the deadline is reset per attempt).
// withFailoverTimeout normally detaches it upstream; detach a raw deadline
// that reaches the transport directly (e.g. in tests) too.
base := req.Context()
if !hasPerAttemptTimeout(base) {
if _, ok := base.Deadline(); ok {
base = detachDeadline(base)
}
}
scheme, host := req.URL.Scheme, req.URL.Host
tried := map[string]struct{}{strings.ToLower(host): {}}
var regions *livekit.RegionSettings // discovered lazily on the first failure
// A Cloud API host has a single origin; region discovery is never consulted.
discover := !isCloudAPI(req.URL.Hostname())
var resp *http.Response
var err error
for attempt := 0; attempt < maxAttempts; attempt++ {
attemptCtx, cancel := withOptionalTimeout(base, timeout)
r := req.Clone(attemptCtx)
r.URL.Scheme, r.URL.Host, r.Host = scheme, host, host
if body != nil {
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil }
}
resp, err = t.base.RoundTrip(r)
// Stop on success, a non-retryable 4xx, caller cancellation, or the last
// attempt. terminate defers the per-attempt cancel to the body's Close.
if !isRetryable(resp, err) || attempt == maxAttempts-1 {
return terminate(resp, err, cancel)
}
if regions == nil && discover {
u := url.URL{Scheme: req.URL.Scheme, Host: req.URL.Host, Path: "/settings/regions"}
regions, _ = t.regions.get(req.URL.Host, u.String(), req.Header, 0)
}
nextScheme, nextHost, ok := nextRegion(regions, tried)
if !ok {
// With no fallback region, a retryable failure is retried against
// the same host.
nextScheme, nextHost = scheme, host
}
status := 0
if resp != nil {
status = resp.StatusCode
}
logger.Warnw("livekit API request failed, retrying with fallback url", err,
"failedUrl", scheme+"://"+host, "fallbackUrl", nextScheme+"://"+nextHost,
"attempt", attempt+1, "maxAttempts", maxAttempts, "status", status)
drainResponse(resp)
cancel() // this attempt's body is drained; release its timer
if !sleepCtx(base, backoffBase<<uint(attempt)) {
return nil, base.Err() // caller cancelled during backoff
}
scheme, host = nextScheme, nextHost
tried[strings.ToLower(host)] = struct{}{}
}
return resp, err // unreachable: the loop returns on the final attempt
}
// withOptionalTimeout applies a per-attempt timeout when one is set; otherwise
// it returns ctx unchanged with a no-op cancel.
func withOptionalTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return ctx, func() {}
}
return context.WithTimeout(ctx, timeout)
}
// terminate hands a response back to the caller, deferring context cleanup to
// the response body's Close so the body stays readable. With no body it cleans
// up immediately.
func terminate(resp *http.Response, err error, cleanup func()) (*http.Response, error) {
if resp != nil && resp.Body != nil {
resp.Body = &cancelOnClose{ReadCloser: resp.Body, cleanup: cleanup}
} else {
cleanup()
}
return resp, err
}
// cancelOnClose runs cleanup once the wrapped response body is closed, releasing
// the per-attempt context's timer without truncating the body early.
type cancelOnClose struct {
io.ReadCloser
cleanup func()
}
func (c *cancelOnClose) Close() error {
err := c.ReadCloser.Close()
c.cleanup()
return err
}
// isRetryable reports whether a region failover should be attempted: a transport
// error, an HTTP 5xx response, or a per-attempt timeout (an unresponsive region
// is worth retrying against another region, with a fresh budget). 4xx and
// success are terminal, as is caller cancellation — if the caller gave up, we
// stop rather than failing over.
func isRetryable(resp *http.Response, err error) bool {
if err != nil {
return !errors.Is(err, context.Canceled)
}
return resp != nil && resp.StatusCode >= 500
}
// nextRegion returns the first region whose host has not yet been attempted.
func nextRegion(settings *livekit.RegionSettings, attempted map[string]struct{}) (scheme, host string, ok bool) {
if settings == nil {
return "", "", false
}
for _, region := range settings.Regions {
u, err := url.Parse(signalling.ToHttpURL(region.Url))
if err != nil || u.Host == "" {
continue
}
if _, seen := attempted[strings.ToLower(u.Host)]; seen {
continue
}
return u.Scheme, u.Host, true
}
return "", "", false
}
func drainResponse(resp *http.Response) {
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return ctx.Err() == nil
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
// sharedAPIRegions is the process-wide region cache used by the API failover
// path. The RTC signaling path has its own regionURLProvider over a separate
// cache, but both share the same regionCache discovery/fetch/TTL logic.
var sharedAPIRegions = newRegionCache()
// RegionError indicates the /settings/regions endpoint returned a non-200
// status while attempting region failover.
type RegionError struct {
StatusCode int
}
func (e *RegionError) Error() string {
return "failed to fetch region settings: status " + http.StatusText(e.StatusCode)
}
// TwirpRegionError is a deprecated alias for [RegionError].
//
// Deprecated: use RegionError.
type TwirpRegionError = RegionError