mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-15 05:29:28 +00:00
fix(resp,limits): reject nested arrays, store-down latch, clamp dial to call budget
Reject a nested array reply instead of recursing into it. decodeReply now takes a depth parameter; a '*' type byte is accepted only at depth 0, so an element that is itself an array (decodeArray calls decodeReply at depth+1) is a protocol error rather than a recursive call. A crafted "*1\r\n*1\r\n..." stream repeated could otherwise recurse to Go's stack limit — a fatal, unrecoverable error, not a panic recoverPanic can catch — from only a few megabytes of wire input. Also lower respMaxArrayLen from 1<<20 to 1024: none of this client's commands (AUTH, SELECT, INCRBY, EXPIRE, GET) ever return an array reply at all, so even 1024 is generous headroom, not a limit tuned to real traffic. Add a store-down latch to the limiter (limits.go): after any store operation fails, record the failure time; for storeDownLatchFor (5s) afterward, every storeIncrBy/storeGet call short-circuits straight to the fail-open/fail-closed policy without attempting the store at all. One request can touch several counters (its own and its group's, request/token/cost, day/month) — each respClient call is already bounded to ~respCallTimeout on its own, but without the latch every one of those still paid a fresh bounded wait against a store that had just proven unreachable. After the window, the next operation probes the real store again: a natural half-open retry, no separate recovery state. Guarded by the same logMu mutex lastLogAt already uses for this kind of small timestamp state. Clamp respClient's dial timeout to the remaining call budget: dialTimeoutFor returns min(respDialTimeout, time.Until(deadline)), with a respDeadlineExceededErr (implements net.Error, Timeout() true) returned outright if the deadline has already elapsed — e.g. this call queued behind the client's mutex for its whole budget while an earlier call was stuck. Without this, a future increase to respDialTimeout alone, leaving respCallTimeout untouched, could silently let one dial attempt consume more of a call's total budget than the call-level deadline is supposed to allow. New tests: a nested-array reply and an array length one over the new 1024 cap, both asserted as protocol errors; a countingErrorStore stub proving a second checkAndCount within the latch window makes zero additional store calls, and that the store is probed again once the window elapses. Tightened TestLimiter_FailOpenTrue_ HungRedisServer_FallsBackQuickly's bound from <6s to <3s now that the latch collapses its two store ops into one real network attempt.
This commit is contained in:
@@ -526,6 +526,68 @@ func TestLimiter_LogsStoreErrorOncePerRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// countingErrorStore is a counterStore stub that always errors and counts
|
||||
// how many times each method is actually invoked. It proves the
|
||||
// store-down latch (limits.go) skips the network call entirely during
|
||||
// its window, rather than merely tolerating the error each time.
|
||||
type countingErrorStore struct {
|
||||
err error
|
||||
incrCalls int
|
||||
getCalls int
|
||||
}
|
||||
|
||||
func (s *countingErrorStore) incrBy(string, int64, time.Duration) (int64, error) {
|
||||
s.incrCalls++
|
||||
return 0, s.err
|
||||
}
|
||||
|
||||
func (s *countingErrorStore) get(string) (int64, error) {
|
||||
s.getCalls++
|
||||
return 0, s.err
|
||||
}
|
||||
|
||||
// TestLimiter_StoreDownLatch_SkipsStoreCallsWithinWindow is review-round-3
|
||||
// item 2: after a store operation fails, every operation for the next
|
||||
// storeDownLatchFor must short-circuit straight to the fail-open policy
|
||||
// without calling the store at all — a request that touches several
|
||||
// counters (min/day/month, tokens/cost, user/group) must not pay a fresh
|
||||
// bounded wait against a store that just proved unreachable for each one.
|
||||
// failOpen=true is used so checkAndCount completes both its req:min and
|
||||
// req:day increments per call instead of short-circuiting after the
|
||||
// first — this is what makes the "zero additional store calls" assertion
|
||||
// meaningful, rather than trivially true because checkAndCount stops
|
||||
// after one op regardless of the latch.
|
||||
func TestLimiter_StoreDownLatch_SkipsStoreCallsWithinWindow(t *testing.T) {
|
||||
store := &countingErrorStore{err: errors.New("boom")}
|
||||
l := newLimiter(store, true)
|
||||
now := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC)
|
||||
l.nowFn = func() time.Time { return now }
|
||||
scopes := []limitScope{{kind: "user", id: "u", limits: nil}}
|
||||
|
||||
if v := l.checkAndCount(scopes); v != nil {
|
||||
t.Fatalf("want no violation with failOpen=true, got %+v", v)
|
||||
}
|
||||
firstCalls := store.incrCalls
|
||||
if firstCalls == 0 {
|
||||
t.Fatal("want the first call to actually reach the store at least once")
|
||||
}
|
||||
|
||||
if v := l.checkAndCount(scopes); v != nil {
|
||||
t.Fatalf("want no violation with failOpen=true, got %+v", v)
|
||||
}
|
||||
if store.incrCalls != firstCalls {
|
||||
t.Errorf("incrBy calls = %d after a second checkAndCount within the latch window, want unchanged at %d (the store must not be called)", store.incrCalls, firstCalls)
|
||||
}
|
||||
|
||||
now = now.Add(storeDownLatchFor) // latch expires
|
||||
if v := l.checkAndCount(scopes); v != nil {
|
||||
t.Fatalf("want no violation with failOpen=true, got %+v", v)
|
||||
}
|
||||
if store.incrCalls <= firstCalls {
|
||||
t.Errorf("incrBy calls = %d after the latch window elapsed, want more than %d (the store must be probed again)", store.incrCalls, firstCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLimitsConfigValidate is carried-item (a): validate rejects
|
||||
// NaN/±Inf/negative cost limits and negative int64 count limits, accepts
|
||||
// zero/positive values, and treats a nil receiver (no limits configured
|
||||
|
||||
Reference in New Issue
Block a user