carlos / platform Public

edge: an upgrade holds its slot until the handshake is answered (#358)

A Connection: Upgrade header is the client's to send, and the upgrade
branch admitted it without a slot whether or not the instance upgraded.
A slow ordinary request carrying the header therefore never counted
toward the host's or the client's in-flight slots.

The upgrade branch now holds a slot like any request, behind the same
slotWriter: the slot goes back at the first final answer or at the
reverse proxy's hijack on a 101, so an open WebSocket still does not
count. slotWriter already forwards Hijack and Unwrap, so the proxy's
ResponseController hijack works through it.

Tests: a Connection: Upgrade request that never upgrades is counted and
the same client is shed behind it; a real handshake through the stdlib
proxy holds the slot while the instance considers it, upgrades, and
leaves InFlight at 0 while bytes flow. TestRateUpgradeJudgedNotHeld is
now TestRateUpgradeHeldUntilAnswered, matching spec §4.2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 74021ca054fb5399370893372368300a16e74c89 parent b20d8be
4 files changed, +176 −38
  • internal/edge/edge.go +24 −15
  • internal/edge/ratelimit.go +13 −15
  • internal/edge/ratelimit_test.go +137 −6
  • internal/ratelimit/limiter.go +2 −2
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index ed155629d..e2ccd7cbd 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -1729,23 +1729,32 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// A Connection: Upgrade request (a WebSocket handshake, typically GET)
- // must reach the activator/proxy with the real ResponseWriter, completely
- // untouched by caching: the stdlib reverse proxy's upgrade path type-
- // asserts the ResponseWriter to http.Hijacker to take over the raw
- // connection, and once upgraded, the open WebSocket is also what keeps a
- // hibernating instance's idle clock from firing (the activator's inflight
- // counting — see internal/activator/activator.go's actEntry.inflight doc
- // comment). So this path skips caching entirely and stays byte-for-byte
- // identical to pre-cache behavior, rather than relying on
- // recordingResponseWriter's Hijack forwarding below.
+ // must reach the activator/proxy with a hijackable ResponseWriter,
+ // completely untouched by caching: the stdlib reverse proxy's upgrade
+ // path hijacks the ResponseWriter (through http.ResponseController) to
+ // take over the raw connection, and once upgraded, the open WebSocket is
+ // also what keeps a hibernating instance's idle clock from firing (the
+ // activator's inflight counting — see internal/activator/activator.go's
+ // actEntry.inflight doc comment). So this path skips caching and
+ // compression entirely, rather than relying on recordingResponseWriter's
+ // Hijack forwarding below; the only wrapper it gets is the rate limit's
+ // slotWriter, which forwards Hijack and Unwrap.
if isUpgradeRequest(r) {
- // The rate limit judges the handshake and holds no slot: an open
- // WebSocket is not work the instance is doing for the busy count. A
- // refusal is written to the raw writer; an admission leaves it
- // untouched.
- if _, ok := e.rateAdmit(w, r, up, false); !ok {
+ // The rate limit holds a slot until the instance answers the
+ // handshake, as for any request (§4.2): the header is the client's
+ // to send, so it must not buy a slow request out of the busy count.
+ // slotWriter gives the slot back at the first final answer or at the
+ // proxy's hijack on a 101, so an open WebSocket is not counted, and
+ // it is itself an http.Hijacker (with Unwrap) for the proxy. A
+ // refusal is written to the raw writer.
+ release, ok := e.rateAdmit(w, r, up)
+ if !ok {
return
}
+ if release != nil {
+ defer release()
+ w = &slotWriter{ResponseWriter: w, release: release}
+ }
if up.hibernating {
if e.act.ServeHTTP(w, r, up.route, up.proxy) {
return
@@ -1816,7 +1825,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// the defer covers an answer that never wrote anything. A request that
// holds no slot (off, exempt, not an instance) keeps its writer as it
// was.
- release, admitted := e.rateAdmit(w, r, up, true)
+ release, admitted := e.rateAdmit(w, r, up)
if !admitted {
return
}
diff --git a/internal/edge/ratelimit.go b/internal/edge/ratelimit.go
index 72551febe..124db8f74 100644
--- a/internal/edge/ratelimit.go
+++ b/internal/edge/ratelimit.go
@@ -108,12 +108,12 @@ func (e *Edge) parseRateSettings(rt registry.Route) ratelimit.Settings {
return s
}
-// rateAdmit judges r for up. holdSlot is false for an upgrade, which is
-// judged when its handshake arrives and never holds a slot. When ok is
-// false the 429 has been written and the caller returns. Otherwise release
-// is nil when the request holds no slot (not judged, or judged without one),
-// and when it is not, the caller must call it once the request is done; it
-// gives the slot back if the slot writer has not already.
+// rateAdmit judges r for up. When ok is false the 429 has been written and
+// the caller returns. Otherwise release is nil when the request holds no
+// slot (not judged, or admitted untracked), and when it is not, the caller
+// must call it once the request is done; it gives the slot back if the slot
+// writer has not already. An upgrade holds one like any request, until its
+// handshake is answered.
//
// Not judged: anything but an instance, a door caller (the box itself, on
// this box or on the box that backhauled it, hopDoorFrom), and a request
@@ -121,7 +121,7 @@ func (e *Edge) parseRateSettings(rt registry.Route) ratelimit.Settings {
// request comes from the hub, so keying on it would put every client in one
// bucket; the hub has the route row and the real client, and limits there
// (its route is viaTunnel).
-func (e *Edge) rateAdmit(w http.ResponseWriter, r *http.Request, up upstream, holdSlot bool) (release func(), ok bool) {
+func (e *Edge) rateAdmit(w http.ResponseWriter, r *http.Request, up upstream) (release func(), ok bool) {
if e.rateMode == RateOff || up.route.Kind != "instance" {
return nil, true
}
@@ -141,7 +141,7 @@ func (e *Edge) rateAdmit(w http.ResponseWriter, r *http.Request, up upstream, ho
observe := e.rateMode == RateObserve
// Keyed on the route's host, never the request's: an alias, a wildcard
// name and the primary are one instance with one set of buckets.
- t, d, retryAfter := e.rateLimiter.Admit(up.route.Host, eff, client, r.URL.Path, holdSlot, observe)
+ t, d, retryAfter := e.rateLimiter.Admit(up.route.Host, eff, client, r.URL.Path, true, observe)
if d != ratelimit.Allow {
e.noteRateLimited(up.route, d, client.String(), observe)
if !observe {
@@ -149,9 +149,6 @@ func (e *Edge) rateAdmit(w http.ResponseWriter, r *http.Request, up upstream, ho
return nil, false
}
}
- if !holdSlot {
- return nil, true
- }
return t.Release, true
}
@@ -189,7 +186,7 @@ func writeRateLimited(w http.ResponseWriter, retryAfter time.Duration) {
}
// slotWriter gives a request's slot back when the instance starts its
-// answer: the first final WriteHeader, Write or Flush. The busy count
+// answer: the first final WriteHeader, Write or Flush, or a Hijack. The busy count
// measures work the instance is doing, not how fast a client downloads, so a
// long body or an event stream stops counting once its headers are out. An
// informational (1xx) header is not the answer and keeps the slot.
@@ -229,9 +226,10 @@ func (s *slotWriter) Flush() {
}
}
-// Hijack forwards like countingWriter's, so the writer stack below a
-// recordingResponseWriter keeps the shape it had. Nothing on the path this
-// writer serves upgrades; upgrades never get one.
+// Hijack gives the slot back and forwards like countingWriter's. On an
+// upgrade the stdlib reverse proxy hijacks once the instance has answered
+// 101, so this is where an upgrade's slot goes back; the open connection is
+// not work the instance is doing for the busy count.
func (s *slotWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := s.ResponseWriter.(http.Hijacker)
if !ok {
diff --git a/internal/edge/ratelimit_test.go b/internal/edge/ratelimit_test.go
index 3866307e0..da99aebea 100644
--- a/internal/edge/ratelimit_test.go
+++ b/internal/edge/ratelimit_test.go
@@ -5,6 +5,7 @@ package edge
// ServeHTTP) against a real upstream; nothing calls rateAdmit directly.
import (
+ "bufio"
"context"
"fmt"
"io"
@@ -385,10 +386,12 @@ func TestRateSlotReleasedAtHeaders(t *testing.T) {
io.Copy(io.Discard, resp.Body)
}
-// An upgrade is judged when the handshake arrives and never holds a slot:
-// it keeps the raw, hijackable writer, and the open connection does not count
-// toward busy. Over the backstop it is refused before the activator.
-func TestRateUpgradeJudgedNotHeld(t *testing.T) {
+// An upgrade holds a slot like any request until the instance answers its
+// handshake (§4.2), through a writer that is still an http.Hijacker. The
+// activator answers each one at once, so the slot goes back and ten
+// upgrades leave nothing held. Over the backstop the next is refused before
+// the activator.
+func TestRateUpgradeHeldUntilAnswered(t *testing.T) {
var calls atomic.Int64
var e *Edge
var heldDuring atomic.Int64
@@ -419,8 +422,8 @@ func TestRateUpgradeJudgedNotHeld(t *testing.T) {
t.Fatalf("upgrade %d: status %d, want 101", i, rec.Code)
}
}
- if n := heldDuring.Load(); n != 0 {
- t.Fatalf("slots held during admitted upgrades summed to %d, want 0", n)
+ if n := heldDuring.Load(); n != 10 {
+ t.Fatalf("slots held during admitted upgrades summed to %d, want 10: each holds one until it is answered", n)
}
if n := e.rateLimiter.InFlight(rlHost); n != 0 {
t.Fatalf("InFlight = %d after ten upgrades, want 0", n)
@@ -433,6 +436,134 @@ func TestRateUpgradeJudgedNotHeld(t *testing.T) {
}
}
+// A Connection: Upgrade header is the client's to send, so it buys nothing:
+// a request carrying one that the instance never upgrades is counted while
+// it waits like any other, and the same client is shed behind it while the
+// instance is busy.
+func TestRateUpgradeHeaderWithoutUpgradeHoldsSlot(t *testing.T) {
+ up := newBlockingUpstream(t)
+ s := newRateScene(t, "enforce", nil, rlRoute(up.addr(), `{"busy_at":1,"client_share":1}`))
+ t.Cleanup(up.open) // before the front server closes on a failure
+
+ upgradeHeaderGet := func(path string) <-chan rlResult {
+ ch := make(chan rlResult, 1)
+ go func() {
+ req, err := http.NewRequest(http.MethodGet, s.front.URL+path, nil)
+ if err != nil {
+ ch <- rlResult{err: err}
+ return
+ }
+ req.Host = rlHost
+ req.Header.Set(rlPeerHeader, "203.0.113.1")
+ req.Header.Set("Connection", "Upgrade")
+ req.Header.Set("Upgrade", "websocket")
+ resp, err := rlClient.Do(req)
+ if err != nil {
+ ch <- rlResult{err: err}
+ return
+ }
+ defer resp.Body.Close()
+ b, err := io.ReadAll(resp.Body)
+ ch <- rlResult{status: resp.StatusCode, header: resp.Header, body: string(b), err: err}
+ }()
+ return ch
+ }
+
+ slow := upgradeHeaderGet("/slow")
+ up.waitArrived(t, "the slow request")
+ if n := s.e.rateLimiter.InFlight(rlHost); n != 1 {
+ t.Fatalf("InFlight = %d while a Connection: Upgrade request that never upgrades waits on the instance, want 1", n)
+ }
+ want429(t, waitResult(t, upgradeHeaderGet("/again"), "the same client's second request"), "1",
+ "the same client's second Connection: Upgrade request while busy")
+ if n := up.hits.Load(); n != 1 {
+ t.Fatalf("upstream hits = %d, want 1: a shed request must never reach the upstream", n)
+ }
+
+ up.open()
+ wantOK(t, waitResult(t, slow, "the slow request"), "the slow request")
+ if n := s.e.rateLimiter.InFlight(rlHost); n != 0 {
+ t.Fatalf("InFlight = %d after the slow request was answered, want 0", n)
+ }
+}
+
+// A real WebSocket handshake through ServeHTTP and the stdlib reverse proxy:
+// the slot is held while the instance considers the handshake, the proxy can
+// still hijack the connection, and once the 101 is out the slot is back
+// while bytes keep flowing both ways. With busy_at 1, the open socket does
+// not make the instance busy for anyone, its own client included.
+func TestRateWebSocketUpgradeReleasesOnceUpgraded(t *testing.T) {
+ var e atomic.Pointer[Edge]
+ var heldAtHandshake atomic.Int64
+ heldAtHandshake.Store(-1)
+ ws := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/socket" {
+ io.WriteString(w, "ok")
+ return
+ }
+ heldAtHandshake.Store(int64(e.Load().rateLimiter.InFlight(rlHost)))
+ conn, buf, err := w.(http.Hijacker).Hijack()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ buf.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n")
+ if buf.Flush() != nil {
+ return
+ }
+ for {
+ line, err := buf.ReadString('\n')
+ if err != nil {
+ return
+ }
+ buf.WriteString("echo:" + line)
+ if buf.Flush() != nil {
+ return
+ }
+ }
+ }))
+ t.Cleanup(ws.Close)
+ s := newRateScene(t, "enforce", nil, rlRoute(ws.Listener.Addr().String(), `{"busy_at":1,"client_share":1}`))
+ e.Store(s.e)
+
+ c, err := net.Dial("tcp", s.front.Listener.Addr().String())
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+ if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
+ t.Fatal(err)
+ }
+ fmt.Fprintf(c, "GET /socket HTTP/1.1\r\nHost: %s\r\n%s: 203.0.113.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", rlHost, rlPeerHeader)
+ br := bufio.NewReader(c)
+ resp, err := http.ReadResponse(br, nil)
+ if err != nil {
+ t.Fatalf("read handshake response: %v", err)
+ }
+ if resp.StatusCode != http.StatusSwitchingProtocols {
+ t.Fatalf("handshake status = %d, want 101", resp.StatusCode)
+ }
+ if n := heldAtHandshake.Load(); n != 1 {
+ t.Fatalf("InFlight = %d while the instance considered the handshake, want 1: an upgrade holds a slot until it is answered", n)
+ }
+
+ for _, msg := range []string{"one\n", "two\n"} {
+ fmt.Fprint(c, msg)
+ line, err := br.ReadString('\n')
+ if err != nil {
+ t.Fatalf("read echo of %q: %v", msg, err)
+ }
+ if line != "echo:"+msg {
+ t.Fatalf("echo = %q, want %q", line, "echo:"+msg)
+ }
+ if n := s.e.rateLimiter.InFlight(rlHost); n != 0 {
+ t.Fatalf("InFlight = %d while the socket is open, want 0: the slot goes back once upgraded", n)
+ }
+ }
+ wantOK(t, s.get(t, "203.0.113.1", rlHost, "/other"), "the socket's own client during the open socket")
+ wantOK(t, s.get(t, "203.0.113.2", rlHost, "/other"), "a second client during the open socket")
+}
+
// Review Focus 1: an alias is the primary's instance, so it spends the
// primary's bucket.
func TestRateAliasUsesPrimaryHost(t *testing.T) {
diff --git a/internal/ratelimit/limiter.go b/internal/ratelimit/limiter.go
index 9510d9358..dacca5d42 100644
--- a/internal/ratelimit/limiter.go
+++ b/internal/ratelimit/limiter.go
@@ -97,7 +97,7 @@ type Ticket struct {
done bool // guarded by l.mu
}
-// noSlot is the Ticket for a request let through without a slot: an upgrade
+// noSlot is the Ticket for a request let through without a slot: one
// judged with holdSlot false, or a client admitted untracked.
var noSlot = &Ticket{}
@@ -125,7 +125,7 @@ func (t *Ticket) Release() {
l.mu.Unlock()
}
-// Admit decides one request. holdSlot false (upgrades) judges without taking a slot.
+// Admit decides one request. holdSlot false judges without taking a slot.
// On Shed/OverCeiling the returned Ticket is nil unless observe is true, in which
// case the request is let through and the Ticket holds a slot like Allow.
//