token_rate_limit
On this page
Token-denominated rate limiter: reserves an estimated cost at admission, reconciles against actual usage after the response completes. Evaluates an ordered list of rules, each with its own optional match condition, algorithm choice, and budget.
Configuration Notes
Experimental: requires the token-rate-limit-filter cargo feature, which is off by default and activates the experimental marker. This filter delivers the agreed M1/M2/M6/M7 milestone scope, but its parent proposal is not yet accepted and open questions remain (HA/clustered Redis/Valkey failure modes, and the relationship to Kuadrant’s TokenRateLimitPolicy – see ai#127). The configuration surface may change between releases.
Mirrors the rules:/match: shape from the 00121_token-rate-limiting proposal in praxis-proxy/enhancements, scoped to this milestone’s static header-value matchers, per-rule algorithm choice, configurable estimation strategies (M3, see [EstimationConfig]), M4 token-type weights (default_weights / per-rule weights), graduated soft-limit tiers (S1), and per-rule soft over-quota enforcement (ai#1241). CEL matchers remain deferred (see the module doc comment).
Assumes request identity has already been resolved upstream (this filter doesn’t authenticate callers) – a catch-all rule (no match:) reserves quota for every request that reaches it, including probes and health checks. Scope rules with explicit match: conditions, or place an identity/auth filter earlier in the pipeline. Tracked as follow-on integration work in grid#101.
Observability is group-level by rule, never by user. Metrics carry only bounded rule, algorithm, backend, error, result, capacity, phase, and outcome labels; accounting logs and optional OpenTelemetry spans likewise omit raw subject and bucket-key values. The Prometheus contract is:
praxis_trl_requests_total{rule,result}(admitted,denied, orsoft_over_quota): budget decisions only. Soft over-quota forwards are not reserved or reconciled (meter-only path does not debit the window). Requests rejected before a decision are counted bypraxis_trl_unauthenticated_total(401, no trusted subject) andpraxis_trl_backend_errors_total(503, fail closed) instead. Hierarchy decisions use the same metrics withruleset tohierarchy:org,hierarchy:team, orhierarchy:user. A configured rule must not use those names.
Accounting log field outcome on hard denials is one of: budget_exhausted (window/bucket capacity), key_capacity (per-rule distinct-key cap), invalid_key, or reservation_capacity. Soft over-quota forwards also use budget_exhausted. Admitted reservations use reserved.
praxis_trl_unauthenticated_total{rule}praxis_trl_tokens_reserved_total{rule}praxis_trl_tokens_reconciled_total{rule}praxis_trl_tokens_refunded_total{rule}praxis_trl_tokens_overage_total{rule}praxis_trl_reservations_total{rule,result}(reconciledororphaned)praxis_trl_soft_tier_activations_total{rule,capacity}praxis_trl_backend_errors_total{rule,backend,error}: failed reservations (the 503 path), failed reconciliation enqueues, and reconciliations abandoned after their retries.erroris one ofunavailable,unconfirmed,invalid_response, orconfiguration_mismatch.praxis_trl_sentinel_discovery_total{phase,result}: bounded Sentinel primary-discovery attempts.phaseisinitialorrediscovery;resultissuccess,failure, orstalewhen generation fencing discards a result. This metric is emitted only for Sentinel topologies.praxis_trl_sentinel_failover_total{outcome}: generation-fenced recovery after a cached connection invalidation.outcomeisrecoveredwhen a writable connection is returned orfailedwhen the bounded discovery deadline is exhausted.recovereddoes not prove that Sentinel promoted a replica; it records proxy-side recovery only.praxis_trl_fail_open_admissions_total{rule,backend,outcome}: explicitbackend.on_failure: openadmissions, withoutcomeequal tobypassedwhen no mutation was dispatched orunconfirmedwhen a dispatched operation may have committed. No reservation handle is created for either outcome.praxis_trl_backend_reconciliation_total{rule,backend,result}: reconciliations completed by the shared-backend worker.praxis_trl_budget_remaining{rule,algorithm}praxis_trl_reservations_active{rule}praxis_trl_active_keys{rule}
Every previous praxis_ai_token_rate_limit_* name has moved to this prefix; no compatibility aliases are emitted.
budget_remaining is the remaining budget for the key of the most recent admission decision on this replica (admitted or denied), and active_keys is how many keys the backend currently retains. Both are snapshots taken as decisions happen, not continuously refreshed values. Like all Prometheus gauges they are f64 and saturate at the largest exactly representable integer (2^53 - 1).
Gauge scope depends on the backend. With the memory backend every gauge describes this process only, so aggregate replicas with sum. With a shared redis/valkey backend, reservations_active is scoped to the namespace and algorithm, so summing it over rules double-counts; aggregate with max across replicas and rules. active_keys is scoped per rule, so aggregate with max across replicas for each rule. Each replica exports the value it last observed from the shared store. budget_remaining stays per replica and per last decision on either backend: it describes whichever key that replica decided last, so max or sum across replicas says little beyond “some key had this much left”. A replica that stops seeing traffic for a rule keeps exporting its last observation until it does.
The shared redis/valkey backend requires Valkey or Redis 7.0+ (PEXPIRE NX/GT is used). Both selectors use the same backend path and are treated as equal qualification targets. The backend keeps sliding-window usage in 60 fixed sub-windows per window (one per second for windows under a minute); usage leaves the window up to one sub-window late, never early. Changing a window’s length changes its sub-window width and so starts that window’s usage from zero. On the sliding window, concurrent admissions on one key are not serialised, so they can overshoot the budget by their combined estimates for one round trip. A dispatched mutation is never replayed; asynchronous replication may still lose an acknowledged write during failover.
The initial release gate is symmetric against official Redis 8.10.2 (redis:8.10.2-alpine@sha256:3811787313eba226a2ef38658c6ccb91cd5e110edc89c37767de373120a0e5a0) and Red Hat Valkey 8.0.11 (registry.access.redhat.com/rhel10/valkey-8@sha256:5929be16ac020c4851d8dc1f5ead6d48c6da066a9a74349711186925ebd553b9). These are symmetric qualification targets, not a claim that every deployment has passed; support requires both complete product lanes. The shared token bucket, by contrast, serialises admissions per key through an optimistic transaction: one key admits at most about one request per two Redis/Valkey round trips across the whole fleet, and contention shows up first as added latency, up to backend.operation_timeout (default 500 ms), then as 503s. Use a non-global key for high-throughput token-bucket rules so the load spreads over many buckets.
During a rolling upgrade from the earlier scripted shared backend, replicas on the old and new versions keep separate state, so for one window (and until old token buckets have drained) combined admissions can reach about twice the budget. All shared-backend timestamps come from the proxy replicas’ clocks, not the datastore’s: skew between replicas can under-count usage at window edges by up to the skew, and a replica whose clock runs fast trims other replicas’ live reservations and keys from the caps early.
Sentinel HA keeps admission and reconciliation on the discovered writable primary. Healthy operations reuse the cached data connection and do not query Sentinel. Discovery and connection replacement are bounded and coalesced; a dispatched operation whose reply is lost is never replayed. Replication remains asynchronous: an acknowledged write can be lost before promotion, and a partitioned old primary can accept writes later discarded. This backend does not provide RPO=0 or strong consistency. Production deployments should use at least three Sentinels, durable primary/replica persistence, suitable min-replicas-to-write and min-replicas-max-lag, and monitor SENTINEL CKQUORUM.
Admissions, denials, reconciliations, and backend failures also emit structured records on the praxis_ai::token_rate_limit::accounting tracing target: INFO for admissions and settlements, WARN for failures. They are on by default at INFO, so every admitted or denied request produces one line in the operational log stream; keep only failures with runtime.log_overrides: {"praxis_ai::token_rate_limit::accounting": "warn"}, and separate them from other operational logs by filtering on the target field. The records contain bounded policy and token-count fields only. They are best-effort operational audit records, not a durable billing source.
Configuration
| Field | Type | Required | Description |
|---|---|---|---|
rules | object[] | yes | Evaluated in order; the first rule whose match is satisfied (or which has no match at all) applies to a given request. A request satisfying no rule’s match is not rate limited by this filter instance – add a trailing rule with no match to enforce a catch-all budget instead. |
rules[].name | string | yes | Human-readable rule identifier, folded into shared-backend key namespacing so distinct rules sharing one backend never collide. Renaming a live shared-backend rule is therefore not a no-op for operators: it changes the datastore key hash, so the old name’s tracked budget is orphaned (left to expire on its own TTL) and the new name starts with a fresh budget. There’s no migration/rename path today – routine config hygiene (e.g. renaming "gold" to "gold-tier") silently resets that rule’s state. |
rules[].match | object | no | Static header-value match condition. Every listed header must be present on the request with an exact value match (ANDed) for this rule to apply. Omit entirely for a catch-all rule. |
rules[].match.headers | object<string, string> | yes | Every header must be present on the request with this exact value for the rule to match (ANDed across all entries). |
rules[].algorithm | sliding_window | token_bucket | yes | Which admission algorithm this rule enforces, and that algorithm’s own parameters. |
rules[].window | string | one of | Sliding window duration (e.g. "1h", "60s"). |
rules[].capacity | integer | one of | Maximum tokens admitted within window. |
rules[].capacity | integer | one of | Maximum tokens held at once (the bucket’s ceiling). |
rules[].refill_rate | number | one of | Tokens refilled per second, up to capacity. |
rules[].reserved_tokens | integer | no | Fixed token cost reserved at admission time, before actual usage is known. Legacy field, retained for backward compatibility: a bare reserved_tokens: N is equivalent to estimation: { strategy: fixed, fallback_estimate: N }. Mutually exclusive with estimation – specifying both on the same rule is a config error. |
rules[].estimation | object | no | Configurable estimation strategy for computing the token cost reserved at admission time. Replaces the legacy reserved_tokens field with request-metadata-aware strategies. Mutually exclusive with reserved_tokens – specifying both on the same rule is a config error. Omitting both is also an error. |
rules[].estimation.strategy | fixed | max_tokens | input_plus_max_tokens | model_scaled | yes | Which strategy to use for this rule’s cost estimation. |
rules[].estimation.multiplier | number | no | Safety-margin multiplier applied to the computed estimate. Defaults to 1.0 (no margin). Must be positive and finite. |
rules[].estimation.fallback_estimate | integer | no | Token count to use when max_tokens is absent from the request, or when max_tokens / max_completion_tokens is zero. Required for fixed; optional for body-dependent strategies. If unset and the body has no token count, the request is admitted without a reservation. A zero count with no fallback is kept, and a zero estimate is rejected, so the request is not forwarded unmetered. |
rules[].estimation.model_multipliers | object<string, number> | no | Per-model multiplier map for model_scaled strategy. |
rules[].estimation.default_multiplier | number | no | Default multiplier for models not listed in model_multipliers. |
rules[].estimation.bytes_per_token | number | no | Approximate bytes-per-token ratio for input_plus_max_tokens. Defaults to 4.0. |
rules[].reservation_timeout | string | no | How long an admitted-but-never-reconciled reservation (lost request: timeout, connection reset, upstream crash) is tracked as active before that already-reserved-at-admission charge against its estimate becomes irreversibly locked in (sliding-window: folded into the settled total so it survives the window’s normal aging-out; token-bucket: the tokens were already decremented at reserve time regardless, this only bounds how long the reservation is tracked as pending). This does not defer when the charge first applies – it applies immediately at admission, same as any other reservation. Answers the proposal’s still-open “lost request handling” question for this milestone. Defaults to [DEFAULT_RESERVATION_TIMEOUT] when unset. |
rules[].weights | object | no | Optional per-rule overlay on [TokenRateLimitConfig::default_weights]. Omitted types inherit the filter defaults (then 1.0). |
rules[].weights.input | number | no | Weight for uncached input tokens (the residual of token.input after subtracting cache read/write). Defaults to 1.0 when omitted. |
rules[].weights.output | number | no | Weight for visible output tokens (the residual of token.output after subtracting nested reasoning). Defaults to 1.0 when omitted. |
rules[].weights.cached_input | number | no | Weight for prompt-cache hits (token.cache_read). A value below 1.0 cheapens cached input; the proposal’s example is 0.1. |
rules[].weights.cache_write | number | no | Weight for prompt-cache writes (token.cache_write). Anthropic cache creation is typically priced above uncached input; omit to keep 1.0. |
rules[].weights.reasoning | number | no | Weight for reasoning / thinking tokens (token.reasoning). |
rules[].tiers | object[] | no | Graduated enforcement tiers (proposal S1). Each tier defines a usage threshold and an action (inject or deny). When the backend admits a request, every tier whose capacity is at or below the current usage level fires: - inject: the request continues and the tier’s headers are set on the upstream request. - deny: hard-reject with 429 (same as M6; must be the last tier). Tiers must have strictly ascending capacity values. At most one deny tier is allowed, and it must be the last. Its capacity must equal the algorithm’s own capacity. When omitted, the rule behaves as before: a single hard deny at the algorithm’s capacity. |
rules[].tiers[].capacity | integer | yes | Usage threshold at which this tier activates. |
rules[].tiers[].action | object | yes | What happens when usage crosses this tier’s threshold. |
rules[].tiers[].action.type | inject | deny | yes | Whether to continue with injected headers or hard-reject. |
rules[].tiers[].action.headers | object<string, string> | no | Headers to inject on the upstream request (required for inject, ignored for deny). |
rules[].enforcement | hard | soft | no | What happens when the algorithm denies a reservation because the token budget is exhausted (ai#1241). Defaults to [EnforcementMode::Hard] (429). Soft forwards with over_quota annotation. |
rules[].over_quota | object | no | Request-header annotation applied when enforcement is [EnforcementMode::Soft] and the algorithm denies the reservation for budget exhaustion. Required for soft (at least one static header and/or include_remaining / include_used). Rejected for hard. |
rules[].over_quota.headers | object<string, string> | no | Static headers set on the upstream request when over quota. Required to be non-empty unless include_remaining or include_used is true. |
rules[].over_quota.include_remaining | bool | no | When true, also set the remaining-quota header from the backend snapshot at denial time (tokens still available in the window / bucket — often 0 when the estimate no longer fits). |
rules[].over_quota.include_used | bool | no | When true, also set a used-quota header as limit - remaining from the same denial-time backend snapshot. |
rules[].over_quota.remaining_header | string | no | Header name for remaining tokens when include_remaining is true. Defaults to X-RateLimit-Remaining-Tokens. |
rules[].over_quota.used_header | string | no | Header name for used tokens when include_used is true. Defaults to X-Token-Quota-Used. |
key | KeySpec | no | How this filter partitions each matched rule’s token budget. Accepts a scalar (global, authenticated_subject, ip, model), a list of dimensions (composite keys), a single dimension mapping (header: x-tenant-id), or a full spec with dimensions and missing. Defaults to one shared global bucket. Composite dimension order does not matter: compiled dimensions are sorted into a canonical order so reordering a list cannot silently reset live budgets. ip and model are as caller-controlled as header when they come from a forwarding header or a client-supplied model string. Pair them with max_keys so one client cannot fill the table. |
max_keys | integer | no | Soft cap on distinct budget keys retained at once, per rule. Bounds cardinality from per-header, per-IP, and composite keying. Defaults to [super::MAX_KEYS]. A new distinct key past this cap is denied (429, accounting outcome key_capacity) rather than growing without bound. In-process ledgers enforce the cap per rule. The shared backend enforces it against the per-rule retained-key set ({namespace}:v2:keys:{rule_hash}, or the token-bucket equivalent). Idle in-process keys are reaped by ledger cleanup, which walks a bounded number of entries per request (including busy ones) so a single in-window key cannot pin the table at this cap. |
backend | object | no | Where every rule’s admission state lives: in-process (default, one budget per gateway instance) or a shared Redis/Valkey backend (one budget shared across every gateway instance/replica). One backend for the whole filter, not per rule – rules already share Redis/Valkey key-space isolation via namespace/rule-name hashing, so per-rule backend selection bought no isolation benefit, only a separate shared-backend connection per rule pointed at the same URL. Revisit if a real deployment ever needs to mix in-process and Redis/Valkey rules in one filter instance. |
backend.kind | memory | redis | valkey | no | Backend implementation selector. |
backend.url | string | no | Standalone Redis/Valkey URL. Exactly one of url or sentinel is required for kind: redis or kind: valkey; they are mutually exclusive. Ignored for kind: memory. Use rediss:// or valkeys:// for verified TLS and ${ENV_VAR} substitution for credentials supplied at deployment. |
backend.sentinel | object | no | Sentinel topology with ordered endpoints and separate Sentinel/data credentials. Exactly one of url or sentinel is required for kind: redis or kind: valkey; they are mutually exclusive. Ignored for kind: memory. TLS is derived from the endpoint scheme: rediss:// or valkeys:// enables verified TLS without a separate boolean. |
backend.sentinel.endpoints | string[] | yes | Ordered Sentinel endpoints (1..=16). Three independent endpoints are recommended operationally; one is accepted for local development. Use a consistent plain (redis:///valkey://) or verified TLS (rediss:///valkeys://) scheme, including after ${ENV_VAR} expansion. |
backend.sentinel.service_name | string | yes | Monitored primary service name. |
backend.sentinel.sentinel_username | string | no | Optional Sentinel ACL username. |
backend.sentinel.sentinel_password | string | no | Optional Sentinel ACL password, preferably supplied via ${ENV_VAR}. |
backend.sentinel.redis_username | string | no | Optional data-node ACL username. |
backend.sentinel.redis_password | string | no | Optional data-node ACL password, preferably supplied via ${ENV_VAR}. |
backend.sentinel.discovery_timeout | string | no | Maximum Sentinel discovery time as a duration string; defaults to 5s. |
backend.namespace | string | no | Key namespace prefix, so multiple filter rules or deployments can share one Redis/Valkey instance without colliding. The namespace is configured once for the whole filter, not per rule, so changing it starts a fresh accounting generation and fresh budgets for every rule in that filter. Ignored for kind: memory. Defaults to "praxis:token_rate_limit" when unset. The shared backend permanently records a schema-versioned fingerprint for each namespace/rule/algorithm identity. Replicas with a different window, capacity, refill rate, reservation timeout, state bound, or compiled budget-key policy fail closed with 503 before mutating shared state. The compatibility markers use {namespace}:v2:sw:rule:{hash}:accounting-config for sliding windows and {namespace}:v2:tb:rule:{hash}:accounting-config for token buckets. The fingerprint also includes the compiled estimation strategy and effective token-type weights, but never request-derived values such as one request’s max_tokens. On first use, the backend scans the namespace for unmarked v2 keys before claiming a persistent generation marker; the scan is bounded and fails closed rather than adopting a partial result. Pre-marker state fails closed and leaves a persistent {namespace}:v2:accounting-generation tombstone that requires a new namespace generation rather than being silently adopted. This protects against quota state surviving an expiring per-rule retained-key index. The first bootstrap must run with pre-marker writers quiesced because those writers do not participate in the scan/claim protocol. To make an intentional semantic change, quiesce the old generation, cut every writer over to a new namespace generation, and only then retire the complete old namespace. Do not delete only a compatibility marker: doing so can bind a new configuration to incompatible residual quota state. Avoid serving traffic from both generations during the cutover because their budgets are independent. Changing configuration in place is deliberately rejected. |
backend.on_failure | closed | open | no | Expected shared-backend failure behavior. closed is the safe default: return 503 without calling upstream. open explicitly waives enforcement during dependency outages: call upstream once without a reservation handle. Configuration mismatches always fail closed. This policy is separate from the pipeline-level failure_mode setting. |
backend.connect_timeout | string | no | Maximum data-node connection time as a duration string; defaults to 500ms. |
backend.operation_timeout | string | no | Maximum datastore operation time as a duration string; defaults to 500ms. |
default_weights | object | no | Filter-wide default per-type weights applied at reconciliation (proposal M4). Omitted types default to 1.0. Rules may overlay individual types via [RuleConfig::weights]. Admission still reserves the estimation/reserved_tokens cost unweighted. |
default_weights.input | number | no | Weight for uncached input tokens (the residual of token.input after subtracting cache read/write). Defaults to 1.0 when omitted. |
default_weights.output | number | no | Weight for visible output tokens (the residual of token.output after subtracting nested reasoning). Defaults to 1.0 when omitted. |
default_weights.cached_input | number | no | Weight for prompt-cache hits (token.cache_read). A value below 1.0 cheapens cached input; the proposal’s example is 0.1. |
default_weights.cache_write | number | no | Weight for prompt-cache writes (token.cache_write). Anthropic cache creation is typically priced above uncached input; omit to keep 1.0. |
default_weights.reasoning | number | no | Weight for reasoning / thinking tokens (token.reasoning). |
hierarchy | object[] | no | Optional three-level hierarchy enforcing independent org, team, and user budgets on each request. Exactly three levels in [org, team, user] order are required when present; each level reuses the existing algorithm/capacity fields from [RuleAlgorithm]. Org and team set identity_header. The user level sets identity: authenticated_subject. Exactly one of those fields is present. A configured hierarchy applies to every request that reaches the filter. The rule list must include a catch-all (a rule with no match), and every rule must have reserved_tokens or estimation.fallback_estimate, so a request cannot skip the three budgets by missing a matcher or a body field. A fixed estimate (reserved_tokens or strategy: fixed) must fit in every level’s capacity. An org, team, or user denial is always 429. A rule’s enforcement: soft does not apply to those three levels. Soft still applies to the matched rule after the three levels accept. |
hierarchy[].level | org | team | user | yes | Which hierarchy level this entry configures. |
hierarchy[].algorithm | sliding_window | token_bucket | yes | Algorithm choice and parameters for this level’s budget. |
hierarchy[].window | string | one of | Sliding window duration (e.g. "1h", "60s"). |
hierarchy[].capacity | integer | one of | Maximum tokens admitted within window. |
hierarchy[].capacity | integer | one of | Maximum tokens held at once (the bucket’s ceiling). |
hierarchy[].refill_rate | number | one of | Tokens refilled per second, up to capacity. |
hierarchy[].reservation_timeout | string | no | How long a hierarchy reservation is tracked before expiry. |
Example
filter: token_rate_limit
key: # optional: defaults to one shared bucket per rule
- authenticated_subject # global | authenticated_subject | ip | model | header: NAME
- model # header first (x-model); body only if already buffered
# key: # IP via a forwarding header (right-most hop after trusted_hops)
# ip:
# header: x-forwarded-for
# trusted_hops: 1
# ipv6_prefix: 64
# key: # named header, fail-open when absent
# header: x-tenant-id
# missing: fallback
max_keys: 100000 # optional: per-rule cap on distinct budget keys
backend: # optional: defaults to in-process state, shared by every rule
kind: valkey # memory (default) | redis | valkey
url: "${TOKEN_RATE_LIMIT_VALKEY_URL}"
namespace: praxis:token_rate_limit
default_weights: # optional: omitted types default to 1.0
input: 1.0
output: 1.0
cached_input: 0.1 # prompt-cache hits (token.cache_read)
cache_write: 1.25 # prompt-cache writes (token.cache_write)
reasoning: 0.9 # thinking tokens (token.reasoning)
rules:
- name: team-alpha # human-readable, unique per filter instance
match: # optional: omit for a catch-all rule
headers:
x-app-id: alpha
algorithm: sliding_window # sliding_window | token_bucket
window: 1h # sliding_window only: window duration
capacity: 100000 # max tokens admitted (sliding_window) or held (token_bucket)
estimation: # M3: configurable estimation strategy
strategy: max_tokens # fixed | max_tokens | input_plus_max_tokens | model_scaled
multiplier: 1.2 # optional safety margin (default: 1.0)
fallback_estimate: 500 # used when max_tokens absent from request
weights: # optional per-rule overlay on default_weights
cached_input: 0.05
- name: team-beta
match:
headers:
x-app-id: beta
algorithm: token_bucket
capacity: 50000
refill_rate: 50 # token_bucket only: tokens refilled per second
reserved_tokens: 200 # legacy: equivalent to estimation: { strategy: fixed, fallback_estimate: 200 }