From 3f59d0dd9ed7e09fa3f1c271548014edf6b41fa8 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 20 Jul 2026 06:12:35 +0200 Subject: [PATCH] add mock for testing region pins with API (#4691) we'll ensure that clients support redirection when making an API to an unpinned region --- cmd/test-server/README.md | 1 + cmd/test-server/config.go | 22 ++++++++++++++++++++++ cmd/test-server/handlers.go | 19 +++++++++++++++++++ cmd/test-server/main.go | 30 +++++++++++++++++++++++++++++- 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md index 00649f8fb..0e7152f14 100644 --- a/cmd/test-server/README.md +++ b/cmd/test-server/README.md @@ -67,6 +67,7 @@ field — for normal behavior. Every field is optional: | `response` | — | the response message for the called method (a JSON object, protojson-shaped); replaces the populated default, giving full control over the returned payload. | | `skipAuth` | `false` | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). | | `sipStatus` | — | fail a SIP dial method (`CreateSIPParticipant`/`TransferSIPParticipant`) with a SIP status, e.g. `{"code":486,"status":"Busy Here"}` (`status` optional). The Twirp error code and `sip_status_code`/`sip_status`/`error_details` metadata are derived from it exactly as the real server does. Composes with `delayMs` to simulate "ring, then fail". | +| `pinnedRegions` | — | array of region **names** the project is pinned to (its **allowed** regions), e.g. `["region-1"]`, mirroring cloud's per-project `PinnedRegions` (region names, not indices). When non-empty a pin is in effect: any region whose name is **not** listed rejects the request with **HTTP 451** (a region-pin violation), and `GET /settings/regions` returns **only** the listed regions. The SDK is expected to see the 451, re-fetch `/settings/regions`, and retry against an allowed region — the 451 carries no region hint (its body is the middleware's plain text, not a Twirp error). This redirect is always active in clients; it can't be disabled. Use a name no listener advertises (e.g. `["region-99"]`) to model a pinned region that's unreachable. Names are the mock's region names (`region-0`, `region-1`, …); contrast `failRegions`, which addresses listeners by index. | Example: `X-Lk-Mock: {"skipAuth":true,"failRegions":[0],"failStatus":400}` diff --git a/cmd/test-server/config.go b/cmd/test-server/config.go index bca4ee251..3f1cb3fdb 100644 --- a/cmd/test-server/config.go +++ b/cmd/test-server/config.go @@ -30,6 +30,16 @@ const ( headerRegion = "X-Lk-Mock-Region" ) +const ( + // regionPinStatus is the HTTP status cloud middleware returns for a region-pin + // violation — an API call reaching a region the project is not pinned to. It is + // 451 (Unavailable For Legal Reasons); clients key off it to rediscover regions + // and retry against an allowed one. + regionPinStatus = http.StatusUnavailableForLegalReasons + // regionPinMessage is the plain-text body the middleware writes with the 451. + regionPinMessage = "project not allowed in this region." +) + // Deprecated: the individual X-Lk-Mock-* control headers predate the unified // X-Lk-Mock JSON header. They are still honored for existing clients; new // clients should send X-Lk-Mock instead. When X-Lk-Mock is present its fields @@ -86,6 +96,18 @@ type mockConfig struct { // derived from it exactly as the real server does, so the SDK sees an // identical error. Composes with DelayMs to simulate "ring, then fail". SIPStatus *sipStatusConfig `json:"sipStatus,omitempty"` + // PinnedRegions lists the region names a project is pinned to (its allowed + // regions), mirroring cloud's per-project PinnedRegions — which are region names, + // not indices. When non-empty a pin is in effect: any region whose name is NOT + // listed rejects the request with HTTP 451 (see regionPinStatus), exactly as + // cloud middleware turns away an API call reaching a region the project is not + // pinned to, and GET /settings/regions returns only the listed regions. The + // client is expected to see the 451, re-fetch /settings/regions, and retry + // against an allowed region — the 451 carries no region hint. An empty/absent + // value means "not pinned": every region serves and discovery returns all + // regions. Names are this mock's region names (e.g. "region-0"); contrast + // FailRegions, which addresses listeners by index. + PinnedRegions []string `json:"pinnedRegions,omitempty"` // legacyDelayMs is the sleep used by the deprecated "delay" fail mode. It is // populated only from the legacy X-Lk-Mock-Delay-Ms header, never from JSON. diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go index 9d14fd2b3..62c1b6c8b 100644 --- a/cmd/test-server/handlers.go +++ b/cmd/test-server/handlers.go @@ -17,6 +17,7 @@ package main import ( "io" "net/http" + "slices" "strconv" "strings" "time" @@ -145,6 +146,14 @@ func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) { return } + // A project pinned to other regions is turned away by cloud middleware up front + // — before the request is served and with no latency — when it reaches a region + // it isn't pinned to. The check is by region name, as it is on the real server. + if len(cfg.PinnedRegions) > 0 && !slices.Contains(cfg.PinnedRegions, h.regionName()) { + h.failRegionPin(w) + return + } + // Delay before responding (success or failure). An explicit delayMs overrides // the method's natural latency — e.g. CreateSIPParticipant blocking until the // callee answers. @@ -211,6 +220,16 @@ func (h *mockHandler) failSIP(w http.ResponseWriter, cfg *mockConfig) { writeTwirpErr(w, xtwirp.ToError(st)) } +// failRegionPin rejects the request with HTTP 451, mirroring cloud middleware +// turning away an API call from a project pinned to a different region. The body +// is the middleware's plain-text message (not a Twirp error): clients key off the +// 451 status to rediscover regions and retry against an allowed one. +func (h *mockHandler) failRegionPin(w http.ResponseWriter) { + w.Header().Set(headerRegion, "") + w.WriteHeader(regionPinStatus) + _, _ = w.Write([]byte(regionPinMessage)) +} + // writeAPIResponse serves a populated, type-correct response for a known API // method. The response is the reflection-populated default unless the mock // config carries a `response` (protojson), which overrides it entirely. Content diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go index 7a2e4f52d..2e1b71f91 100644 --- a/cmd/test-server/main.go +++ b/cmd/test-server/main.go @@ -112,7 +112,14 @@ func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { w.WriteHeader(cfg.RegionsStatus) return } - body, err := protojson.Marshal(h.regions) + regions := h.regions + if len(cfg.PinnedRegions) > 0 { + // A pinned project's discovery returns only its allowed regions, so the + // client redirects straight to one after a 451 — mirroring cloud filtering + // /settings/regions by the project's PinnedRegions. + regions = pinnedRegionSettings(h.regions, cfg.PinnedRegions) + } + body, err := protojson.Marshal(regions) if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -127,6 +134,27 @@ func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { h.serveAPI(w, r) } +// regionName is the advertised name of the region this listener represents. +func (h *mockHandler) regionName() string { + if h.regionIndex < len(h.regions.Regions) { + return h.regions.Regions[h.regionIndex].Region + } + return "" +} + +// pinnedRegionSettings returns only the regions whose name appears in pinned, +// mirroring how cloud filters /settings/regions to a project's PinnedRegions. +// Names outside the region list are ignored. +func pinnedRegionSettings(all *livekit.RegionSettings, pinned []string) *livekit.RegionSettings { + out := &livekit.RegionSettings{} + for _, region := range all.Regions { + if slices.Contains(pinned, region.Region) { + out.Regions = append(out.Regions, region) + } + } + return out +} + func (h *mockHandler) shouldFail(cfg *mockConfig) bool { return slices.Contains(cfg.FailRegions, h.regionIndex) }