Skip to content

Commit 740115a

Browse files
rgarciaclaude
andcommitted
feat: add control-plane fallback for direct-to-VM browser routing
When a GET/HEAD request is routed directly to a browser VM and that VM is unreachable or gone, transparently re-issue the original request to the control plane exactly once. The direct-VM middleware now snapshots the original control-plane request target (URL/Host/Authorization) before rewriting the request to the VM. After the routed attempt, if the method is idempotent (GET/HEAD) and the attempt failed as 'VM unreachable / session gone' -- a transport error, HTTP 502/503/504, or the clean gone signal (404 + X-Kernel-Upstream: gone + JSON body code 'browser_gone') -- the request is restored to its original target (Authorization re-added, jwt query param dropped) and sent once more to the control plane. The cache sniff/evict in finalizeResponse runs on the final response. Never falls back for non-idempotent methods, non-routed requests, normal success, or ordinary 4xx (incl. a 404 without the gone marker). At most one fallback attempt -- no looping. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bec994b commit 740115a

2 files changed

Lines changed: 447 additions & 0 deletions

File tree

lib/browserrouting/route_cache.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.
8686
if err != nil {
8787
return nil, err
8888
}
89+
90+
// snapshot holds the original control-plane request target so a routed
91+
// attempt can be transparently re-issued to the control plane if the VM
92+
// turns out to be unreachable/gone. It stays nil for requests that are
93+
// not actually rewritten to the VM.
94+
var snapshot *originalRequestTarget
95+
8996
sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path)
9097
if ok {
9198
if _, ok := allowed[subresource]; ok {
@@ -95,6 +102,11 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.
95102
if err != nil {
96103
return nil, err
97104
}
105+
106+
// Capture the original target before mutating the request so
107+
// we can restore it for a control-plane fallback.
108+
snapshot = snapshotRequestTarget(req)
109+
98110
req.Header.Del("Authorization")
99111
if route.JWT != "" {
100112
q := req.URL.Query()
@@ -114,13 +126,157 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.
114126
}
115127

116128
res, err := next(req)
129+
130+
// Control-plane fallback: only for requests that were actually routed to
131+
// the VM. If the direct-VM attempt failed as "VM unreachable / session
132+
// gone" and the method is idempotent (GET/HEAD), re-issue the ORIGINAL
133+
// request to the control plane exactly once. We never fall back for
134+
// non-idempotent methods (could double-execute side effects), and we
135+
// never loop: at most one fallback attempt.
136+
if snapshot != nil && shouldFallBackToControlPlane(req.Method, res, err) {
137+
// Drain and close any partial VM response body before retrying so we
138+
// don't leak the connection.
139+
if res != nil && res.Body != nil {
140+
_, _ = io.Copy(io.Discard, res.Body)
141+
_ = res.Body.Close()
142+
}
143+
144+
// Restore the original control-plane target: URL/Host/Authorization
145+
// and drop the VM jwt query param. A body-less GET/HEAD is safe to
146+
// resend.
147+
snapshot.restore(req)
148+
149+
res, err = next(req)
150+
}
151+
117152
if err != nil {
118153
return res, err
119154
}
155+
// Cache sniff/evict always runs on the FINAL response (VM or fallback).
120156
return finalizeResponse(res, cache, lifecycle)
121157
}
122158
}
123159

160+
// originalRequestTarget captures the parts of a request that the direct-VM
161+
// rewrite mutates, so the request can be reconstructed for a control-plane
162+
// fallback.
163+
type originalRequestTarget struct {
164+
scheme string
165+
host string // URL.Host
166+
path string
167+
rawPath string
168+
rawQuery string
169+
reqHost string // Request.Host
170+
authorization []string
171+
hadAuthHeader bool
172+
}
173+
174+
func snapshotRequestTarget(req *http.Request) *originalRequestTarget {
175+
snap := &originalRequestTarget{
176+
scheme: req.URL.Scheme,
177+
host: req.URL.Host,
178+
path: req.URL.Path,
179+
rawPath: req.URL.RawPath,
180+
rawQuery: req.URL.RawQuery,
181+
reqHost: req.Host,
182+
}
183+
if values, ok := req.Header["Authorization"]; ok {
184+
snap.hadAuthHeader = true
185+
snap.authorization = append([]string(nil), values...)
186+
}
187+
return snap
188+
}
189+
190+
// restore rewrites req back to the original control-plane target: it restores
191+
// the URL/Host and Authorization header and drops the jwt query param that the
192+
// VM rewrite added.
193+
func (s *originalRequestTarget) restore(req *http.Request) {
194+
req.URL.Scheme = s.scheme
195+
req.URL.Host = s.host
196+
req.URL.Path = s.path
197+
req.URL.RawPath = s.rawPath
198+
// Restoring the original RawQuery inherently drops the jwt query param, which
199+
// only existed in the VM-rewritten request.
200+
req.URL.RawQuery = s.rawQuery
201+
req.Host = s.reqHost
202+
203+
if s.hadAuthHeader {
204+
req.Header["Authorization"] = append([]string(nil), s.authorization...)
205+
} else {
206+
req.Header.Del("Authorization")
207+
}
208+
}
209+
210+
// shouldFallBackToControlPlane reports whether a routed direct-VM attempt should
211+
// be retried against the control plane. It triggers only for idempotent methods
212+
// (GET/HEAD) when the VM attempt failed as "VM unreachable / session gone":
213+
// - a transport/connection error, OR
214+
// - HTTP 502/503/504, OR
215+
// - the clean gone signal: 404 with header X-Kernel-Upstream: gone and JSON
216+
// body {"code":"browser_gone",...}.
217+
//
218+
// It does NOT trigger on a normal success or an ordinary 4xx from a live VM
219+
// (e.g. 400/401/403, or a 404 that is not the gone marker).
220+
func shouldFallBackToControlPlane(method string, res *http.Response, err error) bool {
221+
switch strings.ToUpper(method) {
222+
case http.MethodGet, http.MethodHead:
223+
default:
224+
return false
225+
}
226+
227+
// Transport/network/connection error reaching the VM.
228+
if err != nil {
229+
return true
230+
}
231+
if res == nil {
232+
return false
233+
}
234+
235+
switch res.StatusCode {
236+
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
237+
// 502/503/504 -- today's dead-VM signal ("Upstream not available").
238+
return true
239+
case http.StatusNotFound:
240+
// Future clean gone signal: 404 + X-Kernel-Upstream: gone + browser_gone.
241+
return isBrowserGoneResponse(res)
242+
default:
243+
return false
244+
}
245+
}
246+
247+
// isBrowserGoneResponse detects the clean "browser gone" marker: an
248+
// X-Kernel-Upstream: gone response header plus a JSON body whose code field is
249+
// "browser_gone". It buffers and restores the response body so the check is
250+
// non-destructive for callers that still read it.
251+
func isBrowserGoneResponse(res *http.Response) bool {
252+
if res == nil {
253+
return false
254+
}
255+
if !strings.EqualFold(strings.TrimSpace(res.Header.Get("X-Kernel-Upstream")), "gone") {
256+
return false
257+
}
258+
if res.Body == nil || !isJSONResponse(res.Header) {
259+
return false
260+
}
261+
262+
body, err := io.ReadAll(res.Body)
263+
_ = res.Body.Close()
264+
// Restore the body so the response remains readable regardless of outcome.
265+
res.Body = io.NopCloser(bytes.NewReader(body))
266+
res.ContentLength = int64(len(body))
267+
if err != nil {
268+
return false
269+
}
270+
271+
var payload struct {
272+
Code string `json:"code"`
273+
}
274+
if err := json.Unmarshal(body, &payload); err != nil {
275+
return false
276+
}
277+
return payload.Code == "browser_gone"
278+
}
279+
124280
func parseCacheLifecycle(req *http.Request) (cacheLifecycle, error) {
125281
if req == nil || req.URL == nil {
126282
return cacheLifecycle{}, nil

0 commit comments

Comments
 (0)