# 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}` and a range key `{method}:{sha256(params)}`. 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, static-delay hedge, and circuit-breaker are supported. Consensus and hedge quantile mode are 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`. 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) | #### `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. Consensus and hedge-quantile are not supported at this scope. Source: [`data/cache_executor.go:L31-38`](https://github.com/erpc/erpc/blob/main/data/cache_executor.go#L31-L38) | | `connectors[*].failsafeForSets` | `[]*FailsafeConfig` | nil | Per-connector failsafe for Set operations. Same restrictions 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 static-delay hedge and per-call timeout.** When the primary cache backend is a remote gRPC server (BDS read-through), add a short static hedge so a slow node doesn't hold up the request — note that `quantile` mode is rejected at connector scope, so the delay must be static. 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: # Static: quantile mode is not supported at connector scope delay: 100ms 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: { // Static: quantile mode is not supported at connector scope delay: "100ms", 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}`. 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. - **`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. ### 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. - [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.