response_header sounds like a check that the endpoint is alive, and for a streaming request it is: headers arrive before generation starts. For a non-streaming request the engine sends nothing until the completion is finished, so the same setting is the total generation budget wearing a liveness timeout's name. At 60s that was invisible on GPUs and a hard cap of roughly a hundred tokens on qwen2.5-coder-14b at 1.7 tokens a second. A request for 120 tokens failed at exactly one minute, and neither the setting's name nor the error said why. Two transports now: the short header timeout for streaming, where it still catches a sick endpoint, and a generation budget for non-streaming, defaulting to ten minutes. One number could not mean both things. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
358 lines
11 KiB
Go
358 lines
11 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func testProxy(mut ...func(*Config)) *Proxy {
|
|
c := DefaultConfig()
|
|
c.FirstTokenTimeout = 2 * time.Second
|
|
c.IdleTimeout = 500 * time.Millisecond
|
|
c.TotalTimeout = 10 * time.Second
|
|
for _, f := range mut {
|
|
f(&c)
|
|
}
|
|
return New(c)
|
|
}
|
|
|
|
// sseServer serves a scripted SSE stream.
|
|
func sseServer(t *testing.T, chunks []string, gap time.Duration) (*httptest.Server, *atomic.Bool) {
|
|
t.Helper()
|
|
var cancelled atomic.Bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
f := w.(http.Flusher)
|
|
f.Flush()
|
|
for _, c := range chunks {
|
|
select {
|
|
case <-r.Context().Done():
|
|
cancelled.Store(true)
|
|
return
|
|
case <-time.After(gap):
|
|
}
|
|
if _, err := io.WriteString(w, c); err != nil {
|
|
cancelled.Store(true)
|
|
return
|
|
}
|
|
f.Flush()
|
|
}
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &cancelled
|
|
}
|
|
|
|
func dataChunk(s string) string { return "data: " + s + "\n\n" }
|
|
|
|
func TestBufferedResponseIsRelayed(t *testing.T) {
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
io.WriteString(w, `{"id":"x","usage":{"prompt_tokens":7,"completion_tokens":3}}`)
|
|
}))
|
|
defer up.Close()
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy().Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Status != 200 || !out.Wrote {
|
|
t.Errorf("outcome=%+v", out)
|
|
}
|
|
if out.Usage == nil || out.Usage.PromptTokens != 7 || out.Usage.TotalTokens != 10 {
|
|
t.Errorf("usage not captured: %+v", out.Usage)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"id":"x"`) {
|
|
t.Errorf("body not relayed: %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestStreamIsRelayedEventByEvent(t *testing.T) {
|
|
up, _ := sseServer(t, []string{
|
|
dataChunk(`{"choices":[{"delta":{"content":"a"}}]}`),
|
|
dataChunk(`{"choices":[{"delta":{"content":"b"}}]}`),
|
|
"data: [DONE]\n\n",
|
|
}, time.Millisecond)
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy().Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`), Stream: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := rec.Body.String()
|
|
if n := strings.Count(body, "data: "); n != 3 {
|
|
t.Errorf("expected 3 data lines, got %d: %q", n, body)
|
|
}
|
|
if !strings.HasSuffix(body, "[DONE]\n\n") {
|
|
t.Errorf("stream did not end with [DONE]: %q", body)
|
|
}
|
|
if out.TTFT == 0 {
|
|
t.Error("TTFT was not recorded")
|
|
}
|
|
}
|
|
|
|
func TestUsageCapturedFromStream(t *testing.T) {
|
|
up, _ := sseServer(t, []string{
|
|
dataChunk(`{"choices":[{"delta":{"content":"a"}}]}`),
|
|
dataChunk(`{"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16}}`),
|
|
"data: [DONE]\n\n",
|
|
}, time.Millisecond)
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy().Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`), Stream: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Usage == nil || out.Usage.TotalTokens != 16 {
|
|
t.Fatalf("usage not captured from stream: %+v", out.Usage)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), `"usage"`) {
|
|
t.Error("usage chunk should reach a client that asked for it")
|
|
}
|
|
}
|
|
|
|
func TestUsageChunkSuppressed(t *testing.T) {
|
|
// The router asked the engine for usage the client never requested: record
|
|
// it, but do not change what the client receives.
|
|
up, _ := sseServer(t, []string{
|
|
dataChunk(`{"choices":[{"delta":{"content":"a"}}]}`),
|
|
dataChunk(`{"choices":[],"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16}}`),
|
|
"data: [DONE]\n\n",
|
|
}, time.Millisecond)
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy().Do(t.Context(), rec, Request{
|
|
URL: up.URL, Body: []byte(`{}`), Stream: true, SuppressUsageChunk: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Usage == nil || out.Usage.TotalTokens != 16 {
|
|
t.Fatalf("usage not recorded: %+v", out.Usage)
|
|
}
|
|
if strings.Contains(rec.Body.String(), `"usage"`) {
|
|
t.Errorf("usage chunk leaked to the client: %q", rec.Body.String())
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "[DONE]") {
|
|
t.Error("[DONE] was dropped along with the usage chunk")
|
|
}
|
|
}
|
|
|
|
func TestRetryableStatusWritesNothing(t *testing.T) {
|
|
// The caller must be free to try another endpoint, which is only safe if
|
|
// the client has seen nothing.
|
|
for _, code := range []int{429, 502, 503, 504} {
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "busy", code)
|
|
}))
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy().Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`)})
|
|
up.Close()
|
|
|
|
if err == nil {
|
|
t.Errorf("%d: expected an error", code)
|
|
}
|
|
if !out.Retryable {
|
|
t.Errorf("%d: not marked retryable", code)
|
|
}
|
|
if out.Wrote || rec.Body.Len() > 0 {
|
|
t.Errorf("%d: wrote %q to the client", code, rec.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNonRetryableStatusIsRelayed(t *testing.T) {
|
|
// A 400 is the client's fault; retrying elsewhere would produce the same
|
|
// answer, so relay it.
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
io.WriteString(w, `{"error":{"message":"bad"}}`)
|
|
}))
|
|
defer up.Close()
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy().Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Retryable {
|
|
t.Error("400 must not be retryable")
|
|
}
|
|
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "bad") {
|
|
t.Errorf("error response not relayed: %d %q", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestConnectionFailureIsRetryable(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
out, err := testProxy(func(c *Config) { c.DialTimeout = 200 * time.Millisecond }).
|
|
Do(t.Context(), rec, Request{URL: "http://127.0.0.1:1/x", Body: []byte(`{}`)})
|
|
if err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
if !out.Retryable || out.Wrote {
|
|
t.Errorf("outcome=%+v", out)
|
|
}
|
|
}
|
|
|
|
func TestClientDisconnectCancelsUpstream(t *testing.T) {
|
|
// Phase 1's exit criterion at the proxy level: the GPU must stop working
|
|
// when the client goes away.
|
|
chunks := make([]string, 200)
|
|
for i := range chunks {
|
|
chunks[i] = dataChunk(fmt.Sprintf(`{"choices":[{"delta":{"content":"%d"}}]}`, i))
|
|
}
|
|
up, cancelled := sseServer(t, chunks, 10*time.Millisecond)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
rec := httptest.NewRecorder()
|
|
|
|
done := make(chan Outcome, 1)
|
|
go func() {
|
|
out, _ := testProxy().Do(ctx, rec, Request{URL: up.URL, Body: []byte(`{}`), Stream: true})
|
|
done <- out
|
|
}()
|
|
|
|
time.Sleep(80 * time.Millisecond) // let a few events through
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("proxy did not return after client cancellation")
|
|
}
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if cancelled.Load() {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("upstream kept generating after the client disconnected")
|
|
}
|
|
|
|
func TestStalledUpstreamHitsIdleTimeout(t *testing.T) {
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
f := w.(http.Flusher)
|
|
io.WriteString(w, dataChunk(`{"choices":[{"delta":{"content":"a"}}]}`))
|
|
f.Flush()
|
|
<-r.Context().Done() // then go silent forever
|
|
}))
|
|
defer up.Close()
|
|
|
|
rec := httptest.NewRecorder()
|
|
p := testProxy(func(c *Config) { c.IdleTimeout = 200 * time.Millisecond })
|
|
|
|
start := time.Now()
|
|
out, err := p.Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`), Stream: true})
|
|
elapsed := time.Since(start)
|
|
|
|
if !errors.Is(err, ErrUpstreamStalled) {
|
|
t.Fatalf("expected ErrUpstreamStalled, got %v", err)
|
|
}
|
|
if !out.Wrote {
|
|
t.Error("the events delivered before the stall should be reported as written")
|
|
}
|
|
if elapsed > 3*time.Second {
|
|
t.Errorf("idle timeout took too long: %v", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestHopByHopHeadersNotForwarded(t *testing.T) {
|
|
var got http.Header
|
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.Header.Clone()
|
|
io.WriteString(w, `{}`)
|
|
}))
|
|
defer up.Close()
|
|
|
|
h := http.Header{}
|
|
h.Set("Authorization", "Bearer sk-llmgw-secret")
|
|
h.Set("Accept", "text/event-stream")
|
|
h.Set("Traceparent", "00-trace-span-01")
|
|
|
|
rec := httptest.NewRecorder()
|
|
if _, err := testProxy().Do(t.Context(), rec, Request{URL: up.URL, Body: []byte(`{}`), Header: h}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got.Get("Authorization") != "" {
|
|
t.Error("the client's Authorization header was forwarded upstream")
|
|
}
|
|
if got.Get("Accept") != "text/event-stream" {
|
|
t.Error("Accept was not forwarded")
|
|
}
|
|
if got.Get("Traceparent") != "00-trace-span-01" {
|
|
t.Error("Traceparent was not forwarded; tracing would break")
|
|
}
|
|
}
|
|
|
|
func TestANonStreamingRequestGetsTheGenerationBudget(t *testing.T) {
|
|
// The engine sends no headers until a non-streaming completion is done, so
|
|
// the header timeout is the generation budget in disguise. At 60s that read
|
|
// as a liveness guard and behaved as a cap of roughly a hundred tokens on a
|
|
// model generating 1.7 a second: a request for 120 tokens failed at exactly
|
|
// one minute, and nothing said why.
|
|
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(200 * time.Millisecond) // "generating"
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
|
|
}))
|
|
defer slow.Close()
|
|
|
|
p := New(Config{
|
|
// Short enough that a shared setting would fail the request.
|
|
ResponseHeaderTimeout: 20 * time.Millisecond,
|
|
GenerationTimeout: 5 * time.Second,
|
|
TotalTimeout: 10 * time.Second,
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
out, err := p.Do(t.Context(), rec, Request{URL: slow.URL, Body: []byte(`{}`), Stream: false})
|
|
if err != nil {
|
|
t.Fatalf("a completion inside the generation budget failed: %v", err)
|
|
}
|
|
if out.Status != http.StatusOK {
|
|
t.Errorf("status %d", out.Status)
|
|
}
|
|
}
|
|
|
|
func TestAStreamingRequestKeepsTheShortLivenessTimeout(t *testing.T) {
|
|
// The other half: for streaming, headers come before generation, so a slow
|
|
// header really does mean a sick endpoint and must still be caught.
|
|
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(2 * time.Second)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer dead.Close()
|
|
|
|
p := New(Config{
|
|
ResponseHeaderTimeout: 50 * time.Millisecond,
|
|
GenerationTimeout: 10 * time.Second,
|
|
TotalTimeout: 10 * time.Second,
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
start := time.Now()
|
|
if _, err := p.Do(t.Context(), rec, Request{URL: dead.URL, Body: []byte(`{}`), Stream: true}); err == nil {
|
|
t.Fatal("a stalled streaming endpoint was not caught")
|
|
}
|
|
if elapsed := time.Since(start); elapsed > time.Second {
|
|
t.Errorf("took %s; the streaming liveness timeout is not being applied", elapsed)
|
|
}
|
|
}
|