mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
fix(resp): bound store-call latency, cap wire lengths, cover fail-closed budget path
Bound respClient's worst-case latency to one respCallTimeout per do/pipeline call instead of per read/write/handshake step. Compute a single absolute deadline at call entry, apply it once via conn.SetDeadline after connecting (covering the AUTH/SELECT handshake and the call's own command I/O), and skip the reconnect-retry when the failing attempt was a timeout (errors.As -> net.Error, Timeout()) — a peer that accepted the connection and went silent must not be asked the same non-question twice. Previously each read, write, and dial got its own fresh 2s budget and every caller queued behind the client's single mutex, so a hung (not merely refused) Redis-compatible server could stall one checkAndCount call for 16-32s+. Cap decodeBulk/decodeArray's wire-declared lengths (respMaxBulkLen 64MiB, respMaxArrayLen 1<<20) before allocating: an unguarded "$9223372036854775807\r\n" overflows n+2 into a negative slice length and panics make([]byte, n+2); a merely huge-but-valid length forces an outsized allocation either way. decodeBulk also now verifies its two trailing bytes are CRLF. Bound readLine to respMaxLineLen (64KiB), reading byte-by-byte so it can bail before ever buffering an unbounded line from a peer that never sends '\n'. Fix TestLimiter_FailClosed_BudgetReadReturnsStoreDownViolation: the erroringStore it used fails incrBy too, so checkAndCount's own increment loop already returned a storeDown violation before evaluateScope (and therefore budgetViolation) ever ran, leaving budgetViolation's storeDown branch uncovered. Replace it with a stub that only fails get, so the test's TokensPerDay limit actually drives evaluateScope into budgetViolation's fail-closed path. Document redis_store.go's incrBy as deliberately at-least-once: the reconnect-retry can re-send INCRBY after a lost reply (possible over-count), and fail-open can additionally count in the fallback — both fail-safe (more restrictive), never fail-open in the unsafe direction. Validate Redis.DB >= 0 in newConfiguredLimiter alongside Address, so a negative db is a constructor error rather than a SELECT -1 on the wire. New tests: a hung (accepts, never replies) fake RESP server proving a respClient call and a failOpen limiter call both stay bounded rather than stacking timeouts; decodeReply subtests for an oversized bulk length, a length just over the cap, a huge array length, a bad bulk trailer, and an oversized line; a negative-DB constructor-rejection test.
This commit is contained in:
@@ -3,11 +3,13 @@ package traefikllmgateway
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEncodeCommand pins the exact RESP2 wire format every command must
|
||||
@@ -127,6 +129,48 @@ func TestDecodeReply(t *testing.T) {
|
||||
t.Fatal("want error for an unknown reply type byte")
|
||||
}
|
||||
})
|
||||
|
||||
// The following four subtests are review item 3: decodeBulk/
|
||||
// decodeArray must reject an out-of-range wire length before
|
||||
// allocating, and decodeBulk must validate its CRLF trailer.
|
||||
|
||||
t.Run("oversized bulk length is a protocol error, not a panic", func(t *testing.T) {
|
||||
// n+2 (9223372036854775807+2) overflows int64 into a negative
|
||||
// slice length; make([]byte, n+2) with that unguarded would
|
||||
// panic. Reaching this line without panicking already proves the
|
||||
// fix; the error check confirms it fails cleanly too.
|
||||
if _, err := decodeReply(newReader("$9223372036854775807\r\n")); err == nil {
|
||||
t.Fatal("want an error for a bulk length exceeding respMaxBulkLen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulk length just over the cap is a protocol error", func(t *testing.T) {
|
||||
if _, err := decodeReply(newReader(fmt.Sprintf("$%d\r\n", respMaxBulkLen+1))); err == nil {
|
||||
t.Fatal("want an error for a bulk length one over respMaxBulkLen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("huge array length is a protocol error", func(t *testing.T) {
|
||||
if _, err := decodeReply(newReader("*1000000000\r\n")); err == nil {
|
||||
t.Fatal("want an error for an array length exceeding respMaxArrayLen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulk with wrong trailer is a protocol error", func(t *testing.T) {
|
||||
if _, err := decodeReply(newReader("$5\r\nhelloXX")); err == nil {
|
||||
t.Fatal("want an error when a bulk reply's trailing bytes are not CRLF")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reply line exceeding the cap is a protocol error, not unbounded buffering", func(t *testing.T) {
|
||||
// No trailing "\r\n" at all: without the byte-by-byte cap check,
|
||||
// readLine would keep buffering every byte offered until the
|
||||
// reader errors (e.g. EOF) rather than bailing out early.
|
||||
huge := strings.Repeat("x", respMaxLineLen+10)
|
||||
if _, err := decodeReply(newReader("+" + huge)); err == nil {
|
||||
t.Fatal("want an error for a reply line exceeding respMaxLineLen")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newReader wraps s in a *bufio.Reader, matching what respClient reads
|
||||
@@ -259,6 +303,38 @@ func newFakeListener(t *testing.T) net.Listener {
|
||||
return ln
|
||||
}
|
||||
|
||||
// newHungListener returns a listener whose accepted connections read (and
|
||||
// discard) anything the client sends but never reply — simulating a
|
||||
// server that accepted the TCP connection and then went silent (a
|
||||
// black-holed network path, a wedged process), as opposed to actively
|
||||
// refusing or resetting. Used by review item 1's tests to prove a call's
|
||||
// latency is bounded by respCallTimeout rather than left to block
|
||||
// indefinitely. Each accepted connection's reader goroutine exits once
|
||||
// the connection is closed — by the client's own closeLocked after its
|
||||
// deadline fires, or by the listener's t.Cleanup at test end.
|
||||
func newHungListener(t *testing.T) net.Listener {
|
||||
t.Helper()
|
||||
ln := newFakeListener(t)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer func() { _ = c.Close() }()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if _, err := c.Read(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln
|
||||
}
|
||||
|
||||
// TestRESPClient_ConnectSendsAuthThenSelect is the brief's Step-1
|
||||
// connection-setup case: a configured password sends AUTH before SELECT,
|
||||
// both requiring a non-error reply, and SELECT is sent with the
|
||||
@@ -345,3 +421,24 @@ func TestRESPClient_ReconnectFailsTwice_ReturnsError(t *testing.T) {
|
||||
t.Fatal("want an error dialing a dead address")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESPClient_HungServer_BoundedByCallDeadline is review item 1: a
|
||||
// server that accepts the connection and then never replies must not
|
||||
// block a call past respCallTimeout, and — because that failure is a
|
||||
// timeout, not a reset/refusal — must not be retried, which would double
|
||||
// the wait for the exact same non-answer.
|
||||
func TestRESPClient_HungServer_BoundedByCallDeadline(t *testing.T) {
|
||||
ln := newHungListener(t)
|
||||
c := newRESPClient(ln.Addr().String(), "", 0)
|
||||
|
||||
start := time.Now()
|
||||
_, err := c.do("GET", "k")
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("want an error from a server that never replies")
|
||||
}
|
||||
if elapsed >= 3*time.Second {
|
||||
t.Fatalf("elapsed = %v, want < 3s (one bounded call deadline, no retry-doubling on timeout)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user