mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
GET /admin/api/events returns recent failures, newest first. It covers rate limits, budget and unpriced-model refusals, upstream errors, timeouts and capacity rejections. Events go into an in-process ring of 200 entries and are copied to a Redis list (llmgw:events, LPUSH plus LTRIM to 200). The copy happens only on the error path, is throttled to 50 per second per replica, and never blocks a request. When Redis is absent or down, the feed falls back to this replica's ring. Messages are fixed strings or scrubbed provider errors. Fields are assigned one at a time, because Yaegi drops values from a multi-value assignment that contains a call. Rejections per day are counted per scope and in total, in the same store call that already rolls back a rejected request. /admin/api/usage returns them as rejectionsPerDay. /admin/api/usage/models accepts span, which sums that many buckets, and prefix, which filters model ids before the limit applies. The overview reports the replica id, the instance name and the warnings logged while the middleware was built. The failover and retry fan-out warning is now logged at WARN. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
358 lines
14 KiB
Go
358 lines
14 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- eventRing: cap, order, copy semantics ---
|
|
|
|
// TestEventRing_AddSnapshot_OrderAndCap proves add overwrites the oldest
|
|
// entry once the ring exceeds eventRingCap, and snapshot returns the
|
|
// survivors newest-first.
|
|
func TestEventRing_AddSnapshot_OrderAndCap(t *testing.T) {
|
|
r := &eventRing{}
|
|
const total = eventRingCap + 10
|
|
for i := 0; i < total; i++ {
|
|
r.add(gatewayEvent{Message: fmt.Sprintf("event-%d", i)})
|
|
}
|
|
|
|
got := r.snapshot(eventRingCap)
|
|
if len(got) != eventRingCap {
|
|
t.Fatalf("len(got) = %d, want %d", len(got), eventRingCap)
|
|
}
|
|
if want := fmt.Sprintf("event-%d", total-1); got[0].Message != want {
|
|
t.Errorf("got[0] = %q, want %q (newest first)", got[0].Message, want)
|
|
}
|
|
if want := fmt.Sprintf("event-%d", total-eventRingCap); got[len(got)-1].Message != want {
|
|
t.Errorf("got[last] = %q, want %q (oldest surviving entry after the first 10 were overwritten)", got[len(got)-1].Message, want)
|
|
}
|
|
}
|
|
|
|
// TestEventRing_Snapshot_LimitTruncates proves limit caps how many of the
|
|
// most recent entries snapshot returns, still newest-first.
|
|
func TestEventRing_Snapshot_LimitTruncates(t *testing.T) {
|
|
r := &eventRing{}
|
|
for i := 0; i < 10; i++ {
|
|
r.add(gatewayEvent{Message: fmt.Sprintf("e%d", i)})
|
|
}
|
|
got := r.snapshot(3)
|
|
if len(got) != 3 {
|
|
t.Fatalf("len(got) = %d, want 3", len(got))
|
|
}
|
|
want := []string{"e9", "e8", "e7"}
|
|
for i, w := range want {
|
|
if got[i].Message != w {
|
|
t.Errorf("got[%d] = %q, want %q", i, got[i].Message, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEventRing_Snapshot_ReturnsFreshCopy proves a caller mutating its own
|
|
// snapshot slice never corrupts the ring's own backing array.
|
|
func TestEventRing_Snapshot_ReturnsFreshCopy(t *testing.T) {
|
|
r := &eventRing{}
|
|
r.add(gatewayEvent{Message: "orig"})
|
|
|
|
got := r.snapshot(1)
|
|
got[0].Message = "mutated"
|
|
|
|
again := r.snapshot(1)
|
|
if again[0].Message != "orig" {
|
|
t.Errorf("snapshot()[0].Message = %q after an external mutation, want %q (snapshot must copy)", again[0].Message, "orig")
|
|
}
|
|
}
|
|
|
|
// TestEventRing_Snapshot_Empty proves an empty ring answers a limit
|
|
// request with an empty, non-panicking slice.
|
|
func TestEventRing_Snapshot_Empty(t *testing.T) {
|
|
r := &eventRing{}
|
|
got := r.snapshot(50)
|
|
if len(got) != 0 {
|
|
t.Errorf("len(got) = %d, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
// --- eventLog: push-rate throttle (Q3: 50/s/replica) ---
|
|
|
|
// TestEventLog_AllowPush_ThrottledAt50PerSecond proves eventPushPerSecond
|
|
// pushes are allowed within one second, the next is throttled, and a new
|
|
// second resets the window.
|
|
func TestEventLog_AllowPush_ThrottledAt50PerSecond(t *testing.T) {
|
|
el := &eventLog{}
|
|
now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
|
|
|
|
for i := 0; i < eventPushPerSecond; i++ {
|
|
if !el.allowPush(now) {
|
|
t.Fatalf("push %d within the same second should be allowed", i)
|
|
}
|
|
}
|
|
if el.allowPush(now) {
|
|
t.Error("a push beyond eventPushPerSecond within the same second must be throttled")
|
|
}
|
|
if !el.allowPush(now.Add(time.Second)) {
|
|
t.Error("a new second must reset the throttle window")
|
|
}
|
|
}
|
|
|
|
// --- gatewayEvent: JSON round trip (Redis storage shape == API shape) ---
|
|
|
|
// TestGatewayEvent_JSONRoundTrip proves every field survives a
|
|
// marshal/unmarshal cycle unchanged — the exact contract eventLog.push/
|
|
// read depend on (the stored Redis payload IS the API view, with no
|
|
// translation step).
|
|
func TestGatewayEvent_JSONRoundTrip(t *testing.T) {
|
|
ev := gatewayEvent{
|
|
Time: time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC),
|
|
Replica: "pod-1",
|
|
Instance: "llmgw",
|
|
User: "alice",
|
|
Group: "eng",
|
|
Model: "openai/gpt-4o",
|
|
Provider: "openai",
|
|
Route: routeChatCompletions,
|
|
Kind: eventKindUpstream,
|
|
Message: "upstream connection error",
|
|
Status: http.StatusBadGateway,
|
|
}
|
|
b, err := json.Marshal(ev)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
var got gatewayEvent
|
|
if unmarshalErr := json.Unmarshal(b, &got); unmarshalErr != nil {
|
|
t.Fatalf("Unmarshal: %v", unmarshalErr)
|
|
}
|
|
gotB, err := json.Marshal(got)
|
|
if err != nil {
|
|
t.Fatalf("re-Marshal: %v", err)
|
|
}
|
|
if string(gotB) != string(b) {
|
|
t.Errorf("round trip mismatch:\n got %s\nwant %s", gotB, b)
|
|
}
|
|
}
|
|
|
|
// TestGatewayEvent_JSON_OmitsOptionalFields proves a minimal event (no
|
|
// Instance/User/Group/Model/Provider/Status) omits those keys entirely,
|
|
// matching gatewayEvent's own omitempty tags — Route/Kind/Message/Time/
|
|
// Replica are the only fields a minimal event (e.g. the capacity hook)
|
|
// always carries.
|
|
func TestGatewayEvent_JSON_OmitsOptionalFields(t *testing.T) {
|
|
ev := gatewayEvent{Route: routeCapacity, Kind: eventKindCapacity, Message: "server is at capacity; try again shortly"}
|
|
b, err := json.Marshal(ev)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
for _, absent := range []string{`"instance"`, `"user"`, `"group"`, `"model"`, `"provider"`, `"status"`} {
|
|
if strings.Contains(string(b), absent) {
|
|
t.Errorf("JSON = %s, must omit %s for an unset optional field", b, absent)
|
|
}
|
|
}
|
|
for _, present := range []string{`"time"`, `"replica"`, `"route"`, `"kind"`, `"message"`} {
|
|
if !strings.Contains(string(b), present) {
|
|
t.Errorf("JSON = %s, must always include %s", b, present)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- recordUpstreamEvent: error classification (F3 hook 2) ---
|
|
|
|
// TestRecordUpstreamEvent_Classification drives every classification
|
|
// branch recordUpstreamEvent's own doc comment promises: a wrapped
|
|
// errProviderTimeout or context.DeadlineExceeded is a timeout/504; a
|
|
// *providerHTTPError is an upstream event only at 429 or >=500, never at
|
|
// an ordinary 4xx; *translateError and context.Canceled record nothing;
|
|
// anything else is upstream/502 with the message scrubbed via
|
|
// sanitizeProviderErr — proven here with a genuine credential-bearing
|
|
// *url.Error, matching the plan's own risk-section requirement (§6).
|
|
func TestRecordUpstreamEvent_Classification(t *testing.T) {
|
|
// Built via net/url, not a literal credential-shaped string constant
|
|
// (matches TestAdminOverview_BaseURLStripsCredentials' own reasoning,
|
|
// admin_test.go): this is a fabricated test fixture, not a real
|
|
// credential, but a literal userinfo URL string in source still
|
|
// trips generic secret scanners.
|
|
credentialBaseURL := (&url.URL{
|
|
Scheme: "https",
|
|
User: url.UserPassword("secretuser", "secretpass"),
|
|
Host: "openai.invalid",
|
|
}).String()
|
|
|
|
// credentialAndKeyBaseURL additionally carries a "?key=" query
|
|
// parameter — the timeout branch's own risk (verify-dash-backend.md
|
|
// finding 2): before this fix only the generic (502) branch ran
|
|
// lastErr through sanitizeProviderErr, so a timeout error embedding
|
|
// this same base URL leaked BOTH the userinfo and any query-string
|
|
// credential riding it (net/http embeds the dialed URL verbatim in a
|
|
// *url.Error on a context-deadline failure). A separate provider
|
|
// ("openai-keyed") carries this BaseURL so the existing "openai"
|
|
// cases above are unaffected.
|
|
credentialAndKeyBaseURL := (&url.URL{
|
|
Scheme: "https",
|
|
User: url.UserPassword("secretuser", "secretpass"),
|
|
Host: "openai.invalid",
|
|
RawQuery: "key=leakyquerykey",
|
|
}).String()
|
|
|
|
cfg := CreateConfig()
|
|
cfg.Providers = map[string]*ProviderConfig{
|
|
"openai": {Type: "openai", BaseURL: credentialBaseURL, APIKey: "sk"},
|
|
"openai-keyed": {Type: "openai", BaseURL: credentialAndKeyBaseURL, APIKey: "sk"},
|
|
}
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h, err := New(context.Background(), next, cfg, "llmgw")
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
gw := h.(*Gateway)
|
|
gw.events.spawn = func(f func()) { f() } // deterministic: no Redis configured anyway, but keep this test independent of that fact
|
|
|
|
watchdogTimeout := fmt.Errorf("%w: chat/completions: no upstream progress for 5m0s", errProviderTimeout)
|
|
|
|
cases := []struct {
|
|
err error
|
|
name string
|
|
provider string // "" defaults to "openai" below
|
|
wantMsgHas string
|
|
wantKind string
|
|
wantStatus int
|
|
wantEvent bool
|
|
}{
|
|
{name: "wrapped errProviderTimeout -> timeout/504", err: watchdogTimeout, wantEvent: true, wantKind: eventKindTimeout, wantStatus: http.StatusGatewayTimeout},
|
|
{name: "context.DeadlineExceeded -> timeout/504", err: fmt.Errorf("dial: %w", context.DeadlineExceeded), wantEvent: true, wantKind: eventKindTimeout, wantStatus: http.StatusGatewayTimeout},
|
|
{name: "providerHTTPError 429 -> upstream/429", err: &providerHTTPError{status: http.StatusTooManyRequests}, wantEvent: true, wantKind: eventKindUpstream, wantStatus: http.StatusTooManyRequests},
|
|
{name: "providerHTTPError 500 -> upstream/500", err: &providerHTTPError{status: http.StatusInternalServerError}, wantEvent: true, wantKind: eventKindUpstream, wantStatus: http.StatusInternalServerError},
|
|
{name: "providerHTTPError 404 -> no event (ordinary client error)", err: &providerHTTPError{status: http.StatusNotFound}, wantEvent: false},
|
|
{name: "providerHTTPError 400 -> no event (ordinary client error)", err: &providerHTTPError{status: http.StatusBadRequest}, wantEvent: false},
|
|
{name: "translateError -> no event", err: &translateError{msg: "bad field"}, wantEvent: false},
|
|
{name: "context.Canceled -> no event", err: context.Canceled, wantEvent: false},
|
|
{name: "nil err -> no event", err: nil, wantEvent: false},
|
|
{
|
|
name: "generic connection error -> upstream/502, credential scrubbed",
|
|
err: &url.Error{Op: "Get", URL: credentialBaseURL + "/v1/chat/completions", Err: errors.New("connection refused")},
|
|
wantEvent: true,
|
|
wantKind: eventKindUpstream,
|
|
wantStatus: http.StatusBadGateway,
|
|
wantMsgHas: "connection refused",
|
|
},
|
|
{
|
|
// Finding 2 fix (verify-dash-backend.md): a *url.Error
|
|
// wrapping context.DeadlineExceeded — net/http's own shape
|
|
// for a context-deadline failure mid-dial — used to skip
|
|
// sanitizeProviderErr entirely on the timeout branch, so
|
|
// BOTH the userinfo (secretuser:secretpass@) and the
|
|
// "?key=" query credential embedded in the dialed URL
|
|
// leaked verbatim into the event feed.
|
|
name: "timeout: *url.Error wrapping DeadlineExceeded, userinfo and ?key= scrubbed",
|
|
provider: "openai-keyed",
|
|
err: &url.Error{Op: "Get", URL: credentialAndKeyBaseURL + "/v1/chat/completions", Err: context.DeadlineExceeded},
|
|
wantEvent: true,
|
|
wantKind: eventKindTimeout,
|
|
wantStatus: http.StatusGatewayTimeout,
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
provider := c.provider
|
|
if provider == "" {
|
|
provider = "openai"
|
|
}
|
|
before := len(gw.events.ring.snapshot(eventRingCap))
|
|
gw.recordUpstreamEvent(nil, "openai/gpt-4o", provider, routeChatCompletions, c.err)
|
|
after := gw.events.ring.snapshot(eventRingCap)
|
|
|
|
if !c.wantEvent {
|
|
if len(after) != before {
|
|
t.Fatalf("recorded an event for err=%v, want none: %+v", c.err, after[0])
|
|
}
|
|
return
|
|
}
|
|
if len(after) != before+1 {
|
|
t.Fatalf("did not record an event for err=%v", c.err)
|
|
}
|
|
got := after[0] // newest first
|
|
if got.Kind != c.wantKind {
|
|
t.Errorf("Kind = %q, want %q", got.Kind, c.wantKind)
|
|
}
|
|
if got.Status != c.wantStatus {
|
|
t.Errorf("Status = %d, want %d", got.Status, c.wantStatus)
|
|
}
|
|
if c.wantMsgHas != "" && !strings.Contains(got.Message, c.wantMsgHas) {
|
|
t.Errorf("Message = %q, want it to contain %q", got.Message, c.wantMsgHas)
|
|
}
|
|
for _, leaked := range []string{"secretuser", "secretpass", "leakyquerykey"} {
|
|
if strings.Contains(got.Message, leaked) {
|
|
t.Errorf("Message leaks credential material %q: %q", leaked, got.Message)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRecordProxyEvent_Classification covers recordProxyEvent's own
|
|
// status-based rule (F3 hook 3): 0/429/>=500 record an event, everything
|
|
// else (including a client-canceled result) does not.
|
|
func TestRecordProxyEvent_Classification(t *testing.T) {
|
|
g := &Gateway{name: "llmgw", replica: "test-replica"}
|
|
g.events = &eventLog{ring: &eventRing{}, nowFn: time.Now, spawn: func(f func()) { f() }}
|
|
|
|
cases := []struct {
|
|
wantKind string
|
|
result proxyResult
|
|
wantStatus int
|
|
wantEvent bool
|
|
}{
|
|
{result: proxyResult{status: 0}, wantEvent: true, wantKind: eventKindUpstream, wantStatus: http.StatusBadGateway},
|
|
{result: proxyResult{status: http.StatusTooManyRequests}, wantEvent: true, wantKind: eventKindUpstream, wantStatus: http.StatusTooManyRequests},
|
|
{result: proxyResult{status: http.StatusBadGateway}, wantEvent: true, wantKind: eventKindUpstream, wantStatus: http.StatusBadGateway},
|
|
{result: proxyResult{status: http.StatusOK}, wantEvent: false},
|
|
{result: proxyResult{status: http.StatusNotFound}, wantEvent: false},
|
|
{result: proxyResult{status: 0, clientCanceled: true}, wantEvent: false},
|
|
{result: proxyResult{status: http.StatusInternalServerError, clientCanceled: true}, wantEvent: false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(fmt.Sprintf("status=%d canceled=%v", c.result.status, c.result.clientCanceled), func(t *testing.T) {
|
|
before := len(g.events.ring.snapshot(eventRingCap))
|
|
g.recordProxyEvent(nil, "openai", routePassthrough, c.result)
|
|
after := g.events.ring.snapshot(eventRingCap)
|
|
if !c.wantEvent {
|
|
if len(after) != before {
|
|
t.Fatalf("recorded an event for %+v, want none", c.result)
|
|
}
|
|
return
|
|
}
|
|
if len(after) != before+1 {
|
|
t.Fatalf("did not record an event for %+v", c.result)
|
|
}
|
|
got := after[0]
|
|
if got.Kind != c.wantKind || got.Status != c.wantStatus {
|
|
t.Errorf("got Kind=%q Status=%d, want Kind=%q Status=%d", got.Kind, got.Status, c.wantKind, c.wantStatus)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestApplyScopeAttribution_FirstUserAndGroupWin proves
|
|
// applyScopeAttribution takes the FIRST user-kind and FIRST group-kind
|
|
// scope, ignoring a later total/other scope, matching
|
|
// buildLimitScopes/withTotalScope's own user-then-group-then-total order.
|
|
func TestApplyScopeAttribution_FirstUserAndGroupWin(t *testing.T) {
|
|
scopes := []limitScope{
|
|
{kind: "user", id: "alice"},
|
|
{kind: "group", id: "eng"},
|
|
{kind: totalScopeKind, id: totalScopeID},
|
|
}
|
|
var ev gatewayEvent
|
|
applyScopeAttribution(&ev, scopes)
|
|
if ev.User != "alice" || ev.Group != "eng" {
|
|
t.Errorf("ev = %+v, want User=alice Group=eng", ev)
|
|
}
|
|
}
|