/config/rate-limiters.llms.txt
Rate Limiters
Protect your infrastructure from runaway callers and prevent a misbehaving provider from eating your whole quota. eRPC applies named budget rules at four independent layers — auth, project, network, upstream — so you can cap inbound traffic per user, per IP, or globally, while a built-in auto-tuner silently backs off when upstreams send 429s and recovers when they clear up.
Quick taste
Illustrative, not a tuned production config — 100 req/s per IP on all inbound methods:
rateLimiters: budgets: - id: inbound rules: - method: "*" maxCount: 100 period: second # give each client IP its own counter, not a shared global pool perIP: trueAgent reference
Copy one of these prompts into your AI agent session (Claude Code, Cursor, …) — each one points the agent at this page's machine-readable reference so it can do the work correctly:
Prompt Example #1: add per-IP and per-user inbound caps
I want to protect my eRPC deployment from runaway callers: cap each client IP at 500 req/min globally, and give authenticated JWT users their own budget based on a claim in their token. Work with my existing eRPC config. Read the full reference first: https://docs.erpc.cloud/config/rate-limiters.llms.txt
Prompt Example #2: cap upstream spend with auto-tuning
I'm paying per-request to Alchemy and I'm worried about unexpected bill spikes. Add an upstream-level rate limit budget to each Alchemy upstream in my eRPC config so eRPC never sends more than my target RPS, and enable the auto-tuner to back off automatically when they return 429s. Make sure minBudget is at least 1. Reference: https://docs.erpc.cloud/config/rate-limiters.llms.txt
Prompt Example #3: protect a chain from heavy-method abuse
During a traffic spike one user's eth_getBlockReceipts and trace_* calls filled all my proxy connections and took down the whole chain. Add a network-level rate limit budget in my eRPC config that hard-caps just those heavy methods fleet-wide, while leaving light methods like eth_blockNumber unrestricted. Reference: https://docs.erpc.cloud/config/rate-limiters.llms.txt
Prompt Example #4: switch to Redis for multi-replica shared limits
I'm scaling my eRPC deployment to multiple replicas and the per-process memory store means each instance has its own independent counter — the effective cluster limit is much higher than intended. Migrate my eRPC config rate limiter store to Redis so all replicas share a single counter. Use a distinct cacheKeyPrefix so staging and prod don't collide. Reference: https://docs.erpc.cloud/config/rate-limiters.llms.txt
Prompt Example #5: debug why my upstream budget isn't returning 429
My upstream-level rate limit budget is clearly being exceeded — I can see erpc_rate_limits_total incrementing — but my clients are getting HTTP 200 instead of 429. Explain why upstream-level blocks don't return HTTP 429 on the wire, and tell me which metric or log to watch to confirm the block is working correctly. Work with my existing eRPC config. Reference: https://docs.erpc.cloud/config/rate-limiters.llms.txt
Rate Limiters — full agent referenceExpand for every option, default, and edge case — or copy this entire section into your AI assistant.
How it works
Every inbound request passes through up to four sequential rate-limit checks in this order:
auth → project → network → upstream. Each check looks up the budget ID configured at
that attachment point, calls TryAcquirePermit, and rejects with a layer-specific error if
any matching rule is OVER_LIMIT. The layers are independent — a budget shared across
layers will have its counter incremented once per layer per request, so avoid attaching the
same budget ID at both project and network scope.
Rule matching uses exact string equality first, then glob (WildcardMatch). If multiple
rules match a single method, all are evaluated in parallel goroutines; the first
OVER_LIMIT result short-circuits the rest.
The backing store is either an in-process sharded memory cache (64 FNV-1a shards,
lazy-expiry cleanup) or a Redis-backed Envoy ratelimit store. In memory mode, each eRPC
process has independent counters — the effective cluster limit is maxCount × instanceCount.
In Redis mode, counters are shared across all instances, but the Redis client fail-opens
while the connection is being established, and again on timeout or admission pressure.
Redis fail-open paths. Three distinct conditions cause the Redis rate limiter to allow a request instead of blocking:
- Not yet connected —
getCache()returns nil;TryAcquirePermitreturns(true, nil)before even reaching the Redis call. This window lasts from startup until Redis first connects. - Admission semaphore full — each budget has a bounded channel (
max(256, connPoolSize × 32)slots). When full, the request is allowed immediately without spawning a goroutine, anderpc_rate_limiter_remote_admission_shedded_totalincrements. This design was added after the 2026-05-07 incident where 25k+ goroutines accumulated during a Redis contention spike. - Per-call timeout (
getTimeout, default1s) —doLimitWithTimeoutreturns after the timer fires, allows the request, and incrementserpc_rate_limiter_failopen_total{reason="limit_timeout"}. The spawned goroutine continues running and completes the Redis write — the counter is updated even though the caller was not blocked.
Auto-tuner. When an upstream has rateLimitBudget set and rateLimitAutoTune is
absent from config, eRPC automatically populates rateLimitAutoTune with defaults and sets
enabled: true. This means adding rateLimitBudget to an upstream implicitly enables
the auto-tuner unless you opt out with rateLimitAutoTune.enabled: false.
The tuner samples errorCount / totalCount over each adjustmentPeriod (default 1 minute).
If error rate exceeds errorRateThreshold (default 10%), maxCount is multiplied by
decreaseFactor (default 0.95). If error rate is exactly 0, maxCount is multiplied by
increaseFactor (default 1.05). Partial error rates in the range (0, threshold] produce
no change. The tuner adjusts rules on the budget — which may be shared with other
upstreams. If two upstreams share a budget and both have auto-tuners, the tuner that fires
last wins; there is no coordination.
Envoy status codes. The Envoy ratelimit library produces three response codes per descriptor. eRPC only inspects OVER_LIMIT:
| Status | Counter range after increment | eRPC action | Internal stats |
|---|---|---|---|
OK | counter ≤ nearLimitThreshold | Allow | Stats.WithinLimit |
OK (near limit) | nearLimitThreshold < counter ≤ maxCount | Allow | Stats.WithinLimit + Stats.NearLimit |
OVER_LIMIT | counter > maxCount | Block | Stats.OverLimit |
Where nearLimitThreshold = floor(maxCount × nearLimitRatio). Default ratio 0.8: for maxCount=100, requests 81–100 are allowed (near-limit stats only); request 101 is blocked. eRPC never surfaces NEAR_LIMIT to callers. Source: upstream/ratelimiter_budget.go:L306
TryAcquirePermit flow. (1) If cache is nil → fail-open (true, nil). (2) GetRulesByMethod returns empty → allow (no rules match). (3) If any matching rule uses perIP/perUser/perNetwork and req == nil → return error. (4) Single matching rule: evaluate directly without spawning a goroutine. (5) Multiple rules: fan-out goroutines, atomic.Bool short-circuits on first OVER_LIMIT. Source: upstream/ratelimiter_budget.go:L153-244
Credit-weighted counting. By default each acquisition consumes 1 hit (HitsAddend: 1). When an upstream sets rateLimitCountMode: credit, the upstream layer passes the request's pre-flight vendor credit-unit estimate as the hit weight, so a heavy eth_getLogs (e.g. 60 CU on Alchemy) drains the budget 60× faster than a flat request count. A 0-CU method (e.g. eth_chainId) is admitted for free and consumes nothing. This weight rides the standard Envoy HitsAddend, so it works on both the memory and Redis stores. Only the upstream scope supports credit counting — project/network/auth scopes always count 1 per request. Source: upstream/upstream.go:L110-131
Cache key format. {cacheKeyPrefix}{domain}_{descriptor_entries_concatenated}_{time_window_bucket}. The erpc_rl_ prefix ensures isolation from other Envoy ratelimit users on the same Redis. Source: upstream/ratelimiter_registry.go:L76
Memory store eviction. Each shard prunes lazily: on every DoLimit call, if the shard's last-prune timestamp differs from the current unix second, all buckets with expiry ≤ now are deleted. This is O(#buckets) per shard — not O(#keys). Source: upstream/ratelimiter_mem_cache.go:L91-100
Admission cap derivation. remoteAdmissionCap = max(256, connPoolSize × 32). The ×32 multiplier accounts for radix's implicit pipelining window (up to 32 commands per connection). Pool of 8 → cap 256 (floor). Pool of 64 → cap 2048. Source: upstream/ratelimiter_registry.go:L281-291
Auto-tuner minimum sample floor. The tuner skips adjustment when totalCount < 10 — insufficient signal. Counters are reset at the start of maybeAdjust regardless, so a sustained pattern of exactly 9 requests per period will perpetually reset the clock. Source: upstream/ratelimiter_autotuner.go:L83-142
Auto-tuner requires a budget. An upstream without rateLimitBudget set never gets an auto-tuner instance, even if rateLimitAutoTune is explicitly configured. Source: upstream/upstream.go:L1322
Shared budgets. A budget ID is a plain string. Any number of attachment points — across projects, networks, upstreams, and auth strategies — can reference the same ID, sharing the same counters. In Redis mode this sharing extends across eRPC instances.
waitTime deprecation. The rules[].waitTime field is explicitly ignored: validation
emits a warning log and discards the value. There is no queue or hold logic in the rate
limiter. Remove waitTime from any existing config to suppress the warning.
Config schema
rateLimiters (top-level)
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
rateLimiters.store.driver | string | "memory" | Backend store. Valid: "memory" or "redis". Set by RateLimitStoreConfig.SetDefaults. Footgun: if rateLimiters: is present in YAML without an explicit store.driver: redis, the memory driver is silently used even if store.redis.* fields are configured. Source: common/defaults.go:L2782 |
rateLimiters.store.redis.uri | string | "" | Redis URI; takes precedence over addr. Source: common/config.go:L390 |
rateLimiters.store.redis.addr | string | "" | host:port; used when uri is empty. Source: common/config.go:L384 |
rateLimiters.store.redis.username | string | "" | Redis ACL username. Source: common/config.go:L385 |
rateLimiters.store.redis.password | string | "" | Redis password; redacted in marshalled output. Source: common/config.go:L386 |
rateLimiters.store.redis.db | int | 0 | Redis DB index. Source: common/config.go:L387 |
rateLimiters.store.redis.tls.enabled | bool | false | Enable TLS. Source: common/config.go:L376 |
rateLimiters.store.redis.tls.certFile | string | "" | Client cert PEM for mTLS. Required with keyFile when server demands client auth. Source: common/config.go:L377 |
rateLimiters.store.redis.tls.keyFile | string | "" | Client key PEM for mTLS. Must match certFile. Source: common/config.go:L378 |
rateLimiters.store.redis.tls.caFile | string | "" | Custom CA bundle; system roots used when empty. Source: common/config.go:L379 |
rateLimiters.store.redis.tls.insecureSkipVerify | bool | false | Skip server cert verification. Never use in production. Source: common/config.go:L380 |
rateLimiters.store.redis.connPoolSize | int | 8 | Radix connection pool size. Derives admission cap: max(256, connPoolSize × 32). Source: common/defaults.go:L955 |
rateLimiters.store.redis.initTimeout | Duration | 5s | Connection establishment timeout. Source: common/defaults.go:L958 |
rateLimiters.store.redis.getTimeout | Duration | 1s | Per-call DoLimit timeout. When exceeded, request fail-opens and erpc_rate_limiter_failopen_total{reason="limit_timeout"} increments. Source: common/defaults.go:L961 |
rateLimiters.store.redis.setTimeout | Duration | 3s | Unused by the rate limiter — present for struct sharing with the data-connector Redis config. Source: common/defaults.go:L964 |
rateLimiters.store.redis.lockRetryInterval | Duration | 500ms | Unused by the rate limiter — present for struct sharing with the data-connector Redis config. Source: common/defaults.go:L967 |
rateLimiters.store.redis.iamAuth.enabled | bool | false | Enables AWS ElastiCache IAM auth for the rate-limiter store. When true, SetDefaults auto-enables TLS (rediss://) and eRPC builds a radix/v3 pool whose dial function mints a fresh SigV4 token per new connection. Mutually exclusive with a static password. Source: data/redis_ratelimiter_iam.go:L1-30 |
rateLimiters.store.redis.iamAuth.cacheName | string | — | ElastiCache replication-group ID. Required when enabled. Lowercased automatically (AWS lowercases cache names at creation). Source: common/config.go:L533-545 |
rateLimiters.store.redis.iamAuth.userID | string | — | ElastiCache user. Required when enabled. The user name and user ID must be identical — supply that single value. Source: common/config.go:L533-545 |
rateLimiters.store.redis.iamAuth.region | string | derived | AWS region; derived from AWS_REGION / instance metadata when omitted. Source: common/config.go:L533-545 |
rateLimiters.store.redis.iamAuth.auth.* | *AwsAuthConfig | nil (default chain) | Optional AWS credential source (same fields as dynamodb.auth). Omit to use the instance-role / IRSA default chain (recommended on EKS/EC2). Source: common/config.go:L518-529 |
rateLimiters.store.nearLimitRatio | float32 | 0.8 | Envoy near-limit threshold fraction (nearLimitThreshold = floor(maxCount × nearLimitRatio)). Has no effect on eRPC blocking decisions — eRPC only checks OVER_LIMIT; NEAR_LIMIT is never returned to callers. Only alters internal Envoy stats counters, which are not exported to Prometheus. Source: upstream/ratelimiter_registry.go:L74 |
rateLimiters.store.cacheKeyPrefix | string | "erpc_rl_" | Prefix for all Redis/memory cache keys. Footgun: two eRPC deployments sharing the same Redis with the same prefix will merge their counters. Use distinct prefixes per deployment ("erpc_prod_", "erpc_staging_"). Source: upstream/ratelimiter_registry.go:L76 |
rateLimiters.budgets[]
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
budgets[].id | string | required | Unique identifier referenced by rateLimitBudget fields. Source: common/config.go:L1806 |
budgets[].rules[] | list | required (min 1) | At least one rule required; empty list fails validation. Source: common/validation.go:L199 |
budgets[].rules[].method | string | "*" | JSON-RPC method name or glob (eth_*, *). Default set by SetDefaults. Source: common/defaults.go:L2818 |
budgets[].rules[].maxCount | uint32 | required | Max requests per period. Mutable at runtime by auto-tuner. Footgun: maxCount: 0 means always-blocked, not unlimited — the Envoy library sets overLimitThreshold=0 so every request is immediately OVER_LIMIT. Omit rateLimitBudget entirely for unrestricted access. uint32 overflow guard: AdjustBudgetByFactor computes the new value as float64 and clamps to math.MaxUint32 (4,294,967,295) before narrowing to uint32, preventing wrap-around from a large increaseFactor. Source: upstream/ratelimiter_budget.go:L109-119 |
budgets[].rules[].period | RateLimitPeriod | second | Window granularity. Accepted string aliases (case-insensitive): second/1s, minute/1m/60s, hour/1h/3600s, day/1d/24h/86400s, week/7d/168h/604800s, month/30d/720h/2592000s, year/365d/8760h/31536000s. Integer enum values 0–6 also accepted. Footgun: "2h" is rejected even though it parses — only values exactly equal to a canonical duration are accepted. Similarly, "90s" (= 1.5 minutes) is rejected. Source: common/config.go:L1882-1950 |
budgets[].rules[].perIP | bool | false | Partition counters by client IP. Footgun: when clientIP is empty or "n/a" (e.g. non-HTTP transport), the ip descriptor is silently omitted and the rule uses a shared global counter for all IP-less callers. Source: upstream/ratelimiter_budget.go:L256-264 |
budgets[].rules[].perUser | bool | false | Partition counters by authenticated user ID. Falls back to global counter when user label is empty or "n/a". Source: common/config.go:L1816 |
budgets[].rules[].perNetwork | bool | false | Partition counters by network ID. Source: common/config.go:L1818 |
budgets[].rules[].waitTime | Duration | 0 | Deprecated — ignored with warning log. Remove from config. Source: common/validation.go:L214-216 |
Attachment points
| Config path | Scope | Evaluation order | Source |
|---|---|---|---|
projects[].auth.strategies[].rateLimitBudget | Per auth strategy | 1st | auth/authorizer.go:L119 |
projects[].auth.strategies[].secret.*.rateLimitBudget | Per secret (overrides strategy) | 1st | auth/strategy_secret.go:L29 |
projects[].auth.strategies[].jwt.rateLimitBudgetClaimName | JWT claim (default "rlm") | 1st | auth/strategy_jwt.go:L87 |
projects[].rateLimitBudget | Project-level | 2nd | erpc/projects.go:L113 |
projects[].networkDefaults.rateLimitBudget | Default for all networks | 3rd | common/defaults.go:L1783 |
projects[].networks[].rateLimitBudget | Network-level | 3rd | erpc/networks.go:L1112 |
projects[].upstreamDefaults.rateLimitBudget | Default for all upstreams | 4th | common/defaults.go:L1514 |
projects[].upstreams[].rateLimitBudget | Upstream-level | 4th | upstream/upstream.go:L460 |
projects[].auth.strategies[].siwe.rateLimitBudget | SIWE per-session | 1st | auth/strategy_siwe.go:L53 |
projects[].auth.strategies[].network.rateLimitBudget | IP-network strategy | 1st | auth/strategy_network.go:L65 |
projects[].auth.strategies[].database.failOpen.rateLimitBudget | DB fail-open | 1st | auth/strategy_database.go:L526 |
User-level budget (set by auth strategy on the user object) overrides strategy-level rateLimitBudget. Source: auth/authorizer.go:L119-122
upstreams[].rateLimitAutoTune
Available only on UpstreamConfig. Implicitly created with enabled: true when rateLimitBudget != "" and rateLimitAutoTune is absent. Explicitly set rateLimitAutoTune.enabled: false to opt out.
| Field | Type | Default | Behavior / footguns |
|---|---|---|---|
rateLimitAutoTune.enabled | *bool | true | Enable/disable. Source: common/defaults.go:L2491 |
rateLimitAutoTune.adjustmentPeriod | Duration | 1m | Minimum elapsed time between adjustments per method. Clock resets even when no adjustment occurs (insufficient samples). Source: common/defaults.go:L2494 |
rateLimitAutoTune.errorRateThreshold | float64 | 0.1 | Decrease triggered when errorRate > threshold. Source: common/defaults.go:L2497 |
rateLimitAutoTune.increaseFactor | float64 | 1.05 | Multiplier when errorRate == 0 exactly. No increase when errorRate is in (0, threshold]. Source: common/defaults.go:L2499 |
rateLimitAutoTune.decreaseFactor | float64 | 0.95 | Multiplier when errorRate > threshold. Source: common/defaults.go:L2503 |
rateLimitAutoTune.minBudget | int | 0 | Floor for maxCount after adjustment. Footgun: 0 means no floor — auto-tuner can drive maxCount to 0 (always-blocked). Set to at least 1. Source: common/config.go:L1057 |
rateLimitAutoTune.maxBudget | int | 100000 | Ceiling for maxCount after adjustment. Source: common/defaults.go:L2506 |
Worked examples
All patterns below are distilled from real production fleets; comments explain the non-obvious choices.
1. Redis store with tuned pool for a multi-replica fleet. In production, each eRPC
machine handles 50k+ rate-limit checks per minute; the default connPoolSize: 8 queues
requests and accumulates goroutines under load. A larger pool with a generous-but-bounded
getTimeout keeps the 99th-percentile check under 1ms without leaking goroutines during
Redis slowdowns:
rateLimiters: store: driver: redis redis: uri: "${REDIS_URL}" # 200ms gives the radix pipeline window (5ms) plus RTT comfortable # headroom while still fast-failing during real outages. # Never set below ~50ms on pipelined clients: the pipeline window # itself takes ~5ms and a too-tight cap triggers constant fail-opens. getTimeout: 200ms # Default is 8 — not enough for 12+ machine fleets. # With radix implicit pipelining (window=5ms, limit=32), # effective in-flight = poolSize × 32: poolSize=64 supports # ~2k concurrent checks/machine without queueing. connPoolSize: 64 # Use distinct prefixes per environment — two deployments sharing the # same Redis and same prefix will silently merge their counters. cacheKeyPrefix: "erpc_prod_" budgets: - id: per-client rules: - method: "*" maxCount: 200 period: second perIP: true2. Tiered per-user budgets via JWT claim. Production edge deployments serve customers
across multiple subscription tiers. The JWT claim "rlm" names the budget to use per
token; the auth strategy resolves it per request without any project/network attachment.
Both perUser: true and perIP: true can be combined for combinatorial (user, IP) keys:
rateLimiters: budgets: # Free tier: 6 000 req/min total per user - id: edge-tier-6krpm-total-unlimited-per-ip rules: - method: "*" maxCount: 6000 period: minute perUser: true # Paid tier: 60 000 req/min total + 500 req/min per IP ceiling - id: edge-tier-60krpm-total-500rpm-per-ip rules: - method: "*" maxCount: 60000 period: minute perUser: true - method: "*" maxCount: 500 period: minute perUser: true perIP: trueprojects: - id: main auth: strategies: - type: jwt jwt: # Token payload contains "rlm": "edge-tier-60krpm-total-500rpm-per-ip" rateLimitBudgetClaimName: rlm3. Upstream outbound budget with aggressive auto-tuner. Production uses fast
adjustmentPeriod: 30s (not the 1-minute default) and an asymmetric
decreaseFactor: 0.7 so the tuner cuts hard during 429 storms but restores slowly with
increaseFactor: 1.1. Always set minBudget: 1 — the default is 0 which lets the
auto-tuner drive maxCount to zero (always-blocked):
rateLimiters: budgets: - id: alchemy-global rules: - method: "*" maxCount: 300 period: secondprojects: - upstreams: - endpoint: "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" rateLimitBudget: alchemy-global rateLimitAutoTune: enabled: true # Faster reaction than the 1-minute default — providers can # 429-storm and recover inside a single minute adjustmentPeriod: 30s errorRateThreshold: 0.1 # Restore gently: 1.1× per period rather than default 1.05× increaseFactor: 1.1 # Cut aggressively: 0.7× per period (not the conservative 0.95×) # to shed load fast during a 429 storm decreaseFactor: 0.7 # CRITICAL: 0 means no floor — auto-tuner can drive to always-blocked minBudget: 1 maxBudget: 1000004. Network-level admission cap on heavy methods to protect proxy capacity. After an
incident where a single user's burst of eth_getBlockReceipts calls filled all Fly proxy
connections for minutes, production now hard-caps just the methods empirically observed
stacking up connections. Light methods like eth_blockNumber are intentionally not
restricted — the cap is surgical, not global:
rateLimiters: budgets: - id: cronos-mainnet rules: # Healthy peak for receipt methods is ~5 RPS fleet-wide; # 100 RPS gives 20× headroom while still protecting proxy capacity. - method: "eth_getBlockReceipts|eth_getTransactionReceipt" maxCount: 100 period: second # Trace/debug requests are large and slow — 30 RPS matches # their typical low volume and prevents runaway archive crawls. - method: "debug_*|trace_*|arbtrace_*" maxCount: 30 period: secondprojects: - id: main networks: - evm: chainId: 25 alias: cronos-mainnet # Network-level: the (N+1)th excess request gets 429-ed in <1ms # instead of holding a proxy slot for up to 16s on a slow upstream. rateLimitBudget: cronos-mainnet5. Isolated budget per vendor to prevent cross-chain auto-tune interference. When
multiple chains share a single vendor budget (e.g. alchemy-global) and the auto-tuner
reacts to errors on one chain, it adjusts the budget for all chains. Isolating a
high-traffic chain onto its own budget prevents a polygon 429 storm from cutting Ethereum's
limit:
rateLimiters: budgets: # Shared default for most chains - id: alchemy-global rules: - method: "*" maxCount: 300 period: second # Isolated: polygon errors won't affect ethereum's tuned limit - id: alchemy-polygon rules: - method: "*" maxCount: 300 period: secondprojects: - upstreams: - id: eth-mainnet-alchemy endpoint: "https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" rateLimitBudget: alchemy-global - id: polygon-alchemy endpoint: "https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}" # Isolated budget: auto-tuner reacting to polygon errors # will not lower the limit for eth-mainnet-alchemy rateLimitBudget: alchemy-polygonRequest/response behavior
Full error code reference:
| Error | HTTP status on wire | When produced |
|---|---|---|
ErrAuthRateLimitRuleExceeded | 429 | Auth-level budget rule fires. Source: common/errors.go:L545 |
ErrProjectRateLimitRuleExceeded | 429 | Project-level budget rule fires. Source: common/errors.go:L1738 |
ErrNetworkRateLimitRuleExceeded | 429 | Network-level budget rule fires. |
ErrUpstreamRateLimitRuleExceeded | 200 (despite ErrorStatusCode()=429) | Upstream-level budget rule fires; wrapped into ErrNoUpstreamsAvailable. The ErrorStatusCode()=429 only affects the gRPC adapter path. Source: common/errors.go:L1801-1821 |
ErrRateLimitBudgetNotFound | 200 (wrapped) | Budget ID typo — runtime error, not caught at startup. Source: upstream/ratelimiter_registry.go:L224 |
ErrRateLimitRuleNotFound | 200 (wrapped) | No production call sites — reserved for future use. Source: common/errors.go:L1723 |
rateLimitersabsent from config = full passthrough. WhenrateLimitersis omitted entirely,bootstrap()logs"no rate limiters defined which means all capacity of both local cpu/memory and remote upstreams will be used"at debug level and returns immediately — no budgets are registered and every request is allowed. Source:upstream/ratelimiter_registry.go:L48-51- No response carries a
Retry-Afterheader from eRPC.
Best practices
- Set
minBudgeton auto-tuner to at least1. DefaultminBudget: 0lets the auto-tuner drivemaxCountto zero (always-blocked). A floor of 10–20 preserves minimal throughput during sustained provider pressure. - Use
driver: rediswith distinctcacheKeyPrefixper environment. Memory mode makes each replica's limit independent (effectiveLimit = maxCount × replicaCount). Two Redis deployments sharing a prefix merge counters across environments. - Don't attach the same budget at both project and network scope. Each layer increments the counter independently — one request consumes two quota units.
- Prefer
method: "eth_getLogs"rules over wildcard for expensive methods. Wildcard rules matched by the auto-tuner affect every method'smaxCount, not just the one that triggered the 429. - Opt out of implicit auto-tune when you manage limits manually. Adding
rateLimitBudgetto an upstream silently enables the auto-tuner. AddrateLimitAutoTune.enabled: falseto any upstream where you want stable, manually-tuned limits. - Never use
period: "2h"or other compound durations. Only the seven canonical period names are accepted;"2h"parses successfully but is rejected at unmarshal time. - Watch
erpc_rate_limiter_failopen_total. Sustainedreason="limit_timeout"spikes mean Redis is too slow; consider raisinggetTimeoutor switching to memory mode for low-latency deployments.
Edge cases & gotchas
waitTimeis silently ignored with a warning. The field exists for backward compatibility. Validation logs"rateLimiter.*.budget.rules.*.waitTime is deprecated and will be ignored"and discards the value. No queue or hold behaviour exists. Source:common/validation.go:L214-216- Auto-tuner is implicitly enabled when
rateLimitBudgetis set.SetDefaultscreatesRateLimitAutoTuneConfig{enabled: true}whenrateLimitBudget != ""andrateLimitAutoTune == nil. Explicitly setrateLimitAutoTune.enabled: falseto prevent auto-adjustment. Source:common/defaults.go:L1674 - Shared budget + two auto-tuners = last-writer wins. If two upstreams share the same budget id, each has its own auto-tuner. Both independently call
AdjustBudgetByFactoron the same rule objects; no locking between tuners. Source:upstream/upstream.go:L1321-L1339(opens in a new tab) - Same budget at project + network layers double-counts. Each check increments the counter independently. One request consumes two quota units when the same budget id is attached at both layers. Source:
erpc/projects.go:L270(opens in a new tab),erpc/networks.go:L1112(opens in a new tab) ErrUpstreamRateLimitRuleExceededdoes NOT return HTTP 429 on the wire. The HTTP status switch aterpc/http_server.go:1484-1490lists auth/project/network errors but omits the upstream error code. An upstream-level block wraps intoErrNoUpstreamsAvailableand returns HTTP 200 with a JSON-RPC error body. Source:erpc/http_server.go:L1484-L1490(opens in a new tab)ErrRateLimitBudgetNotFoundis a runtime error, not a startup error. A typo inrateLimitBudgetis not caught at config validation; the error fires on the first request to hit that code path. Source:upstream/ratelimiter_registry.go:L224(opens in a new tab)maxCount: 0blocks everything. Envoy setsoverLimitThreshold=0; any increment producesOVER_LIMITimmediately. To disable a budget, remove therateLimitBudgetreference rather than settingmaxCount: 0. Source:upstream/ratelimiter_budget.go:L109-L119(opens in a new tab)perIP/perUser/perNetworkfall back to a global counter when the value is empty or"n/a". IP-less callers (e.g. non-HTTP transports) all share one counter, potentially allowing far more traffic than intended for that scope. Source:upstream/ratelimiter_budget.go:L256-L264(opens in a new tab)- Memory store is per-process. In a multi-replica deployment, each instance has independent counters; effective cluster limit =
maxCount × instanceCount. Usedriver: redisfor shared counting. Source:upstream/ratelimiter_mem_cache.go:L1-L30(opens in a new tab) - Redis
cacheKeyPrefixcollision. Two eRPC deployments sharing a Redis instance with the same prefix will merge counters across deployments. Use distinct prefixes per environment. Source:upstream/ratelimiter_registry.go:L75-L76(opens in a new tab) storeblock is auto-created withdriver="memory"when absent. DeclaringrateLimiters:withoutstore.driver: redissilently uses memory even if Redis fields are present. No warning is emitted. Source:common/defaults.go:L2763-2769- Wildcard rules affect auto-tuner scope broadly. A rule
method: "eth_*"matched byeth_callwill have itsmaxCountadjusted whenever anyeth_*method hits the error threshold — not only the method that triggered the change. Source:upstream/ratelimiter_autotuner.go:L113(opens in a new tab) - Period
"2h"is rejected.time.ParseDuration("2h")succeeds but2*time.Hourdoes not equal any canonical period. Only exact matches tosecond,minute,hour,day,week,month,year(and their aliases) are accepted. Source:common/config.go:L1924-1944 - Redis DoLimit goroutines outlive their callers. After
getTimeoutfires, the goroutine continues running and completes the Redis write. The counter is updated even though the caller was allowed through. This prevents counter drift but means Redis receives more writes than the caller observes. Source:upstream/ratelimiter_budget.go:L349(opens in a new tab) - Auto-tuner
adjustmentPeriodclock resets on insufficient samples.lastAdjustments[method]is updated at the start ofmaybeAdjustregardless of whether an actual adjustment occurs. A sustained pattern of exactly 9 requests per period (below the 10-sample floor) will perpetually reset the clock and prevent any evaluation. Source:upstream/ratelimiter_autotuner.go:L94(opens in a new tab) - Auto-tuner only increases when
errorRate == 0.0exactly. Partial improvement in(0, threshold]produces no adjustment — budget will not grow back until error rate reaches exactly zero. Source:upstream/ratelimiter_autotuner.go:L107-L112(opens in a new tab) minBudget: 0is no floor. DefaultminBudget=0means no clamping floor — the auto-tuner can drivemaxCountto 0, making the budget always-blocked. Set to at least 1. Source:common/config.go:L1057nearLimitRatiohas no effect on blocking decisions. eRPC only inspectsOVER_LIMIT;NEAR_LIMITis never surfaced. The field only affects internal Envoy stats counters, which are not exported to Prometheus. Source:upstream/ratelimiter_budget.go:L306(opens in a new tab)- No enforcement on nil request when all rules are non-scoped. If
req == nilis passed and all matching rules haveperIP=false,perUser=false,perNetwork=false, validation does not reject the nil request — it proceeds with the method-only descriptor. Source:upstream/ratelimiter_budget.go:L188-191 ErrRateLimitRuleNotFoundhas no production call sites. The error type is defined incommon/errors.gobut no production code callsNewErrRateLimitRuleNotFound— it appears reserved for future use. Source:common/errors.go:L1723- Admission cap floor of 256 applies regardless of pool size. Even with
connPoolSize: 1, the admission cap is 256. Small pools on low-traffic instances still allow 256 concurrent Redis calls per budget before shedding. Source:upstream/ratelimiter_registry.go:L281-291 - IAM connections for the rate-limiter store recycle at a flat 11h. Unlike the main Redis connector (which adds ±30m jitter), the rate-limiter IAM pool uses a flat
PoolMaxLifetime(11h)— radix lacks a jitter knob. All connections created at startup will expire in the same ~11h window. For a small pool this is acceptable; connections dialed under load spread naturally. Source:data/redis_ratelimiter_iam.go:L70-76
Observability
| Metric | Type | Labels | When it fires |
|---|---|---|---|
erpc_rate_limits_total | counter | project, network, vendor, upstream, category, finality, user, agent_name, budget, scope, auth, origin | A budget rule denies a request. origin ∈ {auth, project, network, upstream} |
erpc_rate_limiter_budget_max_count | gauge | budget, method, scope | Set at budget initialization and on every auto-tuner adjustment |
erpc_rate_limiter_failopen_total | counter | project, network, user, agent_name, budget, category, reason | Rate limiter allowed a request due to error; reason ∈ {admission_full, limit_timeout} |
erpc_rate_limiter_remote_inflight | gauge | budget | Incremented when a Redis DoLimit goroutine starts; decremented on completion or panic |
erpc_rate_limiter_remote_admission_shedded_total | counter | budget | Admission semaphore was full; request fail-opened without spawning a goroutine |
erpc_rate_limiter_remote_duration_seconds | histogram | budget, result | Duration of each Redis DoLimit call; result ∈ {ok, over_limit, fail_open} |
erpc_rate_limiter_budget_decision_total | counter | project, network, category, finality, user, agent_name, budget, method, scope, decision | DEPRECATED — replaced by erpc_rate_limits_total. Zero call sites in production code. Source: telemetry/metrics.go:L515 |
erpc_unexpected_panic_total | counter | origin, store, fingerprint | Redis DoLimit goroutine panicked and was recovered; origin="ratelimiter-redis-dolimit" |
Trace spans: RateLimiter.TryAcquirePermit (attrs: budget, method) and RateLimiter.DoLimit (attrs: budget, method, scope, result, one per rule evaluated). Source: upstream/ratelimiter_budget.go:L161, upstream/ratelimiter_budget.go:L279
Log messages.
| Level | Message | When |
|---|---|---|
Debug | "no rate limiters defined which means all capacity of both local cpu/memory and remote upstreams will be used" | cfg == nil at bootstrap. |
Debug | "initializing rate limiter budget" | Each budget initialized. |
Debug | "preparing rate limiter rule: ..." | Each rule added to budget. |
Warn | "failed to initialize Redis rate limiter on first attempt (rate limiting will fail-open until connected, retrying in background)" | Redis first-connect fails. |
Info | "successfully connected to Redis for rate limiting" | Redis connected. |
Debug | "rate limiter timeout exceeded, failing open" | doLimitWithTimeout timer fires. Logged at debug level only to avoid spam under sustained Redis pressure. |
Warn | "adjusting rate limiter budget from: X to: Y" | Auto-tuner changes maxCount. |
Info | "auto-tuner: adjusting rate limit budget" | Auto-tuner fires (method, prev, next, errorRate, samples, direction). |
Warn | "rateLimiter.*.budget.rules.*.waitTime is deprecated and will be ignored" | waitTime != 0 at validation. |
Source code entry points
upstream/ratelimiter_registry.go:L1-L300(opens in a new tab) — Registry creation, Redis connect (background retry), budget initialization,GetBudget, admission cap:remoteAdmissionCapupstream/ratelimiter_budget.go:L1-L460(opens in a new tab) —RateLimiterBudget:TryAcquirePermit, parallel rule evaluation,evaluateRule,doLimitWithTimeout,AdjustBudgetByFactorupstream/ratelimiter_autotuner.go:L1-L145(opens in a new tab) —RateLimitAutoTuner:RecordSuccess,RecordError,maybeAdjustwith error-rate logicupstream/ratelimiter_mem_cache.go:L1-L130(opens in a new tab) — In-process sharded bucketed counter cache; 64 FNV-1a shards, lazy O(#buckets) cleanupcommon/config.go:L1806-L1950(opens in a new tab) —RateLimiterConfig,RateLimitBudgetConfig,RateLimitRuleConfig,RateLimitPeriodenum + marshal/unmarshalcommon/defaults.go:L2763-L2820(opens in a new tab) —SetDefaultsfor all rate limit types; auto-tune defaults; implicit auto-tune populationcommon/validation.go:L199-L216(opens in a new tab) — Budget/rule validation;waitTimedeprecation warningupstream/ratelimiter_leak_test.go(opens in a new tab) — Goroutine leak tests reproducing the 2026-05-07 Redis contention incident and admission panic safety
Related pages
- Auth — authentication strategies that set per-user
rateLimitBudgetvia JWT claims or per-secret overrides. - Projects — project-level
rateLimitBudgetattachment. - Hedge — hedge bursts add request volume; combine with upstream rate limiters to cap the cost on expensive vendors.
- Retry — retries also add upstream request volume; upstream budgets apply per-attempt.
- Survive provider outages — how rate limiting and failsafe cooperate under provider pressure.