mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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.
94 lines
3.3 KiB
Go
94 lines
3.3 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// redisStore is a counterStore backed by a Redis-compatible server over
|
|
// respClient, so multiple Traefik instances share the same limit counters
|
|
// instead of each enforcing limits against its own in-process state.
|
|
type redisStore struct {
|
|
client *respClient
|
|
}
|
|
|
|
// newRedisStore returns a redisStore using client for storage.
|
|
func newRedisStore(client *respClient) *redisStore {
|
|
return &redisStore{client: client}
|
|
}
|
|
|
|
// incrBy implements counterStore: INCRBY key n, then EXPIRE key
|
|
// ttlSeconds, sent as one pipeline so the two commands share a single round
|
|
// trip. It returns INCRBY's resulting counter value; EXPIRE's reply is not
|
|
// otherwise inspected — a failed EXPIRE right after a successful INCRBY on
|
|
// the same key would only leave that key without a fresh TTL, not corrupt
|
|
// the count. ttl is rounded up to whole seconds (Redis EXPIRE's unit),
|
|
// with a floor of 1s so a sub-second ttl never turns into EXPIRE 0 (an
|
|
// immediate delete).
|
|
//
|
|
// Semantics are deliberately at-least-once, not exactly-once, both in the
|
|
// conservative direction (never under-counts): respClient.pipeline's
|
|
// reconnect-once retry can re-send this same INCRBY if the first attempt's
|
|
// reply was lost after the server already applied it (e.g. the connection
|
|
// dropped between the server processing INCRBY and the client reading its
|
|
// reply), which can over-count by n on that key; and the limiter's
|
|
// failOpen path can additionally count the same request in its in-process
|
|
// fallback store when a call to this method errors out after a partial
|
|
// success upstream. Both are accepted trade-offs — a rate/budget counter
|
|
// that occasionally over-counts by one request's worth is fail-safe (more
|
|
// restrictive than reality), never fail-open in the unsafe direction.
|
|
func (s *redisStore) incrBy(key string, n int64, ttl time.Duration) (int64, error) {
|
|
ttlSeconds := int64(ttl / time.Second)
|
|
if ttl%time.Second != 0 {
|
|
ttlSeconds++
|
|
}
|
|
if ttlSeconds < 1 {
|
|
ttlSeconds = 1
|
|
}
|
|
|
|
replies, err := s.client.pipeline([][]string{
|
|
{"INCRBY", key, strconv.FormatInt(n, 10)},
|
|
{"EXPIRE", key, strconv.FormatInt(ttlSeconds, 10)},
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("redisStore: incrBy %q: %w", key, err)
|
|
}
|
|
if len(replies) == 0 {
|
|
return 0, fmt.Errorf("redisStore: incrBy %q: empty pipeline reply", key)
|
|
}
|
|
if e, ok := replies[0].(error); ok {
|
|
return 0, fmt.Errorf("redisStore: incrBy %q: INCRBY failed: %w", key, e)
|
|
}
|
|
v, ok := replies[0].(int64)
|
|
if !ok {
|
|
return 0, fmt.Errorf("redisStore: incrBy %q: unexpected INCRBY reply type %T", key, replies[0])
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// get implements counterStore: GET key, treating a missing key (RESP null
|
|
// bulk reply) as 0, matching memoryStore's behavior for an absent or
|
|
// expired counter.
|
|
func (s *redisStore) get(key string) (int64, error) {
|
|
reply, err := s.client.do("GET", key)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("redisStore: get %q: %w", key, err)
|
|
}
|
|
if reply == nil {
|
|
return 0, nil
|
|
}
|
|
if e, ok := reply.(error); ok {
|
|
return 0, fmt.Errorf("redisStore: get %q: %w", key, e)
|
|
}
|
|
b, ok := reply.([]byte)
|
|
if !ok {
|
|
return 0, fmt.Errorf("redisStore: get %q: unexpected reply type %T", key, reply)
|
|
}
|
|
v, err := strconv.ParseInt(string(b), 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("redisStore: get %q: non-integer value %q: %w", key, b, err)
|
|
}
|
|
return v, nil
|
|
}
|