# Cache policies > Source: https://docs.erpc.cloud/config/database/evm-json-rpc-cache > Stop paying for the same upstream call twice — eRPC caches every EVM JSON-RPC response by finality bucket, fans out reads in parallel, and rejects stale tip-of-chain data before it ever reaches your users. > Format: machine-readable markdown export of the docs page above. > All collapsible AI sections are inlined and fully expanded. # Cache policies Every repeated upstream call costs money and latency. eRPC intercepts each EVM JSON-RPC request, matches it against your cache policies, and serves the stored response — no extra round-trip. Finalized data is cached forever; realtime data is age-gated against the actual block timestamp so stale chain-tip responses never escape. **What you get** - Parallel fan-out reads across multiple backends — fastest hit wins, peers cancel - Four finality buckets (`finalized`, `unfinalized`, `realtime`, `unknown`) each with independent TTLs - Transparent zstd compression on writes, automatic decompression on reads - Per-request bypass directive (`X-ERPC-Skip-Cache-Read`) for cache-busting without config changes ## Quick taste Illustrative, not a tuned production config — cache finalized data forever in memory: **Config path:** `database.evmJsonRpcCache` **YAML — `erpc.yaml`:** ```yaml database: evmJsonRpcCache: connectors: - id: mem driver: memory memory: { maxItems: 100000 } policies: - connector: mem network: "*" method: "*" # finalized blocks are reorg-immune — cache them forever finality: finalized # 0 = no expiry; safe because finalized data never changes ttl: 0 ``` **TypeScript — `erpc.ts`:** ```typescript database: { evmJsonRpcCache: { connectors: [{ id: "mem", driver: "memory", memory: { maxItems: 100000 } }], policies: [{ connector: "mem", network: "*", method: "*", // finalized blocks are reorg-immune — cache them forever finality: "finalized", // 0 = no expiry; safe because finalized data never changes ttl: 0, }], }, } ``` ## Agent 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: set up caching from scratch** ```text I want to stop paying for repeated upstream calls by enabling eRPC's EVM JSON-RPC cache. Set up a four-bucket cache (finalized, unfinalized, realtime, unknown) with an in-memory connector so every finality class is covered. Work with my existing eRPC config. Read the full reference first: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt ``` **Prompt Example #2: add a persistent Redis tier behind in-memory** ```text I already have an in-memory cache connector in my eRPC config but I want to add Redis as a warm durable fallback so cached data survives restarts. Wire both connectors to the same finalized and unfinalized policies so the memory connector wins fan-out races when hot. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt ``` **Prompt Example #3: debug realtime cache misses (eth_blockNumber always bypasses cache)** ```text My eRPC cache seems to have zero hits for eth_blockNumber and eth_call with "latest". Audit my cache policies in my eRPC config and explain why realtime methods are missing, then add the correct finality: realtime policy with an appropriate TTL. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt ``` **Prompt Example #4: limit cache to a specific method allowlist to avoid wasted connector RTTs** ```text I want to restrict my cache policies so only the methods my gRPC connector actually indexes are tried (eth_getBlockByNumber, eth_getBlockByHash, eth_getBlockReceipts, eth_getLogs, eth_getTransactionByHash, eth_getTransactionReceipt). Requests for other methods should skip the cache entirely. Update my eRPC config accordingly. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt ``` **Prompt Example #5: shadow-write to a new connector before enabling reads** ```text I want to migrate to a new PostgreSQL cache connector without risking stale reads. Set up a shadow-write policy (appliesTo: set) on the new connector while keeping reads on the existing one, so I can verify data populates correctly before cutting over. My config is in my eRPC config. Reference: https://docs.erpc.cloud/config/database/evm-json-rpc-cache.llms.txt ``` --- ### Cache policies — full agent reference ### How it works **Policy matching pipeline.** At startup eRPC builds an ordered slice of `CachePolicy` objects from `evmJsonRpcCache.policies`. Every request passes through `findGetPolicies` (reads) or `findSetPolicies` (writes). Both walk the slice and test each policy's `network`, `method`, `params`, and `finality` fields against the request. For reads, finality matching is asymmetric: a finalized request also matches unfinalized policies (a block finalized after write may be stored under the unfinalized policy); a realtime request matches only realtime policies; an unknown-finality request matches all policies. For writes, finality must match exactly. `findGetPolicies` deduplicates by connector so each connector is probed at most once per request. **Cache key derivation.** Every lookup computes a partition key `{networkId}:{blockRef}` (or `{networkId}:{cacheKeySuffix}:{blockRef}` when `networks[].cacheKeySuffix` is set) and a range key `{method}:{sha256(params)}`. `:` and `\` inside the block ref are backslash-escaped so an unsuffixed ref cannot collide with a suffixed key; refs without those bytes are unchanged. The block reference comes from `ExtractBlockReferenceFromRequest`, which inspects the per-method `reqRefs` path config. If the block reference resolves to `*` (a wildcard tag like `"latest"`), the connector is queried via a reverse index rather than the main index. If the block reference is empty — method not recognized or not cacheable — the whole cache lookup is skipped silently. **Get fan-out.** `Get` spawns one goroutine per matching policy. All goroutines race under a shared `fanCtx`; the first to find an acceptable non-empty hit calls `cancelFan()` so peers exit without posting to metrics. Results travel through a buffered channel sized to the number of goroutines so no goroutine ever blocks after `fanCtx` is done — a slow peer that refuses to honour cancellation never pins user-visible latency. A 30-second backstop context wraps the fan-out when the parent carries no deadline, protecting against FD leaks from misbehaving connectors. **Set fan-out.** `Set` fans out using `sync.WaitGroup` (fire-and-forget per policy). A hard 5-second write timeout is applied per connector, independent of any failsafe configuration. Responses with a JSON-RPC error field are never cached. Empty results for future blocks (beyond the network's confidence head) are never cached regardless of the `empty` policy setting. **Realtime freshness gate.** After a cache hit is retrieved for a realtime request, `shouldAcceptCachedResult` compares the block's unix timestamp (extracted from the response via `ExtractBlockTimestampFromResponse`) against the policy TTL. This is **block age, not cache-entry wall-clock age**: a response cached 5 minutes ago is accepted if its block was produced 3 seconds ago; a response cached 30 seconds ago is rejected if its block is 2 minutes old and TTL is 60 s. For responses with no block timestamp (`eth_blockNumber`, `eth_gasPrice`, `eth_getLogs`), the gate falls back to the connector's own latest-block timestamp via the `CacheHeadReporter` interface — currently implemented only by `GrpcConnector`, which polls every 60 seconds. If neither source is available the gate fails open (accepts the result). When age exceeds TTL the result is reclassified as `ttl_rejected` and the fan-out loop continues searching peer connectors. Non-realtime data (finalized, unfinalized, unknown) is never age-gated. **Finality buckets.** The zero value of the Go `DataFinalityState` enum is `finalized`, so omitting `finality` in YAML silently creates a finalized-only policy. | Finality | Meaning | Recommended TTL | |---|---|---| | `finalized` | Past chain's finalization horizon; reorg-immune | `0` (forever) | | `unfinalized` | Recent block, still reorgable | 5–15 s (≈ 2× block time) | | `realtime` | Updates every block (`eth_blockNumber`, `eth_gasPrice`, …) | 1–3 s | | `unknown` | Block reference indeterminate (tx-hash keyed) | `0` (key is immutable) | To cache all traffic types you need at least four policies — one per finality bucket — or the realtime/unfinalized traffic will always bypass the cache and hit upstreams. **Empty-result handling.** The `empty` field controls three modes: - `ignore` (default) — empty results are never cached on write; cached empty results are treated as misses on read. - `allow` — cache and serve empty results like any other result. - `only` — cache and serve only empty results; non-empty responses skip this policy. A result is "emptyish" if the raw JSON bytes are `null`, `""`, `"0x"`, `"0x0"`, `0`, `[]`, `{}`, or a hex string whose non-prefix digits are all zeros. **Skip-cache-read directive.** Callers can set the `X-ERPC-Skip-Cache-Read` HTTP header or `skip-cache-read` query parameter to bypass specific connectors. The value can be `"true"` (skip all connectors), `"false"` (normal), or a glob pattern matched against connector IDs. **Compression.** When `evmJsonRpcCache.compression.enabled = true` (the default — auto-created even when the `compression:` block is omitted), values are compressed with zstd before storage. Only values whose pre-compression size meets or exceeds `threshold` (default 1024 bytes) are compressed; smaller values are stored raw. If the compressed output is larger than the original it is also stored raw. Detection on read is by the zstd magic bytes `0x28 0xB5 0x2F 0xFD` — no explicit flag needed. **Connector failsafe wrapping.** When a connector config lists `failsafeForGets` or `failsafeForSets`, the raw connector is wrapped in a `FailsafeConnector`. Retry, hedge, timeout, and circuit-breaker are supported; hedge and timeout accept quantile-driven (adaptive) values, which resolve against the connector’s own 1-minute sliding latency window tracked per (method, finality) executor — configuring either in quantile mode creates that tracker, and cold start (no samples yet) falls back to `base + min`. The circuit breaker counts the executor’s own timeout expiry as a failure, so sustained timeouts exclude the connector (fail-fast to upstream fallthrough) until a half-open probe completes in time. Consensus is not supported at this scope. **gRPC connector (BDS read-through).** `GrpcConnector` is a read-only connector backed by BDS gRPC servers. It does not support `Set`, and its policies never match for writes, so `appliesTo: get` is optional. Supported methods: `eth_getBlockByNumber`, `eth_getBlockByHash`, `eth_getLogs`, `eth_getTransactionByHash`, `eth_getTransactionReceipt`, `eth_getBlockReceipts`, `eth_chainId`, `eth_blockNumber`. `eth_blockNumber` is derived by fetching the latest block internally — there is no native BDS gRPC call for it. The gRPC connector performs a fast-miss check: if the request block number is below the connector's earliest known block, it returns `ErrRecordNotFound` immediately without a network call. **CacheHash derivation (range key).** The range key `{method}:{sha256(params)}` is computed by `JsonRpcRequest.CacheHash` (`common/json_rpc.go:L1385-L1409`). It hashes params positionally using SHA-256 over a recursive type-aware `hashValue` function, producing a hex string prefixed with the method name. The hash is memoized on the request object — multiple policies referencing the same request compute it only once. **reqRefs and respRefs — block reference extraction.** These per-method config fields (`networks[*].methods.definitions..reqRefs` / `respRefs`) tell the block-reference extraction logic where in a JSON-RPC request or response to find a block number, tag, or hash. They determine whether a request is cacheable and what partition key to use. Built-in path variables in `common/defaults.go`: | Variable | Path | Used by | |---|---|---| | `FirstParam` | `[[0]]` | `eth_getBlockByNumber`, `eth_getBlockByHash`, … | | `SecondParam` | `[[1]]` | `eth_getStorageAt`, `eth_getCode`, … | | `ThirdParam` | `[[2]]` | `eth_getProof` | | `NumberOrHashParam` | `[["number"], ["hash"]]` | response object fields | | `BlockNumberOrBlockHashParam` | `[["blockNumber"], ["blockHash"]]` | receipt-like responses | | `ArbitraryBlock` | `[["*"]]` | `eth_getTransactionByHash`, `eth_getTransactionReceipt` — tx-hash methods | Path format: each selector is an `[]interface{}` where integers index JSON arrays and strings key into JSON objects. An empty path (`[]`) means the value at that position IS the scalar. A single-element path `["*"]` means "arbitrary block" — produces `blockRef = "*"` which routes to the reverse index. **reqRefs extraction algorithm** (`architecture/evm/block_ref.go:L277-L310`): 1. If `reqRefs` has exactly one path of length 1 and that element is `"*"`: return `blockRef="*"` immediately. 2. Otherwise for each path, parse the value as a block parameter (hex number, decimal, or named tag). 3. If multiple paths yield different block references (e.g. `eth_getLogs` with different `fromBlock`/`toBlock`): set `blockRef = "*"` (composite range). 4. Accumulate the highest block number seen across all paths. 5. If no path resolves: `blockRef = ""` → cache bypassed entirely, trace attribute `cache.skip_reason = "empty_block_ref"` set. **respRefs extraction algorithm** (`architecture/evm/block_ref.go:L343-L368`): Called only after a response exists. First non-`"*"` block ref wins; result supplements or overrides the request-derived ref for the cache key. Custom `reqRefs`/`respRefs` in user config **override built-in defaults entirely** — there is no merge. ### Config schema All fields are under `database.evmJsonRpcCache`. `Duration` values accept Go duration strings or bare integers (milliseconds). #### `database.evmJsonRpcCache` (root) | Field | Type | Default | Behavior / footguns | |---|---|---|---| | `database.evmJsonRpcCache` | `*CacheConfig` | `nil` | When `nil` (omitted): cache subsystem is entirely disabled — the `EvmJsonRpcCache` object is never created. `SetDefaults` on sub-fields is only called when this pointer is non-nil. The auto-generated project (`id: "main"`, created when no `projects:` block exists) has NO `evmJsonRpcCache` config. You must provide the key explicitly to enable caching. Source: [`common/defaults.go:L831-836`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L831-L836) | For requests that keep a moving tag untranslated (`"latest"`, `"finalized"` — by default `eth_getBlockByNumber`), eRPC keys the cache entry by the concrete block number — the response's own number on write, the network's tracked tip on read. `"safe"` keeps its literal-tag key. That means a burst of concurrent tag queries landing on the same tip coalesces onto one cached entry, but once the tip advances the key changes and the next request forwards upstream. You don't have to worry about a `"latest"` query returning a block that's multiple tips stale just because the TTL hasn't expired. #### `evmJsonRpcCache.compression` Auto-created even when omitted: `CacheConfig.SetDefaults` always creates `&CompressionConfig{}` and calls its `SetDefaults()` when `c.Compression == nil`. The only way to opt out is `compression.enabled: false`. | Field | Type | Default | Behavior / footguns | |---|---|---|---| | `compression.enabled` | `*bool` | `true` — set by `SetDefaults` when nil | Enable zstd compression. Footgun: omitting the block silently enables compression. Source: [`common/defaults.go:L580-582`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L580-L582) | | `compression.algorithm` | string | `"zstd"` | Only `"zstd"` is implemented; reserved for future algorithms. Source: [`common/defaults.go:L585-587`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L585-L587) | | `compression.zstdLevel` | string | `"fastest"` | Valid: `"fastest"`, `"default"`, `"better"`, `"best"`. Unknown values fall back to `fastest` with a warning. Source: [`common/defaults.go:L590-592`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L590-L592) | | `compression.threshold` | int (bytes) | `1024` | Minimum pre-compression size to attempt compression. Footgun: the inline code default is 512 bytes but `SetDefaults` overrides to 1024; `SetDefaults` wins at runtime. Source: [`common/defaults.go:L597-599`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L597-L599) | #### `evmJsonRpcCache.connectors[*]` Connector `SetDefaults` is called with `scope = connectorScopeCache`, which sets cache-specific defaults. | Field | Type | Default | Behavior / footguns | |---|---|---|---| | `connectors[*].id` | string | `"cache-"` | Unique ID referenced by policies. Auto-set to `"cache-" + driver` when blank. Source: [`common/defaults.go:L847-849`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L847-L849) | | `connectors[*].driver` | string | required | One of `memory`, `redis`, `dynamodb`, `postgresql`, `grpc`. | | `connectors[*].memory.maxItems` | int | required | Max items in Ristretto in-process cache. | | `connectors[*].memory.maxTotalSize` | string | required | Max total memory, e.g. `"1GB"`. | | `connectors[*].memory.emitMetrics` | `*bool` | `nil` | Emit Ristretto cost/set-failure metrics when true. | | `connectors[*].redis.addr` | string | `""` | Redis `host:port`. Mutually exclusive with `uri`. | | `connectors[*].redis.uri` | string | `""` | Full Redis URI. Preferred over `addr`. | | `connectors[*].redis.password` | string | `""` | Redacted in JSON/YAML marshal. | | `connectors[*].redis.db` | int | `0` | Redis DB index. | | `connectors[*].redis.connPoolSize` | int | `0` (driver default) | Connection pool size. | | `connectors[*].redis.initTimeout` | Duration | driver default | Timeout for initial connection. | | `connectors[*].redis.getTimeout` | Duration | driver default | Per-GET timeout. | | `connectors[*].redis.setTimeout` | Duration | driver default | Per-SET timeout. | | `connectors[*].redis.lockRetryInterval` | Duration | driver default | Distributed lock retry interval. | | `connectors[*].redis.tls` | `*TLSConfig` | `nil` | Optional TLS for Redis. | | `connectors[*].dynamodb.table` | string | required | DynamoDB table name. | | `connectors[*].dynamodb.region` | string | required | AWS region. | | `connectors[*].dynamodb.partitionKeyName` | string | required | Partition key attribute name. | | `connectors[*].dynamodb.rangeKeyName` | string | required | Range key attribute name. | | `connectors[*].dynamodb.reverseIndexName` | string | required | GSI name for reverse index (wildcard block lookups). | | `connectors[*].dynamodb.ttlAttributeName` | string | required | DynamoDB TTL attribute name. | | `connectors[*].dynamodb.initTimeout` | Duration | driver default | Init timeout. | | `connectors[*].dynamodb.getTimeout` | Duration | driver default | Per-GET timeout. | | `connectors[*].dynamodb.setTimeout` | Duration | driver default | Per-SET timeout. | | `connectors[*].dynamodb.maxRetries` | int | `0` | AWS SDK max retries. | | `connectors[*].dynamodb.statePollInterval` | Duration | driver default | State poller interval. | | `connectors[*].dynamodb.lockRetryInterval` | Duration | driver default | Distributed lock retry interval. | | `connectors[*].postgresql.connectionUri` | string | required | PostgreSQL connection URI. Redacted in marshal. | | `connectors[*].postgresql.table` | string | `"erpc_json_rpc_cache"` (cache scope) | Auto-set to `"erpc_json_rpc_cache"` when scope=cache and value is blank. Source: [`common/defaults.go:L1027`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L1027) | | `connectors[*].postgresql.minConns` | int32 | `4` (cache scope) | Min pool connections. Source: [`common/defaults.go:L1034-1040`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L1034-L1040) | | `connectors[*].postgresql.maxConns` | int32 | `32` (cache scope) | Max pool connections. Source: [`common/defaults.go:L1041-1047`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L1041-L1047) | | `connectors[*].grpc.bootstrap` | string | `""` | HTTP URL to fetch gRPC server list. | | `connectors[*].grpc.servers` | `[]string` | nil | Explicit gRPC server URLs. When both `bootstrap` and `servers` are provided, explicit list takes precedence and bootstrap is also resolved and appended. Source: [`data/grpc.go:L85-95`](https://github.com/erpc/erpc/blob/main/data/grpc.go#L85-L95) | | `connectors[*].grpc.headers` | `map[string]string` | nil | Headers sent with every gRPC request. | | `connectors[*].grpc.getTimeout` | Duration | `100ms` | Per-call timeout. Applied when the existing context deadline is absent or farther in the future than `getTimeout`. Footgun: older docs incorrectly stated `0 (no override)`; the correct runtime default is 100ms. Source: [`common/defaults.go:L927-929`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L927-L929) | | `connectors[*].failsafeForGets` | `[]*FailsafeConfig` | nil | Per-connector failsafe for Get operations. Hedge and timeout accept quantile-driven values resolved from the connector's own latency window ([`data/cache_executor.go:L204-207`](https://github.com/erpc/erpc/blob/main/data/cache_executor.go#L204-L207), [`data/cache_executor.go:L244-249`](https://github.com/erpc/erpc/blob/main/data/cache_executor.go#L244-L249)). Consensus is not supported at this scope. Source: [`data/cache_executor.go:L59-63`](https://github.com/erpc/erpc/blob/main/data/cache_executor.go#L59-L63) | | `connectors[*].failsafeForSets` | `[]*FailsafeConfig` | nil | Per-connector failsafe for Set operations. Same semantics as Gets. | #### `evmJsonRpcCache.policies[*]` | Field | Type | Default | Behavior / footguns | |---|---|---|---| | `policies[*].connector` | string | required | ID matching a `connectors[*].id`. | | `policies[*].network` | string | `"*"` | Wildcard/glob network filter against `networkId`. Source: [`common/defaults.go:L608-610`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L608-L610) | | `policies[*].method` | string | `"*"` | Wildcard/glob method filter. Source: [`common/defaults.go:L605-607`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L605-L607) | | `policies[*].params` | `[]interface{}` | nil | Positional parameter pattern; nil matches any params. Each element: string (wildcard-matched), object (recursive key match), array (index match), or nil (match any/absent). Extra params beyond pattern length are ignored. | | `policies[*].finality` | `DataFinalityState` | `finalized` (zero value of iota) | One of `finalized`, `unfinalized`, `realtime`, `unknown`. **Footgun: omitting this field is byte-identical to `finality: finalized`.** Realtime methods will silently miss unless a separate `finality: realtime` policy exists. Source: [`common/data.go:L9-27`](https://github.com/erpc/erpc/blob/main/common/data.go#L9-L27) | | `policies[*].empty` | `CacheEmptyBehavior` | `ignore` (iota=0) | One of `ignore`, `allow`, `only`. | | `policies[*].appliesTo` | `CachePolicyAppliesTo` | `"both"` | One of `"both"`, `"get"`, `"set"`. Restricts whether the policy is evaluated for reads, writes, or both. Source: [`common/defaults.go:L611-613`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L611-L613) | | `policies[*].minItemSize` | `*string` | nil | ByteSize string (`"512B"`, `"1KB"`, `"2MB"`). Measured as **pre-compression length of the JSON-RPC `result` field bytes**. Responses smaller than this are silently skipped (no error, increments `erpc_cache_set_skipped_total`). Source: [`architecture/evm/json_rpc_cache.go:L1069-1072`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L1069-L1072) | | `policies[*].maxItemSize` | `*string` | nil | ByteSize string. Same format and measurement as `minItemSize`. Responses whose result byte length exceeds this are silently skipped. Source: [`data/cache_policy.go:L152-160`](https://github.com/erpc/erpc/blob/main/data/cache_policy.go#L152-L160) | | `policies[*].ttl` | Duration | `0` (unlimited) | Time-to-live stored with each key. When set on a `realtime` policy, also used as the freshness window in the age gate. | ### Worked examples All patterns below are distilled from real production fleets; comments explain the non-obvious choices. **1. Two-tier memory connectors: fast unfinalized + large finalized pool.** Production fleets separate recent reorgable data from immutable historical data because their access patterns and eviction pressures differ. Unfinalized blocks churn quickly; finalized blocks are read far more and rarely evicted: **Config path:** `database.evmJsonRpcCache` **YAML — `erpc.yaml`:** ```yaml database: evmJsonRpcCache: connectors: - id: memory-cache-unfinalized driver: memory memory: # High item count for fast-cycling recent blocks and tx receipts maxItems: 2000000 # Cap at 8GB: recent data is small-ish JSON, 2M items * ~4KB avg maxTotalSize: 8GB - id: memory-cache-finalized driver: memory memory: # Lower count: finalized items are large (full blocks, log ranges) maxItems: 500000 maxTotalSize: 4GB policies: - connector: memory-cache-unfinalized network: "*" # Only methods this fleet actually indexes — wildcard eth_get* wastes RTTs # on guaranteed misses for eth_getBalance, eth_getCode, eth_getProof, etc. method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unfinalized # empty: allow — a null receipt means "not indexed yet"; still worth # caching so rapid polling doesn't hammer upstreams for the same miss empty: allow ttl: 0 - connector: memory-cache-unfinalized network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unknown empty: allow ttl: 0 - connector: memory-cache-finalized network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: finalized empty: allow # 0 = no expiry: finalized data is reorg-immune and never goes stale ttl: 0 ``` **TypeScript — `erpc.ts`:** ```typescript database: { evmJsonRpcCache: { connectors: [ { id: "memory-cache-unfinalized", driver: "memory", memory: { // High item count for fast-cycling recent blocks and tx receipts maxItems: 2000000, // Cap at 8GB: recent data is small-ish JSON, 2M items * ~4KB avg maxTotalSize: "8GB", }, }, { id: "memory-cache-finalized", driver: "memory", memory: { // Lower count: finalized items are large (full blocks, log ranges) maxItems: 500000, maxTotalSize: "4GB", }, }, ], policies: [ { connector: "memory-cache-unfinalized", network: "*", // Only methods this fleet actually indexes — wildcard eth_get* wastes RTTs // on guaranteed misses for eth_getBalance, eth_getCode, eth_getProof, etc. method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*", finality: "unfinalized", // empty: allow — a null receipt means "not indexed yet"; still worth // caching so rapid polling doesn't hammer upstreams for the same miss empty: "allow", ttl: 0, }, { connector: "memory-cache-unfinalized", network: "*", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*", finality: "unknown", empty: "allow", ttl: 0, }, { connector: "memory-cache-finalized", network: "*", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*", finality: "finalized", empty: "allow", // 0 = no expiry: finalized data is reorg-immune and never goes stale ttl: 0, }, ], }, } ``` **2. gRPC read-through connector with quantile hedge and per-call timeout.** When the primary cache backend is a remote gRPC server (BDS read-through), add a quantile hedge so a slow node doesn't hold up the request — the delay resolves against this connector's own 1-minute latency window (per method/finality executor), and falls back to `base + min` before any samples exist. The `400ms` timeout keeps the cache miss path fast enough that going straight to upstreams is always cheaper than waiting on a struggling connector: **Config path:** `database.evmJsonRpcCache` **YAML — `erpc.yaml`:** ```yaml database: evmJsonRpcCache: connectors: - id: grpc-cache driver: grpc grpc: # getTimeout: applied when parent context has no deadline or a farther deadline # 400ms: if the cache read takes longer, skip it and go to upstreams getTimeout: 400ms servers: - https://rpc.example.com:50051 headers: Authorization: "Bearer \${CACHE_API_TOKEN}" failsafeForGets: - matchMethod: "*" timeout: # Hard ceiling on a single get call; hedge fires before this duration: 400ms hedge: delay: # p95 of this connector's own completed reads (1-minute window) quantile: 0.95 # floor; also cold-start fallback (no samples yet → base+min) min: 50ms max: 200ms maxCount: 1 retry: maxAttempts: 2 # 0: retry immediately — no point waiting for a slow cache node delay: "0" policies: - connector: grpc-cache network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unfinalized empty: allow # get-only: gRPC connector is read-only, no Set support appliesTo: get ttl: 0 - connector: grpc-cache network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unknown empty: allow appliesTo: get ttl: 0 - connector: grpc-cache network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: finalized empty: allow appliesTo: get ttl: 0 ``` **TypeScript — `erpc.ts`:** ```typescript database: { evmJsonRpcCache: { connectors: [{ id: "grpc-cache", driver: "grpc", grpc: { // getTimeout: applied when parent context has no deadline or a farther deadline // 400ms: if the cache read takes longer, skip it and go to upstreams getTimeout: "400ms", servers: ["https://rpc.example.com:50051"], headers: { Authorization: "Bearer \${CACHE_API_TOKEN}" }, }, failsafeForGets: [{ matchMethod: "*", timeout: { duration: "400ms" }, hedge: { // p95 of this connector's own reads (1m window); cold start → base+min delay: { quantile: 0.95, min: "50ms", max: "200ms" }, maxCount: 1, }, retry: { maxAttempts: 2, delay: "0" }, }], }], policies: [ { connector: "grpc-cache", network: "evm:1", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt", finality: "unfinalized", empty: "allow", appliesTo: "get", ttl: 0, }, { connector: "grpc-cache", network: "evm:1", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt", finality: "unknown", empty: "allow", appliesTo: "get", ttl: 0, }, { connector: "grpc-cache", network: "evm:1", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt", finality: "finalized", empty: "allow", appliesTo: "get", ttl: 0, }, ], }, } ``` **3. Chain-specific gRPC reader in front of a wildcard fallback pool.** Production fleets often have a high-fidelity per-chain reader colocated in the same cluster alongside a broader pooled connector that covers every chain. The chain-specific connector wins the connector dedup for its network; the wildcard connector catches everything else. Per-chain reader policies come first so their `network: "evm:1"` entries win before the wildcard `network: "*"` entry: **Config path:** `database.evmJsonRpcCache` **YAML — `erpc.yaml`:** ```yaml database: evmJsonRpcCache: connectors: - id: eth-reader driver: grpc grpc: getTimeout: 400ms servers: - grpc://eth-reader-lb.cache.svc.cluster.local:50051 failsafeForGets: - matchMethod: "*" retry: { maxAttempts: 2, delay: "0" } timeout: { duration: 400ms } hedge: { delay: 100ms, maxCount: 1 } - id: edge-cache-pool driver: grpc grpc: getTimeout: 400ms servers: - https://cache-base.rpc.example.com:50051 - https://cache-arbitrum.rpc.example.com:50051 failsafeForGets: - matchMethod: "*" retry: { maxAttempts: 2, delay: "0" } timeout: { duration: 400ms } hedge: { delay: 100ms, maxCount: 1 } - id: memory-cache-finalized driver: memory memory: { maxItems: 500000, maxTotalSize: 4GB } policies: # Chain-specific per-chain reader policies come first — connector dedup means these # win the evm:1 slot; the wildcard pool never sees evm:1 requests - connector: eth-reader network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unfinalized empty: allow appliesTo: get ttl: 0 - connector: eth-reader network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: unknown empty: allow appliesTo: get ttl: 0 - connector: eth-reader network: "evm:1" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt" finality: finalized empty: allow appliesTo: get ttl: 0 # Wildcard pool covers all other chains - connector: edge-cache-pool network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unfinalized empty: allow appliesTo: get ttl: 0 - connector: edge-cache-pool network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: unknown empty: allow appliesTo: get ttl: 0 - connector: edge-cache-pool network: "*" method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*" finality: finalized empty: allow appliesTo: get ttl: 0 # In-memory finalized cache for all chains — written by upstreams, read locally - connector: memory-cache-finalized network: "*" method: "*" finality: finalized ttl: 0 ``` **TypeScript — `erpc.ts`:** ```typescript database: { evmJsonRpcCache: { connectors: [ { id: "eth-reader", driver: "grpc", grpc: { getTimeout: "400ms", servers: ["grpc://eth-reader-lb.cache.svc.cluster.local:50051"], }, failsafeForGets: [{ matchMethod: "*", retry: { maxAttempts: 2, delay: "0" }, timeout: { duration: "400ms" }, hedge: { delay: "100ms", maxCount: 1 }, }], }, { id: "edge-cache-pool", driver: "grpc", grpc: { getTimeout: "400ms", servers: ["https://cache-base.rpc.example.com:50051", "https://cache-arbitrum.rpc.example.com:50051"], }, failsafeForGets: [{ matchMethod: "*", retry: { maxAttempts: 2, delay: "0" }, timeout: { duration: "400ms" }, hedge: { delay: "100ms", maxCount: 1 }, }], }, { id: "memory-cache-finalized", driver: "memory", memory: { maxItems: 500000, maxTotalSize: "4GB" } }, ], policies: [ // Chain-specific per-chain reader policies come first — connector dedup means these // win the evm:1 slot; the wildcard pool never sees evm:1 requests { connector: "eth-reader", network: "evm:1", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt", finality: "unfinalized", empty: "allow", appliesTo: "get", ttl: 0 }, { connector: "eth-reader", network: "evm:1", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt", finality: "unknown", empty: "allow", appliesTo: "get", ttl: 0 }, { connector: "eth-reader", network: "evm:1", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt", finality: "finalized", empty: "allow", appliesTo: "get", ttl: 0 }, // Wildcard pool covers all other chains { connector: "edge-cache-pool", network: "*", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*", finality: "unfinalized", empty: "allow", appliesTo: "get", ttl: 0 }, { connector: "edge-cache-pool", network: "*", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*", finality: "unknown", empty: "allow", appliesTo: "get", ttl: 0 }, { connector: "edge-cache-pool", network: "*", method: "eth_getBlockByNumber|eth_getBlockByHash|eth_getBlockReceipts|eth_getLogs|eth_getTransactionByHash|eth_getTransactionReceipt|eth_query*", finality: "finalized", empty: "allow", appliesTo: "get", ttl: 0 }, // In-memory finalized cache for all chains — written by upstreams, read locally { connector: "memory-cache-finalized", network: "*", method: "*", finality: "finalized", ttl: 0 }, ], }, } ``` **4. Intentionally no realtime caching.** Some fleets deliberately skip the `finality: realtime` bucket so `eth_blockNumber`, `eth_getBlockByNumber("latest")`, and `eth_gasPrice` always go to fresh upstreams. This is the right choice when your cache connector doesn't implement `CacheHeadReporter` (all drivers except gRPC) — without a block timestamp the realtime age gate fails open and you'd serve stale chain-tip data. The three-bucket shape below makes the intent explicit and avoids the silent miss: **Config path:** `database.evmJsonRpcCache` **YAML — `erpc.yaml`:** ```yaml database: evmJsonRpcCache: connectors: - id: mem driver: memory memory: { maxItems: 500000, maxTotalSize: 4GB } policies: # Deliberately no finality: realtime policy — eth_blockNumber and "latest" # calls always bypass cache and go to upstreams for a fresh head - connector: mem network: "*" method: "*" finality: finalized ttl: 0 - connector: mem network: "*" method: "*" finality: unfinalized # ~2x Ethereum block time; adjust to 2x your slowest chain's block time ttl: 24s - connector: mem network: "*" method: "*" finality: unknown ttl: 0 ``` **TypeScript — `erpc.ts`:** ```typescript database: { evmJsonRpcCache: { connectors: [{ id: "mem", driver: "memory", memory: { maxItems: 500000, maxTotalSize: "4GB" } }], policies: [ // Deliberately no finality: realtime policy — eth_blockNumber and "latest" // calls always bypass cache and go to upstreams for a fresh head { connector: "mem", network: "*", method: "*", finality: "finalized", ttl: 0 }, { connector: "mem", network: "*", method: "*", finality: "unfinalized", // ~2x Ethereum block time; adjust to 2x your slowest chain's block time ttl: "24s", }, { connector: "mem", network: "*", method: "*", finality: "unknown", ttl: 0 }, ], }, } ``` **5. Shadow-write debugging mode.** Write cache entries for a new connector without risking stale reads — `appliesTo: set` means reads always go to upstreams while you verify the connector is populating correctly. Flip to `appliesTo: both` once you've confirmed hit rates in `erpc_cache_set_success_total`: **Config path:** `database.evmJsonRpcCache` **YAML — `erpc.yaml`:** ```yaml database: evmJsonRpcCache: connectors: - id: new-pg driver: postgresql postgresql: connectionUri: "postgres://\${DB_USER}:\${DB_PASS}@db-new:5432/erpc" # table auto-set to "erpc_json_rpc_cache" at cache scope when blank policies: - connector: new-pg finality: finalized # set only: reads still hit upstreams; verify connector fills correctly appliesTo: set ttl: 0 ``` **TypeScript — `erpc.ts`:** ```typescript database: { evmJsonRpcCache: { connectors: [{ id: "new-pg", driver: "postgresql", postgresql: { connectionUri: "postgres://\${DB_USER}:\${DB_PASS}@db-new:5432/erpc", // table auto-set to "erpc_json_rpc_cache" at cache scope when blank }, }], policies: [{ connector: "new-pg", finality: "finalized", // set only: reads still hit upstreams; verify connector fills correctly appliesTo: "set", ttl: 0, }], }, } ``` ### Request/response behavior - **Cache key structure.** Partition key: `{networkId}:{blockRef}`, or `{networkId}:{cacheKeySuffix}:{blockRef}` when `networks[].cacheKeySuffix` is set. `:` and `\` in the block ref (and suffix) are backslash-escaped; other refs, including `"*"`, are stored as-is. Range key: `{method}:{sha256(params)}`. When `blockRef = "*"` (tag-based or multi-block range), the connector is queried via the reverse index (`ConnectorReverseIndex = "idx_reverse"`). When `blockRef = ""`, the lookup is bypassed entirely. Reverse-index wildcard is `{networkId}:{suffix}:*` (from `CachePartitionKey`), so suffixed and unsuffixed networks on the same chainId do not share a reverse-index slot even when the block ref contains `:`. - **`X-ERPC-Skip-Cache-Read` header / `skip-cache-read` query param.** Value `"true"` skips all connectors. A glob pattern (e.g. `"mem*"`) skips matching connectors only. Passing an empty connector ID with a pattern always returns false — patterns are only evaluated per-connector. - **Responses with a JSON-RPC `error` field are never cached.** Neither are empty results for blocks beyond the network's confidence head, regardless of `empty` policy setting. - **Set has a hard 5-second per-connector write timeout**, independent of any configured failsafe. If both are configured, the shorter one fires first. - **Realtime age gate uses block timestamp, not wall-clock cache age.** The `erpc_cache_get_age_guard_reject_total` metric fires when age exceeds TTL; its label is `method` not `category` — unique among cache metrics. ### Best practices - **Always define four policies (one per finality bucket) when you want to cache all traffic.** A single catch-all policy with no explicit `finality:` silently caches only finalized data. `eth_blockNumber`, `eth_gasPrice`, `eth_call` with `"latest"`, and `eth_getBalance` with `"latest"` are all `realtime` and will miss unless a `finality: realtime` policy exists. - **Keep realtime TTL between 1 and 3 s.** Lower than 1 s means the freshness gate will almost always reject on slower connectors; higher than 3 s risks serving chain-tip data that is multiple blocks stale. - **Set `finality: unfinalized` TTL at 2× block time.** For Ethereum mainnet (~12 s) use 24 s. This gives enough room for the block to finalize without serving stale data for too long. - **Place the fastest connector first in the policy list when sharing a finality bucket.** The Get fan-out races all connectors in parallel, but list order determines which connector wins ties and how quickly cancelation propagates. Memory connectors should come before Redis or PostgreSQL. - **Do not set `compression.enabled: false` unless you have profiled actual CPU overhead.** zstd at `"fastest"` level saves 50–80% storage on typical ABI-encoded JSON-RPC results. The benefit almost always outweighs the CPU cost, and the memory connector would double-compress without it (both layers detect magic bytes and short-circuit, but the extra pass is wasted work only when compression is enabled on both). - **Use `minItemSize` to avoid caching tiny responses.** Responses under 200 B are often `null`, short errors, or simple scalars. Caching them creates key-count pressure on Ristretto with negligible savings. - **Monitor `erpc_cache_get_age_guard_reject_total` vs `erpc_cache_get_success_hit_total` for realtime policies.** A high reject ratio means your realtime TTL is too tight relative to connector polling latency or your `GrpcConnector` head poll interval (60 s). Raise the TTL or switch to a connector with an embedded block timestamp in responses. ### Edge cases & gotchas 1. **`finality: finalized` is the zero value — intentionally the safest default.** The Go comment at [`common/data.go:L10-L15`](https://github.com/erpc/erpc/blob/main/common/data.go#L10-L15) explicitly states finalized gets 0 so that an unspecified finality defaults to the safest sane cache default. Omitting `finality` means only finalized requests match; realtime, unfinalized, and unknown requests bypass this policy and always hit upstreams. 2. **Realtime finality must be stated explicitly — omission silently drops all realtime traffic.** Common realtime methods that silently miss without `finality: realtime`: `eth_blockNumber`, `eth_gasPrice`, `eth_call` with `"latest"`, `eth_getBalance` with `"latest"`. This is the most common misconfiguration. 3. **findGetPolicies deduplicates by connector; findSetPolicies does not.** A connector can receive multiple writes for the same request if multiple policies reference it. Source: [`architecture/evm/json_rpc_cache.go:L926-L978`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L926-L978). 4. **Emptyish hit under `empty=ignore` is detected in the fan-out goroutine, not post-fan-out.** Without this, the emptyish result would win the race and cancel peers, losing a chance for a peer with `empty=allow` and non-empty data to win. Source: [`architecture/evm/json_rpc_cache.go:L343-L351`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L343-L351). 5. **The realtime age gate checks block timestamp, not cache-entry age.** A response cached 5 minutes ago is accepted if its block was produced 3 seconds ago. A response cached 30 seconds ago is rejected if its block is 2 minutes old and TTL is 60 s. Source: [`architecture/evm/json_rpc_cache.go:L841-L920`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L841-L920). 6. **`eth_blockNumber`, `eth_gasPrice`, and `eth_getLogs` carry no block timestamp** in their response. The realtime age gate falls back to the connector's `CacheLatestBlockTimestamp`, which is only implemented by `GrpcConnector` (polls every 60 seconds). For all other connectors the gate fails open (accepts the cached result). Source: [`architecture/evm/json_rpc_cache.go:L879-L889`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L879-L889). 7. **Compression threshold has two definitions.** The inline code default in `NewEvmJsonRpcCache` is 512 bytes, but `SetDefaults` sets it to 1024 bytes. `SetDefaults` always runs at startup, so the effective default is 1024. The 512 value is only reachable when the cache object is constructed directly without `SetDefaults` (i.e., in unit tests). Source: [`common/defaults.go:L597-L599`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L597-L599). 8. **Set has a hard 5-second timeout per connector in addition to any failsafe timeout.** If both are configured, the shorter one fires first. The 5-second fallback protects connectors with no failsafe configured. Source: [`architecture/evm/json_rpc_cache.go:L768`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L768). 9. **`minItemSize` and `maxItemSize` measure pre-compression result length, not stored blob size.** A 4 KB result that compresses to 400 bytes still fails a `maxItemSize: 2KB` check. The measured value is `JsonRpcResponse.ResultLength()` — raw JSON result bytes, not the full JSON-RPC envelope. Source: [`architecture/evm/json_rpc_cache.go:L1067-L1072`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L1067-L1072). 10. **Memory connector has independent internal zstd compression.** Combined with the `EvmJsonRpcCache` layer's own compression pass, values stored in the memory connector may be double-compressed. Both layers detect already-compressed data by magic bytes and short-circuit. Source: [`architecture/evm/json_rpc_cache.go:L1150-L1157`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L1150-L1157). 11. **`SkipCacheRead` accepts both boolean and string values.** Both normalized to string on parse: `true` → `"true"`, `false` → `"false"`. The directive is accepted from config, the `X-ERPC-Skip-Cache-Read` HTTP header, and the `skip-cache-read` query parameter. Passing a glob pattern with an empty `connectorId` always returns false. Source: [`common/request.go:L895-914`](https://github.com/erpc/erpc/blob/main/common/request.go#L895-L914). 12. **Policy ordering matters for Get when finality = unknown.** All matching policies race in parallel; the fastest connector with data wins. Place the fastest connector first so it most likely wins. For Set, all matching policies receive the write regardless of order. 13. **`gRPC` connector is read-only.** It does not support `Set`. Supported methods: `eth_getBlockByNumber`, `eth_getBlockByHash`, `eth_getLogs`, `eth_getTransactionByHash`, `eth_getTransactionReceipt`, `eth_getBlockReceipts`, `eth_chainId`, `eth_blockNumber`. `eth_blockNumber` is derived by fetching the latest block internally. Source: [`data/grpc.go:L270-278`](https://github.com/erpc/erpc/blob/main/data/grpc.go#L270-L278). 14. **`appliesTo: "set"` without a paired `"get"` policy** writes succeed but reads always miss — useful for shadow-write debugging but rarely the right production shape. 15. **`reqRefs` with multiple conflicting block references collapse to `"*"`.** `eth_getLogs` with different `fromBlock`/`toBlock` values routes to the reverse index and the partition key becomes `*`. Users must cache only finalized data for range-query methods to avoid serving stale reorged data. Source: [`architecture/evm/block_ref.go:L296-L300`](https://github.com/erpc/erpc/blob/main/architecture/evm/block_ref.go#L296-L300). 16. **`ErrRecordNotFound` and `ErrEndpointMissingData` are semantic misses, not errors.** They must not increment `erpc_cache_get_error_total`. A regression where this happened caused 36k+ spurious errors per 15 minutes. Source: [`erpc/evm_json_rpc_cache_fanout_test.go`](https://github.com/erpc/erpc/blob/main/erpc/evm_json_rpc_cache_fanout_test.go). 17. **`database.evmJsonRpcCache: nil` completely disables the cache subsystem.** The `EvmJsonRpcCache` object is never created, saving memory and eliminating block-ref extraction overhead on every request. Source: [`common/defaults.go:L831-836`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L831-L836). 18. **Compression is auto-enabled — you cannot disable it by omitting the block.** `CacheConfig.SetDefaults` always creates `&CompressionConfig{}` and calls `SetDefaults()` on it (sets `enabled=true`). The only escape is explicitly setting `compression.enabled: false`. Source: [`common/defaults.go:L482-488`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L482-L488). 19. **Cancellation guard uses `fanCtx.Err()`, not `errors.Is(err, context.Canceled)`.** An inner failsafe wrapper can produce an opaque typed error that `errors.Is` cannot unwind. The fan-out loop trusts `fanCtx.Err()` as the authoritative cancellation signal so a losing peer's wrapped error never inflates `erpc_cache_get_error_total`. Source: [`erpc/evm_json_rpc_cache_fanout_test.go`](https://github.com/erpc/erpc/blob/main/erpc/evm_json_rpc_cache_fanout_test.go). 20. **Empty results for future (not-yet-produced) blocks are never cached, regardless of `empty` policy setting.** `emptyResultBeyondConfidence` compares the requested block number against the network's latest (or finalized) head. If the block hasn't been produced yet, the empty result is silently discarded — preventing the cache from poisoning responses for blocks that simply don't exist yet. Source: [`architecture/evm/json_rpc_cache.go:L1079-L1082`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L1079-L1082), [`architecture/evm/common.go:L55-L89`](https://github.com/erpc/erpc/blob/main/architecture/evm/common.go#L55-L89). 21. **gRPC connector's block-head timestamp is set to zero when timestamp parse fails.** This is intentional: storing a stale timestamp from a previous poll would make `CacheLatestBlockTimestamp` report a dangerously stale age. Storing 0 makes the realtime guard fail-open (accept the cached result) rather than reject based on wrong age data. Source: [`data/grpc.go:L350-L356`](https://github.com/erpc/erpc/blob/main/data/grpc.go#L350-L356). 22. **`appliesTo: "get"` enables independent write/read policies with different TTLs or finality.** Setting `appliesTo: "get"` on a policy means it will never be written to — a separate write-only policy (possibly with a different finality bucket or size limit) can populate the connector while the read-only policy controls what is served. The default `"both"` means the same policy governs both directions. 23. **Same-chain networks share partition keys unless `cacheKeySuffix` is set.** Two eRPC projects on chainId 998 with a shared Redis both write `evm:998:{blockRef}`. `X-ERPC-Skip-Cache-Read` skips the read but still writes, so the last fetch poisons the other project. Set `cacheKeySuffix` on one network (e.g. `systx` → `evm:998:systx:{blockRef}`). Colon is rejected in the suffix. A `:` inside the block ref is backslash-escaped in the partition key (`systx:foo` → `systx\:foo`) so it cannot equal suffix `systx` plus ref `foo`. Refs without `:` or `\` keep the historical key. The reverse-index wildcard (`evm:998:systx:*`) is passed explicitly by the cache layer via `CachePartitionKey`, not inferred from splitting the concrete key — so hash-keyed Gets (`eth_getTransactionByHash` / Receipt) stay isolated from the unsuffixed `evm:998:*` slot even when the block ref itself contains `:`. Memory/Redis `Delete` must use the same `WithReverseIndex` ctx as `Set`; otherwise the `rvi#` companion leaks (or, with last-colon inference, can clear the unsuffixed slot). DynamoDB/Postgres delete the main row, so the GSI/row reverse index goes with it. Source: [`common/config.go:L3022-L3028`](https://github.com/erpc/erpc/blob/main/common/config.go#L3022-L3028), [`architecture/evm/json_rpc_cache.go:L1250`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L1250), [`data/connector.go`](https://github.com/erpc/erpc/blob/main/data/connector.go), [`data/memory.go`](https://github.com/erpc/erpc/blob/main/data/memory.go), [`data/redis.go`](https://github.com/erpc/erpc/blob/main/data/redis.go). ### Observability | Metric | Type | Labels | When it fires | |---|---|---|---| | `erpc_cache_get_skipped_total` | counter | project, network, category | No matching policy found for request | | `erpc_cache_get_success_hit_total` | counter | project, network, category, connector, policy, ttl | Cache hit returned to caller | | `erpc_cache_get_success_miss_total` | counter | project, network, category, connector, policy, ttl | All connectors confirmed miss | | `erpc_cache_get_error_total` | counter | project, network, category, connector, policy, ttl, error | Connector transport/non-semantic error | | `erpc_cache_get_age_guard_reject_total` | counter | project, network, **method**, connector, policy, ttl | Block timestamp age exceeded policy TTL; only realtime requests with non-zero TTL. Label is `method` not `category` — unique among cache metrics | | `erpc_cache_get_success_hit_duration_seconds` | histogram | project, network, category, connector, policy, ttl | Latency of a cache hit | | `erpc_cache_get_success_miss_duration_seconds` | histogram | project, network, category, connector, policy, ttl | Latency of a cache miss | | `erpc_cache_get_error_duration_seconds` | histogram | project, network, category, connector, policy, ttl, error | Latency when a connector returns a transport error | | `erpc_cache_set_success_total` | counter | project, network, category, connector, policy, ttl | Connector Set succeeded | | `erpc_cache_set_error_total` | counter | project, network, category, connector, policy, ttl, error | Connector Set failed | | `erpc_cache_set_skipped_total` | counter | project, network, category, connector, policy, ttl | `shouldCacheResponse` returned false (size limit, error field, empty-ignore, future block) | | `erpc_cache_set_original_bytes_total` | counter | project, network, category, connector, policy, ttl | Cumulative uncompressed bytes before any compression attempt; emitted for every successful set | | `erpc_cache_set_compressed_bytes_total` | counter | project, network, category, connector, policy, ttl | Cumulative bytes after zstd compression. Only emitted when compression was actually applied (enabled, value ≥ threshold, compressed < original). A zero value does NOT indicate a bug — it means compression was not applied in that window | | `erpc_cache_set_success_duration_seconds` | histogram | project, network, category, connector, policy, ttl | Latency of a successful set | | `erpc_cache_set_error_duration_seconds` | histogram | project, network, category, connector, policy, ttl, error | Latency when a connector Set returns an error | | `erpc_ristretto_cache_current_cost` | gauge | connector | Memory connector current cost in bytes | | `erpc_ristretto_cache_sets_failed_total` | counter | connector | Ristretto dropped or rejected a set | | `erpc_cache_connector_earliest_block_number` | gauge | connector, network | gRPC connector earliest known block number (refreshed every 60 s) | | `erpc_cache_connector_latest_block_number` | gauge | connector, network | gRPC connector latest block number (refreshed every 60 s) | | `erpc_cache_connector_finalized_block_number` | gauge | connector, network | gRPC connector finalized block number (refreshed every 60 s) | | `erpc_cache_connector_earliest_block_timestamp_seconds` | gauge | connector, network | gRPC connector earliest block unix timestamp (refreshed every 60 s) | | `erpc_cache_connector_latest_block_timestamp_seconds` | gauge | connector, network | gRPC connector latest block unix timestamp (refreshed every 60 s) | | `erpc_cache_connector_finalized_block_timestamp_seconds` | gauge | connector, network | gRPC connector finalized block unix timestamp (refreshed every 60 s) | **OTel trace spans:** | Span | Source | |---|---| | `Cache.Get` | `architecture/evm/json_rpc_cache.go:L153` | | `Cache.FindGetPolicies` | `architecture/evm/json_rpc_cache.go:L173` | | `Cache.GetForPolicy` | `architecture/evm/json_rpc_cache.go:L241` | | `Cache.Set` | `architecture/evm/json_rpc_cache.go:L572` | | `Evm.ExtractBlockReferenceFromRequest` | `architecture/evm/block_ref.go:L18` | | `Evm.ExtractBlockTimestampFromResponse` | `architecture/evm/block_ref.go:L190` | | `Request.GenerateCacheHash` | `common/json_rpc.go:L1387` | Span attributes on `Cache.Get`: `network.id`, `request.method`, `request.finality`, `cache.policies_matched`, `cache.hit` (bool), `cache.miss_reason`, `cache.miss_connector_id`, `cache.miss_policy`. Span attributes on `Cache.GetForPolicy`: `cache.policy_summary`, `cache.connector_id`, `cache.method`, `cache.get_outcome` (`found`/`miss`/`ttl_rejected`/`empty_ignored`/`error`/`cancelled`), `cache.block_ref`, `cache.group_key`, `cache.request_key`. ### Debug log messages These `DEBUG`-level log messages appear in eRPC output and are useful for tracing cache behavior: | Message | Condition | |---|---| | `"will not cache the response because we cannot resolve a block reference"` | `blockRef` is empty on Set — method not in the cacheable set | | `"skip caching because response contains an error"` | `shouldCacheResponse` skipped due to JSON-RPC error field | | `"skip caching because response size does not match policy limits"` | result length outside `minItemSize`/`maxItemSize` | | `"skip caching empty result for a not-yet-produced (future) block"` | `emptyResultBeyondConfidence` — block number is ahead of network head | | `"rejecting cached result because block age exceeds policy TTL"` | age guard rejection in `shouldAcceptCachedResult` | | `"cached result rejected due to age exceeding TTL"` | same, logged at the Get fan-out level | | `"cache connector errored during GET"` | connector transport error (not a semantic miss) | | `"skipping cache connector due to skip-cache-read directive pattern"` | `SkipCacheRead` directive matched a connector ID glob | | `"returning cached response"` | hit served (Trace level also logs raw result) | | `"compressed cache value"` | zstd compression applied (includes original/compressed/savings) | | `"decompressed cache value"` | zstd decompression applied on read | ### Source code entry points - [`architecture/evm/json_rpc_cache.go:L153-L400`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L153-L400) — `EvmJsonRpcCache.Get`: parallel fan-out, fanCtx, 30-second backstop, winner selection - [`architecture/evm/json_rpc_cache.go:L572-L800`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L572-L800) — `EvmJsonRpcCache.Set`: parallel fan-out, 5-second hard write timeout, `shouldCacheResponse` - [`architecture/evm/json_rpc_cache.go:L841-L920`](https://github.com/erpc/erpc/blob/main/architecture/evm/json_rpc_cache.go#L841-L920) — `shouldAcceptCachedResult`: realtime age gate, block-timestamp extraction, connector-head fallback, fail-open - [`data/cache_policy.go:L1-L200`](https://github.com/erpc/erpc/blob/main/data/cache_policy.go#L1-L200) — `CachePolicy` struct; `MatchesForGet`, `MatchesForSet`, `matchParams`, `MatchesSizeLimits`, `GetTTL` - [`architecture/evm/block_ref.go:L277-L368`](https://github.com/erpc/erpc/blob/main/architecture/evm/block_ref.go#L277-L368) — `ExtractBlockReferenceFromRequest`, `ExtractBlockReferenceFromResponse`: block-ref derivation, reqRefs/respRefs path walking - [`common/defaults.go:L474-L650`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L474-L650) — `CacheConfig.SetDefaults`, `CachePolicyConfig.SetDefaults`, `CompressionConfig.SetDefaults` - [`common/request.go:L895-L914`](https://github.com/erpc/erpc/blob/main/common/request.go#L895-L914) — `ShouldSkipCacheRead`: header/query parsing, boolean vs glob logic - [`erpc/evm_json_rpc_cache_fanout_test.go`](https://github.com/erpc/erpc/blob/main/erpc/evm_json_rpc_cache_fanout_test.go) — behavior-locking tests: slow-peer non-blocking, miss-as-error classification, cancellation wrapping ### Related pages - [Storage connectors](/config/database/connectors.llms.txt) — configure the memory, Redis, DynamoDB, PostgreSQL, and gRPC backends referenced by policies. - [Rate limiters](/config/rate-limiters.llms.txt) — limit how many upstream calls the cache miss path can trigger. - [Failsafe](/config/failsafe/retry.llms.txt) — retry/timeout/circuit-breaker applied per-connector via `failsafeForGets`/`failsafeForSets`. - [Reduce RPC costs](/use-cases/reduce-rpc-costs.llms.txt) — the primary use case this feature serves. - [EVM method reference](/reference/evm/methods.llms.txt) — per-method finality classification and built-in reqRefs/respRefs defaults. --- ## Navigation (machine-readable surface) - Up: [All pages index](https://docs.erpc.cloud/llms.txt) - Root index of every page: [llms.txt](https://docs.erpc.cloud/llms.txt) · everything in one file: [llms-full.txt](https://docs.erpc.cloud/llms-full.txt) ### Sibling pages - [Storage drivers](https://docs.erpc.cloud/config/database/drivers.llms.txt) — Five interchangeable cache back-ends — memory, Redis, PostgreSQL, DynamoDB, and a read-only gRPC BDS connector — all behind one uniform interface, with optional per-operation failsafe policies that keep transient storage hiccups invisible to your upstreams. - [Goldsky cache (gRPC)](https://docs.erpc.cloud/config/database/goldsky-cache.llms.txt) — Plug Goldsky's hosted, read-only gRPC cache into eRPC — historical blocks, logs, transactions and receipts served from Goldsky's indexed chain data before your requests reach your upstreams — and Monad's MIP-16 eth_queryLogs and eth_queryTransactions served natively. - [Shared state](https://docs.erpc.cloud/config/database/shared-state.llms.txt) — Give every pod in your fleet the same real-time view of each upstream's block height — routing stays consistent as you scale out, with zero added latency on the request path. - [SVM JSON-RPC cache](https://docs.erpc.cloud/config/database/svm-json-rpc-cache.llms.txt) — Cache Solana JSON-RPC responses without ever serving a stale account balance — eRPC classifies every SVM read by Solana's own commitment rules, keys base58 params case-sensitively, and refuses to treat a moving rooted head as immutable.