Files
magpie/internal/gateway/queue_keepalive_test.go
yetone 24bef0f9be gateway: a streamed request waiting for its key's slot (Concurrency) or for room in its minute (Requests per minute) has its agent sent the stream's 200 and SSE keepalives after 15s, as a held try's stream is, instead of nothing until it is sent (coeo91 on Discord: WorkBuddy, Trae and Qoder said it timed out)
serve's try took its lane slot and waited for the minute (04704111)
before holdWriter.watch started, so for up to 2 minutes (or QueueWait,
unbounded by default) the agent got no status, no headers, no byte.
keepQueued runs beside lanes.take and rpms.wait: past keepHeldAfter it
sends the stream's 200 and ": keepalive" comments every keepaliveEvery
(keepAlive, the mechanism held tries already use; none for Gemini, #934).
Once they are out the try is held, so the vendor's error, or the queue
turning it away, reaches the agent as its protocol's stream error
(failTo: Chat's error chunk, Responses' response.failed, Messages'
event: error), and the next candidate still answers in the same stream.
A wait shorter than keepHeldAfter sends nothing: its 429 keeps its
Retry-After. Non-streamed requests are unchanged.

What the agents do with it (read from their app bundles, not tried live):
- WorkBuddy 5.5.6 / WorkBuddy AI 5.6.2 (codebuddy-headless.js): no
  header timeout, first-token timer 20 min, so a 2-minute queue alone
  doesn't time it out there.
- Qoder 0.4.3: 60s to headers, then 60s more after them; its SSE parser
  drops comment lines, so keepalives don't extend that. Headers at 15s
  give it 75s instead of 60s; longer waits need firstPayloadTimeoutMs.
- Trae: its agent is downloaded at runtime and wasn't read.

Verified (Linux, golang:1.26.8 in Docker):
- queue_keepalive_test.go through lanGuard(New().Handler()), with an
  agent whose connection gives up after 400ms of silence: a request
  queued 1.2s for the minute (chat, responses, messages) and one queued
  for a Concurrency slot get 200, keepalives, then the reply; a vendor
  500 after keepalives is each protocol's stream error; a QueueWait
  waited out is the stream's error naming the limit; a short wait is
  still a plain 429. With keepQueued taken out, every one but the 429
  test fails with "i/o timeout". TestQueuedUnheldTryHeldOnceKeptAlive
  fails without the hold (": keepalive\n{\"error\":...}" in the stream).
- go test -tags nogui ./internal/gateway: ok (122s).
- go test -race on internal/gateway the way CI runs it (go test -race -c,
  build/race-shards.sh over 14 processes, -test.timeout=20m): 1258 tests,
  every shard ok. A single serial -race process passes 10m's default
  timeout, which is why CI shards it (#923).
- go vet -tags nogui ./internal/gateway for linux, windows and darwin;
  gofmt -l clean. After rebasing on 5e17a183: TestQueued, TestBurst,
  RPM, TestQuiet and TestSilentHeld under -race, and linux vet, pass.
2026-10-08 23:13:12 +08:00

276 lines
11 KiB
Go

package gateway
import (
"bufio"
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/yetone/magpie/internal/provider"
)
// streamVendor streams a short reply on chat completions, Responses and
// Messages; fail is how many requests it answers 500 "overloaded" first,
// after the first it lets through.
type streamVendor struct {
mu sync.Mutex
seen int
fail int
block chan struct{} // the first request waits on it, if set
}
func (v *streamVendor) ServeHTTP(w http.ResponseWriter, r *http.Request) {
io.Copy(io.Discard, r.Body)
v.mu.Lock()
v.seen++
n := v.seen
failing := n > 1 && v.fail > 0
if failing {
v.fail--
}
v.mu.Unlock()
if n == 1 && v.block != nil {
select {
case <-v.block:
case <-r.Context().Done():
return
}
}
if failing {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
io.WriteString(w, `{"error":{"type":"api_error","message":"the vendor is overloaded"}}`)
return
}
w.Header().Set("Content-Type", "text/event-stream")
switch {
case strings.HasSuffix(r.URL.Path, "/messages"):
io.WriteString(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"m\",\"content\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":0}}}\n\n"+
"event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n"+
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"queued ok\"}}\n\n"+
"event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n"+
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":2}}\n\n"+
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
case strings.HasSuffix(r.URL.Path, "/responses"):
io.WriteString(w, quietCreated+quietDelta("queued ok")+quietDone)
default:
io.WriteString(w, "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"queued ok\"}}]}\n\n"+
"data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"model\":\"m\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n"+
"data: [DONE]\n\n")
}
}
// idleConn is a connection whose reads time out after idle with nothing
// read, as an agent's HTTP client with an idle timeout (WorkBuddy, Trae,
// Qoder): any byte, a comment's too, starts it again.
type idleConn struct {
net.Conn
idle time.Duration
}
func (c idleConn) Read(b []byte) (int, error) {
c.SetReadDeadline(time.Now().Add(c.idle))
return c.Conn.Read(b)
}
// idleClient is an HTTP client that gives up once it hears nothing for
// idle, headers or body.
func idleClient(idle time.Duration) *http.Client {
d := &net.Dialer{}
return &http.Client{Transport: &http.Transport{
DisableKeepAlives: true,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
c, err := d.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
return idleConn{c, idle}, nil
},
}}
}
// queueGateway is the real server (lanGuard in front) with a provider "q"
// of one key, set up by p, and every keepalive within milliseconds.
func queueGateway(t *testing.T, v *streamVendor, set func(*provider.Provider)) string {
t.Helper()
fresh(t)
keepFast(t, keepaliveGap, 40*time.Millisecond, keepaliveLongest)
k, w := keepHeldAfter, watchEvery
keepHeldAfter, watchEvery = 80*time.Millisecond, 10*time.Millisecond
t.Cleanup(func() { keepHeldAfter, watchEvery = k, w })
up := httptest.NewServer(v)
t.Cleanup(up.Close)
p := provider.Provider{ID: "q", Name: "Queued", Key: "k", Models: []string{"m"}, Chat: up.URL + "/v1", Responses: up.URL + "/v1", Anthropic: up.URL}
set(&p)
if err := provider.Save(p); err != nil {
t.Fatal(err)
}
gw := httptest.NewServer(lanGuard(New().Handler()))
t.Cleanup(gw.Close)
return gw.URL
}
var queueAsks = []struct{ name, path, body string }{
{"chat", "/v1/chat/completions", `{"model":"q/m","stream":true,"messages":[{"role":"user","content":"hi"}]}`},
{"responses", "/v1/responses", `{"model":"q/m","stream":true,"input":"hi"}`},
{"messages", "/v1/messages", `{"model":"q/m","stream":true,"max_tokens":50,"messages":[{"role":"user","content":"hi"}]}`},
}
// askIdle sends body to path with an agent that gives up after idle of
// silence, and returns the status, the stream read, and the read's error.
func askIdle(gw, path, body string, idle time.Duration) (int, string, error) {
req, _ := http.NewRequest("POST", gw+path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res, err := idleClient(idle).Do(req)
if err != nil {
return 0, "", err
}
defer res.Body.Close()
var b strings.Builder
_, err = io.Copy(&b, bufio.NewReader(res.Body))
return res.StatusCode, b.String(), err
}
// coeo91 on Discord: with a Requests per minute limit, a request held for
// room in the minute had its agent hear nothing at all — WorkBuddy, Trae
// and Qoder said it timed out. Through the real server, a streaming
// request that waits 1.2s for the minute with an agent that gives up
// after 400ms of silence is sent the stream's 200 and keepalives
// meanwhile, then the vendor's reply, in each protocol's stream.
func TestQueuedStreamKeptAliveForTheMinute(t *testing.T) {
for _, a := range queueAsks {
t.Run(a.name, func(t *testing.T) {
shortMinute(t, 1200*time.Millisecond, time.Minute)
gw := queueGateway(t, &streamVendor{}, func(p *provider.Provider) { p.MaxRPM = 1 })
if code, body, err := askIdle(gw, a.path, a.body, 400*time.Millisecond); err != nil || code != 200 {
t.Fatalf("first: %d %v %s", code, err, body)
}
began := time.Now()
code, body, err := askIdle(gw, a.path, a.body, 400*time.Millisecond)
if err != nil {
t.Fatalf("the agent gave up after %s of a queued request: %v (read %q)", time.Since(began).Round(time.Millisecond), err, body)
}
if code != 200 || !strings.Contains(body, ": keepalive") || !strings.Contains(body, "queued ok") {
t.Fatalf("queued = %d %q, want 200, keepalives, then the reply", code, body)
}
if d := time.Since(began); d < time.Second {
t.Fatalf("answered after %s, inside the minute", d)
}
})
}
}
// A slot of MaxConcurrency waited for is kept alive the same way.
func TestQueuedStreamKeptAliveForASlot(t *testing.T) {
one := 1
v := &streamVendor{block: make(chan struct{})}
gw := queueGateway(t, v, func(p *provider.Provider) { p.MaxConcurrency = &one })
first := make(chan error, 1)
go func() {
_, _, err := askIdle(gw, queueAsks[0].path, queueAsks[0].body, 5*time.Second)
first <- err
}()
time.AfterFunc(time.Second, func() { close(v.block) })
time.Sleep(100 * time.Millisecond) // the first has the slot
code, body, err := askIdle(gw, queueAsks[0].path, queueAsks[0].body, 400*time.Millisecond)
if err != nil || code != 200 || !strings.Contains(body, ": keepalive") || !strings.Contains(body, "queued ok") {
t.Fatalf("queued for a slot = %d %v %q, want 200, keepalives, then the reply", code, err, body)
}
if err := <-first; err != nil {
t.Fatal(err)
}
}
// Once a queued request's agent has the stream's 200, the vendor failing
// after it reaches the agent as its stream's error event, in its
// protocol's own shape, not as a status the agent can no longer be told,
// nor a JSON error body inside the stream.
func TestQueuedStreamFailureIsTheStreamsError(t *testing.T) {
wants := map[string][]string{
"chat": {`"error"`, "the vendor is overloaded"},
"responses": {"event: response.failed", `"status":"failed"`, "the vendor is overloaded"},
"messages": {"event: error", `"type":"error"`, "the vendor is overloaded"},
}
for _, a := range queueAsks {
t.Run(a.name, func(t *testing.T) {
shortMinute(t, 1200*time.Millisecond, time.Minute)
gw := queueGateway(t, &streamVendor{fail: 10}, func(p *provider.Provider) { p.MaxRPM = 1 })
if code, body, err := askIdle(gw, a.path, a.body, 400*time.Millisecond); err != nil || code != 200 {
t.Fatalf("first: %d %v %s", code, err, body)
}
code, body, err := askIdle(gw, a.path, a.body, 2*time.Second)
if err != nil || code != 200 || !strings.Contains(body, ": keepalive") {
t.Fatalf("queued = %d %v %q, want the stream's 200 and keepalives", code, err, body)
}
for _, w := range wants[a.name] {
if !strings.Contains(body, w) {
t.Fatalf("stream %q doesn't say %s", body, w)
}
}
if strings.Contains(body, `{"error":{"type":"api_error","message":"the vendor is overloaded"}}`+"\n") || strings.HasPrefix(strings.TrimLeft(strings.ReplaceAll(body, ": keepalive\n", ""), "\n"), "{") {
t.Fatalf("the vendor's JSON error went into the stream raw: %q", body)
}
})
}
}
// A request that waits out its queue (QueueWait) after keepalives were
// sent is told so as its stream's error, saying the limit.
func TestQueuedStreamWaitedOutIsTheStreamsError(t *testing.T) {
one := 1
v := &streamVendor{block: make(chan struct{})}
defer close(v.block)
gw := queueGateway(t, v, func(p *provider.Provider) { p.MaxConcurrency, p.QueueWait = &one, 1 })
go askIdle(gw, queueAsks[2].path, queueAsks[2].body, 5*time.Second)
time.Sleep(100 * time.Millisecond)
code, body, err := askIdle(gw, queueAsks[2].path, queueAsks[2].body, 400*time.Millisecond)
if err != nil || code != 200 || !strings.Contains(body, ": keepalive") || !strings.Contains(body, "event: error") || !strings.Contains(body, "requests at once is its limit") {
t.Fatalf("waited out = %d %v %q, want keepalives, then the limit as the stream's error", code, err, body)
}
}
// A wait shorter than keepHeldAfter sends nothing ahead: turned away, it
// is still a 429 with its Retry-After.
func TestQueuedBrieflyStill429(t *testing.T) {
shortMinute(t, 1500*time.Millisecond, 50*time.Millisecond)
gw := queueGateway(t, &streamVendor{}, func(p *provider.Provider) { p.MaxRPM = 1 })
if code, _, err := askIdle(gw, queueAsks[0].path, queueAsks[0].body, time.Second); err != nil || code != 200 {
t.Fatalf("first: %d %v", code, err)
}
code, body, err := askIdle(gw, queueAsks[0].path, queueAsks[0].body, time.Second)
if err != nil || code != 429 || strings.Contains(body, "keepalive") {
t.Fatalf("second = %d %v %q, want a plain 429", code, err, body)
}
}
// A try that wasn't held — the last one, its retries spent — and whose
// agent was kept alive while it waited is held from then on: the vendor's
// error is the stream's to tell (failTo), not a JSON body written into
// the stream the agent already has.
func TestQueuedUnheldTryHeldOnceKeptAlive(t *testing.T) {
k, w := keepHeldAfter, watchEvery
keepHeldAfter, watchEvery = 20*time.Millisecond, 5*time.Millisecond
t.Cleanup(func() { keepHeldAfter, watchEvery = k, w })
rec := httptest.NewRecorder()
hw := newHoldWriter(rec, false)
hw.alive, hw.streams = &keptAlive{proto: provider.Chat}, true
stop := hw.keepQueued()
time.Sleep(80 * time.Millisecond)
stop()
if !hw.alive.sent {
t.Fatal("the agent wasn't kept alive while its try waited")
}
hw.Header().Set("Content-Type", "application/json")
hw.WriteHeader(400)
hw.Write([]byte(`{"error":{"message":"bad request"}}`))
if hw.passing || strings.Contains(rec.Body.String(), "bad request") {
t.Fatalf("the vendor's error went into the kept-alive stream: passing %v, %q", hw.passing, rec.Body.String())
}
}