Redis / Valkey Integration
Auto AI Router supports an optional Redis (or Valkey) backend that enables two features when running multiple replicas:
| Feature | Without Redis | With Redis |
|---|---|---|
| Rate limiting (RPM/TPM) | Per-pod counters — each replica enforces limits independently | Global counters — limits enforced across the whole cluster |
Response storage (store: true) |
Local bbolt file — not accessible from other pods | Shared Redis — any replica can retrieve stored responses |
If Redis is not configured, both features fall back to their original in-process implementations automatically.
When to use Redis
| Scenario | Recommended mode |
|---|---|
| Single replica, Redis low-latency | hybrid: false (direct Redis, exact counts) |
| Single replica, Redis high-latency | hybrid: true (local decisions, async sync) |
| Multiple replicas, need exact limits | hybrid: false |
| Multiple replicas, slight drift is OK | hybrid: true (better throughput, ~5 s accuracy) |
| No Redis at all | Omit redis section — pure in-process counters |
Single-replica deployments that don't need shared response storage can omit Redis entirely.
Configuration
Add a redis section to config.yaml:
redis:
enabled: true
addresses:
- "redis:6379" # host:port of your Redis/Valkey instance
password: "os.environ/REDIS_PASSWORD" # optional; supports env variable syntax
key_prefix: "rl:" # namespace prefix for all keys (default: "rl:")
force_single_client: true # set false only for Redis Cluster
connect_timeout: 5s
conn_write_timeout: 10s
command_timeout: 3s # per-command deadline cap (default: 3s)
key_ttl: 120 # rate-limit key TTL in seconds (default: 120)
hybrid: false # see "Hybrid mode" below
sync_interval: 5s # hybrid only: how often to pull remote counts
All Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable Redis backend |
addresses |
[]string | — | One or more host:port addresses |
username |
string | — | Redis ACL username (optional) |
password |
string | — | Redis AUTH password (optional) |
select_db |
int | 0 |
Redis database index |
key_prefix |
string | "rl:" |
Prefix prepended to every key |
tls_enabled |
bool | false |
Enable TLS |
connect_timeout |
duration | 5s |
TCP dial timeout |
conn_write_timeout |
duration | 10s |
Per-connection write/pipeline timeout |
force_single_client |
bool | false |
Skip cluster detection (use for single-node) |
command_timeout |
duration | 3s |
Maximum duration for a single Redis command |
key_ttl |
int | 120 |
Rate-limit key TTL in seconds |
hybrid |
bool | false |
Enable hybrid mode (see below) |
sync_interval |
duration | 5s |
Hybrid only: interval between Redis sync pulls |
min_idle_conns |
int | 10 |
Minimum idle connections (reserved for future use) |
max_idle_conns |
int | 100 |
Maximum idle connections (reserved for future use) |
max_conn_lifetime |
duration | 30m |
Maximum connection lifetime (reserved for future use) |
All string values support the os.environ/VAR_NAME syntax for environment variable substitution.
Minimal config for single-node Valkey
Config with password via environment variable
redis:
enabled: true
addresses:
- "os.environ/REDIS_ADDRESS"
password: "os.environ/REDIS_PASSWORD"
force_single_client: true
Startup Health Check
On startup, the router connects to Redis and immediately performs a PING health check:
- If Redis is reachable → rate limiter and response store use Redis.
- If Redis is unreachable (connection error or ping timeout) → both features silently fall back to their in-process implementations. The server starts normally.
Hybrid Mode
When hybrid: true, the router uses a HybridBackend that combines an in-process local counter with an asynchronous Redis sync:
Request arrives
│
▼
Local counter (in-memory, <1 µs)
• TryAllowAll / tryAllowRPM / canAllowTPM
• Decision is instant — no network call
│
├── allowed ──► enqueue write op
│ │
│ Background writeWorker
│ (batches 200 ops, flushes every 100 ms via DoMulti pipeline)
│ │
│ ▼
│ Redis (async, non-blocking)
│
└── denied ──► return 429 immediately
Background sync (every sync_interval, default 5 s):
- Fetch total RPM/TPM for all tracked keys from Redis (one pipeline round-trip).
- Subtract local counts →
remote_count = redis_total − local_total. - Store
remote_countin memory. - Next rate-limit check uses
effective_limit = limit − remote_count, so traffic from other replicas is accounted for.
Trade-offs
| Property | Direct Redis (hybrid: false) |
Hybrid (hybrid: true) |
|---|---|---|
| Latency per request | +1 RTT to Redis | ~0 (in-memory) |
/health endpoint latency |
1 pipeline RTT (batched) | ~0 (in-memory) |
| Cross-instance accuracy | Exact (atomic Lua scripts) | ±sync_interval drift (default ±5 s) |
| Redis unavailability impact | Requests blocked until timeout | Continues with local counters |
| Write load on Redis | 1–2 commands per request | Batched async; typically 1 pipeline per 100 ms |
Use hybrid: false when you need hard rate-limit enforcement across replicas with zero tolerance for drift. Use hybrid: true when latency matters more than exact cross-replica synchronisation.
Key Layout
All keys are namespaced under key_prefix (default rl:):
| Key pattern | Used for |
|---|---|
rl:rpm:{c:credname} |
Credential request count ZSET (sliding 60s) |
rl:tpm:{c:credname} |
Credential token count ZSET (sliding 60s) |
rl:rpm:{c:credname}:m:credname:model |
Model request count ZSET (sliding 60s) |
rl:tpm:{c:credname}:m:credname:model |
Model token count ZSET (sliding 60s) |
rl:response:{id} |
Stored Responses API entry |
The {c:credname} portion is a Redis hash tag — it ensures all four keys for a given credential (cred rpm, cred tpm, model rpm, model tpm) land in the same hash slot. This is required by valkey-go's multi-key EVAL slot validation, which is enforced even on single-node deployments.
Rate-limit keys expire after key_ttl seconds of inactivity (default 120 seconds) via Redis EXPIRE. Response keys use the TTL from the ttl field of the request, or persist indefinitely when ttl: 0.
How Rate Limiting Works in Redis
Each request is recorded atomically using a Lua script that runs entirely on the Redis server:
- Remove entries older than 60 seconds from the sorted set (
ZREMRANGEBYSCORE) - Count remaining entries (
ZCARD) - If count ≥ limit → reject (return 0)
- Add new entry with current timestamp as score and a UUID as member (
ZADD) - Reset key TTL (
EXPIRE)
The TryAllowAll check (credential RPM + credential TPM + model RPM + model TPM) is a single Lua script that validates all four counters atomically before recording anything — no TOCTOU race conditions across replicas.
Token consumption (ConsumeTokens) stores entries as uuid:count members so the TPM check can sum token counts with ZRANGE inside a Lua script.
Batched Reads (Health & Metrics)
The /health endpoint and the metrics updater need current RPM/TPM for every credential and model. Instead of issuing one Redis command per key, the router sends all read commands in a single pipeline (DoMulti) — one network round-trip regardless of the number of credentials or models configured. This keeps /health response time proportional to Redis RTT, not to the size of the configuration.
Timeouts
Two independent timeout layers protect against slow Redis:
| Layer | Config field | Default | Scope |
|---|---|---|---|
| Operation timeout | — | 30s |
Applied by the rate limiter when the request context has no deadline |
| Command timeout | command_timeout |
3s |
Applied per Redis command inside the backend |
The command timeout is applied only when the parent context deadline is farther away than command_timeout. This ensures a single slow Redis call does not block a request for the full operation timeout.
Retry Behavior
Redis operations are automatically retried on transient network errors (connection reset, broken pipe, io.EOF). Up to 2 retries are attempted with a short exponential backoff (20 ms, 40 ms).
Retries are not performed on:
- Context cancellation or deadline exceeded (the caller already gave up)
- Network timeouts during command execution (the command may have already been committed on the server)
- Redis protocol errors (e.g.,
WRONGTYPE, script errors)
Idempotency of write operations: Each rate-limit entry uses a UUID as the ZSET member. If a retry sends the same command after a silent success, Redis ZADD updates the score (timestamp) of the existing member rather than inserting a duplicate — so requests are never double-counted.
In hybrid mode, writes go through the async queue and are not retried individually. If a batch fails the affected entries are simply lost. The next sync cycle will re-read the true Redis total and correct the remote-count estimate.
Memory Sizing
A rough guide for the rate-limit keyspace:
- Each request adds one entry to the RPM sorted set (UUID string ≈ 50 bytes + overhead ≈ ~100 bytes per entry).
- At 1000 RPM sustained across 10 credentials × 20 models = 200 keys, each holding up to 60 000 entries ≈ ~1.2 GB in the worst case. In practice, entries expire within 60 seconds so live memory is much lower.
For the response store, size depends on average response payload. A typical 2 KB response at 10 000 stored responses ≈ ~20 MB.
Start with --maxmemory 256mb and adjust based on observed usage.
Limitations
- Redis Cluster: only standalone and basic single-node deployments are supported. Cluster mode is not supported (keys in multi-key Lua scripts must share a hash slot).
- Sentinel: not supported. Use a load-balancer in front of Redis for HA.
- Pool settings (
min_idle_conns,max_idle_conns,max_conn_lifetime): parsed and reserved for future use; the valkey-go client manages its own connection pool internally. - Hybrid mode and response store:
hybridonly affects rate limiting. The response store always talks to Redis directly (reads must be synchronous).