/config/projects.llms.txt
Projects
A project is a tenant inside one eRPC deployment: your frontend, your indexer, and your test environment can each get their own endpoint, providers, auth rules, and rate-limit budget — served from a single process. Everything under this section configures what lives inside a project.
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: add a second project for my indexer
I want to create a second eRPC project in my eRPC config for my indexer traffic — separate upstreams, longer timeouts, and a different rate-limit budget from my frontend project. Walk me through setting up projects[].id, upstreams, networks, and auth. Also point me at the child pages I should read for each section. Reference: https://docs.erpc.cloud/config/projects.llms.txt
Prompt Example #2: understand project URL routing
Explain how eRPC routes requests to the right project based on the URL structure, and how projects[].id maps to the endpoint path. I want to know the exact URL pattern and any multi-tenant gotchas before I deploy. Work with my existing eRPC config. Reference: https://docs.erpc.cloud/config/projects.llms.txt
Projects — agent starting pointsExpand for every option, default, and edge case — or copy this entire section into your AI assistant.
This page is an orientation hub. Implementation detail lives in the child pages — fetch their machine-readable companions:
Project-level config keys: projects[].id, projects[].auth, projects[].cors,
projects[].networkDefaults, projects[].upstreamDefaults, projects[].rateLimitBudget,
projects[].healthCheck, projects[].providers, projects[].upstreams, projects[].networks,
projects[].allowClientDirectives.
Each is documented exhaustively in the child page whose topic it belongs to; multi-tenant
behavior (URL shape /{projectId}/{architecture}/{chainId}) is covered in
URL structure.
How it works
Isolation boundaries. ProjectsRegistry is built once at startup and eagerly registers every projects[] entry. Per project: a health tracker (rolling-window scorer), a providers registry, an upstreams registry, an optional auth registry (only when auth is configured), a selection-policy JS runtime, and a networks registry. Shared across projects: the rate-limiters registry, shared-state registry, the EVM JSON-RPC cache (project-scoped at network-prepare time via evmJsonRpcCache.WithProjectId(projectId)), the vendors registry, and the proxy-pool registry. Duplicate project ids fail startup with ErrProjectAlreadyExists.
Request routing (HTTP). handleRequest first applies domain aliasing: each server.aliasing.rules[] entry is matched against the request Host (port stripped) with a wildcard; the first match pre-selects projectId/architecture/chainId. Then parseUrlPath fills remaining values from path segments. A trailing /healthcheck segment, or any non-POST/non-OPTIONS method (e.g. GET), flags a healthcheck rather than an RPC call. Project resolution via erpc.GetProject — unknown id returns ErrProjectNotFound → HTTP 404.
gRPC routing. The project comes from x-erpc-project metadata (required, else InvalidArgument); chain from x-erpc-chain-id; architecture from x-erpc-architecture, defaulting to "evm". The shared RequestProcessor then performs auth + forward identically to HTTP. For query streams, AcquireRateLimitPermit is called explicitly before the query executor because streams bypass Forward.
Per-request pipeline inside a project. Each entry of a (possibly batched) request body is processed in its own goroutine: payload validation → forwardHeaders capture (wildcard match against incoming header names; matched values forwarded to upstream HTTP requests) → ignoreMethods/allowMethods gate (allow overrides ignore; rejected methods return JSON-RPC error -32601 without touching auth or upstreams) → auth payload extraction from query/headers → project.AuthenticateConsumer (no-op returning nil user when project has no auth config) → network id resolution (from path or "networkId" field in body; lazy network bootstrap on first use) → directive defaults + HTTP enrichment → project.Forward.
PreparedProject.Forward. Resolves the network (lazy-loads on first request; lazily created network configs are exposed back into project.Config.Networks via ExposeNetworkConfig), sets it on the request before rate limiting so metrics have the network label, then AcquireRateLimitPermit enforces the project-level budget. After that the "requests received" counter fires (so project-rate-limited requests are not counted as received). For EVM a pre-forward hook may short-circuit entirely (e.g. eth_chainId served statically, cache-affecting normalizations for eth_blockNumber/eth_call/eth_getLogs/trace_filter), otherwise network.Forward plus a network post-forward hook runs. If the network has shadow upstreams the response is cloned and mirrored asynchronously.
Rate-limit layering. Project-level: projects[].rateLimitBudget names a budget in global rateLimiters.budgets[]; AcquireRateLimitPermit evaluates rules matching the method and calls TryAcquirePermit(... origin="project"); over-limit → ErrProjectRateLimitRuleExceeded → HTTP 429. Auth-level: each auth strategy may carry its own rateLimitBudget, overridable per-user (JWT claim / database record); checked inside Authenticate after a strategy succeeds, over-limit → ErrAuthRateLimitRuleExceeded → 429. Network- and upstream-level budgets are separate layers. All layers fail open: no store configured, Redis not yet connected, Redis timeout, or admission-semaphore saturation all allow the request.
CORS. Two CORS surfaces: admin (admin.cors, checked before admin request handling) and per-project (projects[].cors, checked right after project resolution). handleCORS: if the request carries no Origin header it is always allowed (no point enforcing CORS for non-browser clients — eRPC treats CORS as a soft deterrent, not a security boundary). With an Origin present, each allowedOrigins pattern is evaluated with a wildcard/boolean match. Allowed origin → echo the origin in Access-Control-Allow-Origin plus methods/headers/expose/credentials/max-age headers; OPTIONS preflight then terminates with 204. Disallowed origin → no CORS headers but the request still proceeds (browser blocks it; curl etc. succeed), except OPTIONS which gets a bare 204. CORS for a project only runs when cors is configured (nil → OPTIONS falls through to the JSON-RPC body parser).
Auth payload extraction precedence (HTTP). ?token= (deprecated, highest priority) → ?secret= → header X-ERPC-Secret-Token → Authorization: Basic … (password half = secret; username ignored) → Authorization: Bearer … (JWT) → ?jwt= → ?signature=&message= (SIWE; message may be base64) → headers X-Siwe-Message + X-Siwe-Signature → fallback type network (client-IP based).
Consumer authentication. AuthRegistry.Authenticate: nil payload → 401; zero strategies → allow (nil user). Iterate strategies in order; skip non-applicable (method filter / unsupported payload type); first success attaches a User to the request and enforces the strategy's rate-limit budget. If every applicable strategy errors, errors are joined into one ErrAuthUnauthorized. Auth rate-limit denial still returns the authenticated user alongside the 429 error, so user labels remain populated on rate-limit metrics.
Admin RPCs. erpc_project(params: [projectId]) → {config, health}; erpc_taxonomy → all projects/networks/upstreams with aliases; API-key CRUD resolves the project's database-auth connector; cordon RPCs resolve upstreams through the project registry. Admin auth itself is global (admin.auth), not per-project; requests to an instance without admin config return ErrAuthUnauthorized "admin is not enabled for this project".
Healthcheck and projects. No project in path → all projects evaluated; a project with zero initialized upstreams lazily prepares upstreams for its static networks before evaluating; providers-only projects with no initialized upstreams report healthy with an explanatory message.
HTTP status code mapping. 400 invalid path/unmarshal/invalid request; 401 ErrAuthUnauthorized; 404 ErrProjectNotFound/ErrNetworkNotFound; 429 ErrAuthRateLimitRuleExceeded/ErrProjectRateLimitRuleExceeded/ErrNetworkRateLimitRuleExceeded/capacity; everything else 200 (JSON-RPC app errors). The top-level fatal-error writer forces 200 for POST per JSON-RPC convention.
Wildcard/boolean pattern grammar. Used by CORS origins, aliasing domains, forwardHeaders, ignoreMethods/allowMethods, and rate-limit rule methods. Tokens: | (OR), & (AND), ! (NOT), parentheses. Leaf patterns are glob matches where * = any sequence, ? = exactly one char, . = any single character (not a literal dot). Operator precedence: NOT > AND > OR. For values starting with 0x, prefixes >=, <=, >, <, = enable hex/decimal numeric comparison. No escape syntax exists.
Config schema
projects[] — ProjectConfig
| Dotted path | Type | Default | Behavior / interactions |
|---|---|---|---|
projects | []*ProjectConfig | If empty/nil: a default project id: "main" is injected with server.aliasing.rules: [{matchDomain: "*", serveProject: "main"}] so /evm/123 works without a project segment. The injected project carries opinionated networkDefaults (getLogsMaxAllowedRange: 30000, getLogsSplitOnError: true, retry 5 attempts, timeout 120 s, hedge quantile 0.7 max 2) and upstreamDefaults (getLogsAutoSplittingRangeThreshold: 5000, retry 1 attempt delay 500 ms, timeout 60 s). Validation requires projects to be non-nil after defaulting. | Config root |
projects[].id | string | — (required) | Used as the project label on nearly every metric, in URL path routing, gRPC x-erpc-project, admin RPC params. Uniqueness enforced at registration: duplicate → ErrProjectAlreadyExists. |
projects[].auth | *AuthConfig | nil (no auth → all requests allowed; AuthenticateConsumer returns (nil, nil)) | When set, an auth registry is built at registration. Requires ≥1 strategy. |
projects[].cors | *CORSConfig | nil (no CORS handling at all; OPTIONS not short-circuited) | Checked after project resolution. |
projects[].providers | []*ProviderConfig | If both providers and upstreams are empty: two default providers are injected: {id: "public", vendor: "repository"} and {id: "envio", vendor: "envio"}. | Unique ids required. Per-project providers registry. |
projects[].upstreamDefaults | *UpstreamConfig | nil | Applied to every upstream via ApplyDefaults before that upstream's own SetDefaults; also passed to the providers registry. |
projects[].upstreams | []*UpstreamConfig | nil | Shorthand non-http(s)/grpc endpoints (e.g. alchemy://KEY) are converted into providers and removed from the list at SetDefaults time. Requires ≥1 of upstreams/providers. Unique ids required. |
projects[].networkDefaults | *NetworkDefaults | nil | Applied to statically defined networks at SetDefaults and to lazy-loaded networks at first request. Its own rateLimitBudget is network-level, distinct from project-level. |
projects[].networks | []*NetworkConfig | nil | Networks not listed are lazily created on first request and the resulting config is appended back into Config.Networks (visible via admin erpc_project). Unique network ids and unique aliases required. |
projects[].rateLimitBudget | string | "" (no project-level limiting) | Names a budget id under global rateLimiters.budgets[]. Enforced per request in AcquireRateLimitPermit. Must exist in rateLimiters — unknown budget fails startup. |
projects[].userAgentMode | "simplified" | "raw" | "" → treated as simplified at request time | simplified buckets the User-Agent into ~20 low-cardinality names (curl, viem, ethers, chrome, …); raw stores it verbatim (high metric cardinality). Used for the agent_name metric label. Query param ?user-agent= takes precedence over the header. |
projects[].forwardHeaders | []string (wildcard patterns) | nil (forward nothing) | Each pattern is wildcard-matched against every incoming header name; matches are forwarded to the upstream HTTP request. FOOTGUN: values are stored under the pattern key, so a wildcard pattern like X-Custom-* forwards the value under the literal header name X-Custom-*, not the original header name. Exact names work as expected. |
projects[].allowClientDirectives | *string (wildcard pattern) | nil (all directives allowed) | Controls which X-ERPC-* request directives (headers and query params) clients may use. The pattern is pre-compiled at project registration and evaluated against each directive's query-param key (e.g. skip-cache-read, use-upstream, skip-consensus) using the standard wildcard/boolean grammar. nil/omitted = all allowed (backward-compatible). "" = none allowed. "!skip-cache-read & !use-upstream" = all except those two. Config-set directiveDefaults always apply regardless of this filter. Does not filter X-ERPC-Force-Trace (which bypasses OTel sampling at span creation, before project resolution). Overridable per caller via projects[].auth.strategies[].allowClientDirectives. |
projects[].auth.strategies[].allowClientDirectives | *string (wildcard pattern) | nil (inherit project value) | Per-caller override of the project-level pattern, applied to users authenticated by this strategy. Same syntax and same "" = none / "*" = all semantics. Overrides in both directions: a strategy may widen (project "" + strategy "*") or narrow (project "*" + strategy "") — the project value is a default, not a ceiling. Typical shape is deny-by-default at the project and re-grant to trusted credentials. The capability is attached to the User by the authenticating strategy, so a caller identified only through trustUserIdHeader always falls back to the project value — an unvalidated identity header can never widen directive access. |
projects[].ignoreMethods | []string (wildcard) | nil (nothing ignored) | If any pattern matches the JSON-RPC method, the method is rejected — unless re-allowed by allowMethods. Rejection is JSON-RPC error code: -32601 ("method not supported: X") returned without auth/upstream work. |
projects[].allowMethods | []string (wildcard) | nil | Overrides ignoreMethods (e.g. ignoreMethods: ["*"] + allowMethods: ["eth_getLogs"] = only eth_getLogs). NOTE: allowMethods alone does NOT create an allowlist — a method matching neither list is still served (initial shouldHandleMethod = true). |
projects[].scoreMetricsWindowSize | Duration | 0 → falls back to 1 minute at runtime | Rolling window of the per-upstream health tracker (10 sliding buckets). FOOTGUN: source-code comments in two places say "10m" but the actual code value is var ScoreMetricsWindowSize = 1 * time.Minute. To get a 10-minute window you must set scoreMetricsWindowSize: 10m explicitly. See source (opens in a new tab). |
projects[].cors.* / admin.cors.* — CORSConfig
| Dotted path | Type | Default (after SetDefaults) | Behavior |
|---|---|---|---|
cors.allowedOrigins | []string | ["*"] programmatically — but Validate() errors if the field is missing/empty for explicit cors: blocks: *.cors.allowedOrigins is required. | Each entry is a full wildcard/boolean pattern matched against the Origin header value. Match errors are logged and that pattern skipped. |
cors.allowedMethods | []string | ["GET","POST","OPTIONS"] | Joined with ", " into Access-Control-Allow-Methods. Informational for the browser; no server-side enforcement. |
cors.allowedHeaders | []string | ["content-type","authorization","x-erpc-secret-token"] | Joined into Access-Control-Allow-Headers. |
cors.exposedHeaders | []string | nil (no default) | Joined into Access-Control-Expose-Headers. When nil (the common default), strings.Join(nil, ", ") returns "", so the server sends Access-Control-Expose-Headers: "". Browsers interpret this as zero extra headers exposed — any frontend JS call to response.headers.get('X-ERPC-Cache') returns null. eRPC emits rich diagnostic response headers (X-ERPC-Cache, X-ERPC-Upstream, X-ERPC-Duration, X-ERPC-Attempts, X-ERPC-Upstream-Attempts, X-ERPC-Upstream-Retries, X-ERPC-Upstream-Hedges, X-ERPC-Network-Attempts, X-ERPC-Network-Retries, X-ERPC-Network-Hedges, X-ERPC-Consensus-Slots, X-ERPC-Consensus-Disputes, X-ERPC-Consensus-Low-Participants, X-ERPC-Cache-Attempts, X-ERPC-Cache-Retries, X-ERPC-Cache-Hedges) — browser clients cannot read any of these without an explicit exposedHeaders list. Set e.g. exposedHeaders: ["X-ERPC-Cache", "X-ERPC-Upstream", "X-ERPC-Duration", "X-ERPC-Attempts"]. There is no wildcard shorthand accepted by current browsers. |
cors.allowCredentials | *bool | false | Only when explicitly true is Access-Control-Allow-Credentials: true emitted. |
cors.maxAge | int (seconds) | 3600 if 0 | Emitted as Access-Control-Max-Age only when > 0. |
admin.cors | *CORSConfig | If admin section exists and cors is nil: {allowedOrigins: ["*"], allowCredentials: false} then SetDefaults (safe because admin requires secret-token auth). | Applied to /admin before any admin handling. |
projects[].auth.* — AuthConfig
| Dotted path | Type | Default | Behavior |
|---|---|---|---|
auth.strategies | []*AuthStrategyConfig | — (required, ≥1) | Evaluated in declaration order; first strategy that applies to the method, supports the extracted payload type, and authenticates successfully wins. |
auth.strategies[].type | "secret" | "jwt" | "siwe" | "network" | "database" | — (required; unknown type → startup ErrInvalidConfig) | Selects the strategy object; the matching sub-config must be present or startup fails. |
auth.strategies[].ignoreMethods | []string (wildcard) | nil | Strategy skipped for matching methods. |
auth.strategies[].allowMethods | []string (wildcard) | nil | Re-applies the strategy even when ignored (allow > ignore). |
auth.strategies[].rateLimitBudget | string | "" (no auth-level limiting) | Applied AFTER successful authentication. Per-user override User.RateLimitBudget (from secret.rateLimitBudget, siwe.rateLimitBudget, network.rateLimitBudget, JWT claim named by jwt.rateLimitBudgetClaimName (default "rlm"), or database record) takes precedence. Over-limit → ErrAuthRateLimitRuleExceeded (429). TryAcquirePermit is called with origin = "auth". |
auth.strategies[].database.connector.id | string | — | Must be unique across database strategies in one project; referenced by admin API-key RPCs (erpc_addApiKey etc.). |
server.aliasing.* — AliasingConfig
| Dotted path | Type | Default | Behavior |
|---|---|---|---|
server.aliasing.rules | []*AliasingRuleConfig | nil | First rule whose matchDomain wildcard-matches the request Host (port stripped) wins; later rules ignored. Match errors are logged and the rule skipped. |
server.aliasing.rules[].matchDomain | string (wildcard) | — | e.g. "*", "eth.mycompany.com". |
server.aliasing.rules[].serveProject | string | "" | Pre-selects projectId; path may still override with an explicit 3-segment path. |
server.aliasing.rules[].serveArchitecture | string | "" | Pre-selects architecture ("evm" or "svm"). |
server.aliasing.rules[].serveChain | string | "" | Pre-selects the chain identifier — the numeric chainId for EVM, the cluster (mainnet-beta, or <chain>:<cluster> for a fork) for SVM. Combination project+chain WITHOUT architecture is rejected: "it is not possible to alias for project and chain WITHOUT architecture". |
rateLimiters.budgets[].* — global rate-limit budget config
| Dotted path | Type | Default | Behavior |
|---|---|---|---|
rateLimiters.budgets[].id | string | — | Referenced by projects[].rateLimitBudget, auth.strategies[].rateLimitBudget, network/upstream budgets. |
rateLimiters.budgets[].rules[].method | string (wildcard) | "*" if empty | A rule applies when the method matches (wildcard). |
rateLimiters.budgets[].rules[].maxCount | uint32 | — | Fixed-window limit per period. |
rateLimiters.budgets[].rules[].period | second | minute | hour | day | week | month | year | second if invalid; YAML accepts names, ints 0–6, or duration aliases ("1s", "24h", "7d", …) | Fixed window unit. |
rateLimiters.budgets[].rules[].perIP / perUser / perNetwork | bool | false | Adds ip/user/network descriptor entries so counters are scoped per client IP / authenticated user id / network id. |
rateLimiters.budgets[].rules[].waitTime | Duration | 0 | Present in config; not consumed in the project-level path. |
Legacy project fields (YAML-only)
These fields are read at load time, converted or warned, then erased. They are never visible after LoadConfig.
| Field | Type | Behavior |
|---|---|---|
routingStrategy | string | Semantic — triggers eval synthesis. "round-robin" → synthesizes rotateBy(ctx.tickCount) per network. Any other non-empty value → synthesizes sortByScore + stickyPrimary. Deprecation warning emitted. |
scoreSwitchHysteresis | float64 | Semantic — triggers eval synthesis even without routingStrategy. Non-zero → synthesizes sortByScore + stickyPrimary eval with stickyPrimary({ hysteresis: <value> }). Zero but another semantic field present → defaults to 0.10. |
scoreMinSwitchInterval | Duration | Semantic — triggers eval synthesis. Non-zero → synthesizes with stickyPrimary({ ..., minSwitchInterval: '<duration>' }). Zero but another semantic field present → defaults to '30s'. |
scoreGranularity | string | Inert — warning only, no behavior change. Previously controlled per-upstream vs per-method scoring. Replaced by selectionPolicy.evalPerMethod. |
scorePenaltyDecayRate | float64 | Inert — warning only, no behavior change. Previously modulated penalty recovery. No equivalent exists. |
scoreMetricsMode | string | Inert — warning only, no behavior change. Previously configured metrics aggregation. Replaced by erpc_selection_* metrics with fixed cardinality. |
scoreRefreshInterval | Duration | Inert — warning only, no behavior change. Previously set score-recalculation polling interval. Replaced by selectionPolicy.evalInterval. |
Legacy translation mechanics. ProjectConfig.UnmarshalYAML captures all seven legacy fields. LoadConfig calls legacy.TranslateFromConfig before SetDefaults and Validate. Semantic fields present → per-network selectionPolicy.evalFunc synthesized UNLESS the network already has an explicit selectionPolicy.eval (modern fields always win). Inert fields only → warnings emitted, canonical default policy installs. After translation, LegacyProject is nil on every ProjectConfig — any code path that accesses it after LoadConfig always sees nil. Migration: remove all seven legacy fields and write an explicit selectionPolicy.eval using the chainable stdlib.
URL path → (project, architecture, chain) resolution
parseUrlPath cleans and splits the path; preselected values come from aliasing. Decision matrix (P=project, A=architecture, C=chain preselected):
- None preselected: 1 seg → P; 2 segs → P + (network-alias resolve of seg2, else A); 3 segs → P/A/C; 0 segs → valid for healthcheck only; >3 → error
must only provide /<project>/<architecture>/<chainId>. - P preselected: 1 seg → alias resolve else A; 2 segs → A/C; 3 segs → full explicit override.
- P+A preselected: 1 seg → alias resolve (must yield both) else C; 3 segs → full override.
- P+A+C preselected: only
/(or healthcheck) allowed; >1 seg → error. - A+C preselected: 1 seg → P; 3 segs → full override.
- A only preselected: 1 seg → P; 2 segs → P/C; 3 segs → full override; 0 segs allowed for global healthcheck.
- P+C without A → error ("it is not possible to alias for project and chain WITHOUT architecture").
Post-checks: project required unless healthcheck; if A or C present, A must be "evm"; any non-POST/non-OPTIONS method becomes a healthcheck. /admin only with exactly one segment + POST/OPTIONS.
Network alias resolution. Aliases are registered eagerly for static networks and lazily when a network is first prepared. ResolveAlias(alias) returns (architecture, chainId) or ("",""). Duplicate alias with a different target keeps the first and logs a warning. Network ids must look like evm:<int>; alias misses in path-position fall through to being treated as an architecture, producing architecture is not valid for bogus aliases. If both path and body lack a network, the body's "networkId" field (e.g. "evm:42161") is consulted; otherwise: architecture and chain must be provided in URL ... or in request body ... or configured via domain aliasing.
Observability
All metrics use the erpc_ namespace.
| Metric | Labels | Notes |
|---|---|---|
erpc_cors_requests_total | project, origin | Every request that carries an Origin header, before allow/deny. NOTE: the project label value is actually r.URL.Path, not the project id — cardinality grows with distinct request paths. |
erpc_cors_preflight_requests_total | project, origin | Allowed-origin OPTIONS preflights only. Same path-as-project label caveat. |
erpc_cors_disallowed_origin_total | project, origin | Origin present but no pattern matched. Same path-as-project label caveat. |
erpc_network_request_received_total | project, network, category, finality, user, agent_name | Fired in Project.Forward after project rate limiting — project-rate-limited requests are not counted here. |
erpc_network_successful_request_total | project, network, vendor, upstream, category, attempt, finality, emptyish, user, agent_name | Vendor/upstream = "<cache>" for cache hits, "n/a" when upstream missing. |
erpc_network_failed_request_total | project, network, category, attempt, error, severity, finality, user, agent_name | error = common.ErrorFingerprint(err). |
erpc_network_request_duration_seconds | project, network, vendor, upstream, category, finality, user | Histogram. vendor/upstream = "<error>" on failure. |
erpc_rate_limits_total | project, network, vendor, upstream, category, finality, user, agent_name, budget, scope, auth, origin | Unified deny counter. Project layer: origin="project", auth="". Auth layer: origin="auth", auth="<type>:<index>". |
erpc_rate_limiter_budget_max_count | budget, method, scope | Gauge. |
erpc_rate_limiter_failopen_total | project, network, user, agent_name, budget, category, reason | Reasons: admission_full / limit_timeout. Monitor this to detect hard-enforcement gaps. |
erpc_auth_failed_total | project, network, strategy, reason, agent_name | Incremented inside auth strategies. |
erpc_shadow_response_identical_total | — | Shadow traffic comparison outcomes. |
erpc_shadow_response_mismatch_total | — | Mismatches log at error level with both hashes. |
erpc_shadow_response_error_total | — | Shadow request error outcomes. |
erpc_network_evm_block_range_requested_total | — | Block-range heatmap; emitted when a block number is extractable from the response. |
Trace spans: Project.Forward (detail span), Project.PreForwardHook, RateLimiter.TryAcquirePermit (detail) and RateLimiter.DoLimit (client span with budget, method, scope, result attrs), Project.executeShadowRequest, and a request-level span via common.StartRequestSpan.
Notable logs: registered project <id> info at registration; successfully forwarded request for network (info); CORS request from disallowed origin, continuing without CORS headers (debug); skipping duplicate alias registration with different target (warn); no projects found in config; will add a default 'main' project (warn); no providers or upstreams found in project; will use default 'public' endpoints repository (warn).
Edge cases & gotchas
- Requests without an
Originheader bypass CORS entirely — by design; eRPC treats CORS as a soft deterrent, not a security boundary. - Disallowed origins are NOT rejected: non-OPTIONS requests proceed and get a normal JSON-RPC response, just without
Access-Control-*headers; only the browser enforces blocking. OPTIONS gets a bare 204. .matches any single character in glob patterns (go-wildcard v2):allowedOrigins: ["https://erpc.cloud"]also matcheshttps://erpcXcloud. Same applies to aliasingmatchDomainand all wildcard fields. There is no escape syntax.forwardHeadersstores values under the configured pattern, not the matched header name — a wildcard pattern forwards upstream a literal header named like the pattern (e.g.X-Custom-*). Only exact-name patterns round-trip faithfully.- Project rate-limited requests never increment
erpc_network_request_received_totalbecauseAcquireRateLimitPermitruns before the counter; useerrc_rate_limits_total{origin="project"}for those. scoreMetricsWindowSizestale source-code comments: two comments in the codebase say "10 minutes" (common/config.go:530,erpc/projects_registry.go:131-132), but the actual package-level variable isvar ScoreMetricsWindowSize = 1 * time.Minute(source (opens in a new tab)). If you omitscoreMetricsWindowSizeexpecting a stable 10 m default, you silently get 1 m: the rolling window rotates every 6 seconds, upstream scoring reacts 10× faster than expected. SetscoreMetricsWindowSize: 10mexplicitly to get 10 minutes.- All rate limiting fails open: no store configured, Redis still connecting, Redis call timeout (
limit_timeout), or admission semaphore full (admission_full). Deployments needing hard guarantees must monitorerpc_rate_limiter_failopen_total. allowMethodsis not an allowlist by itself —shouldHandleMethodstartstrue; withoutignoreMethodseverything is served regardless ofallowMethods. To build an allowlist:ignoreMethods: ["*"]+allowMethods: [...].- Unsupported-method responses normally carry
id: nulldue to an inverted error check before reading the request id in the error path. GET /myproject/evm/123(or any non-POST/non-OPTIONS method) is a healthcheck, not an RPC error.- OPTIONS to a project without
corsconfigured is not short-circuited — the early-return lives inside theproject.Config.CORS != nilbranch, so the OPTIONS request falls into JSON-RPC body parsing and errors. - CORS for a project can only run after project resolution succeeds: unknown project → 404 with no CORS headers, which browsers may surface as a CORS failure rather than 404.
- Lazy-loaded networks inherit
networkDefaultsand are appended intoproject.Config.Networksat runtime (visible via adminerpc_project). - First aliasing rule wins; rule-match errors are logged and skipped. An explicit full 3-segment path can always override aliased preselection.
- Network aliases are per-project; a duplicate alias targeting a different chain is ignored with a warning, keeping the first registration. Aliases must be unique within a project's static config.
- Zero-project configs still validate because
SetDefaultsinjects themainproject beforeValidateruns. - Project rate-limit test is fixed-window, not sliding — rate-limit rules apply to a fixed time window; requests over the limit in the current window are denied regardless of past windows.
projects[].rateLimitBudgetreferencing an unknown budget fails startup — but auth-strategy budgets and per-user (database/JWT-claim) budgets are resolved at request time and surface as runtimeErrRateLimitBudgetNotFound.- CORS metrics label
projectactually contains the URL path, so cardinality grows with distinct request paths and you cannot group by real project id from these counters alone. - Secret auth via
Authorization: Basicuses only the password half; the username is ignored. The deprecated?token=query param still works and takes top precedence. - Auth rate-limit denial still returns the authenticated user alongside the 429 error, so
userlabels remain populated on rate-limit metrics. exposedHeaders: nilsilently blocks browser access to allX-ERPC-*headers.strings.Join(nil, ", ")returns"", so the server sendsAccess-Control-Expose-Headers: "". Enumerate desired headers explicitly — no glob wildcard shorthand is accepted by current browsers.- Legacy scoring fields are consumed and erased before
SetDefaults— afterTranslateFromConfigruns,LegacyProjectis nil on everyProjectConfig. Admin RPCs and health endpoints always see nil for this field. - Inert legacy fields (
scoreGranularity,scorePenaltyDecayRate,scoreMetricsMode,scoreRefreshInterval) do NOT synthesize an eval. A config with only these four fields gets deprecation warnings but retains the full canonical default selection policy (removeCordoned + keepHealthy + preferTag + sortByScore + stickyPrimary + probeExcluded). routingStrategy: "score-based"(or any non-"round-robin"non-empty string) synthesizes asortByScore + stickyPrimaryeval. A per-networkselectionPolicy.eval(new field) always wins over the synthesized legacy eval.scoreSwitchHysteresisandscoreMinSwitchIntervalare semantic fields that trigger eval synthesis even withoutroutingStrategy. If either is non-zero, asortByScore + stickyPrimaryeval is synthesized across all networks.allowClientDirectivesdoes not filterX-ERPC-Force-Trace— force-trace bypasses OTel sampling at span creation inStartHTTPServerSpan, which runs before project resolution. Filtering it requires deferring the sampling decision until after project resolution.allowClientDirectivesdoes not affectdirectiveDefaults— config-set directive defaults (vianetworks[].directiveDefaults) always apply regardless of the client directive filter. The filter only gates directives arriving via HTTP headers or query parameters.
Source code entry points
erpc/projects.go(opens in a new tab) —PreparedProject: per-tenant facade;Forward(metrics, shadow fan-out),AcquireRateLimitPermit(project budget),AuthenticateConsumer, lazy network-config exposure, health info.erpc/projects_registry.go(opens in a new tab) —ProjectsRegistry: builds/boots all projects; per-project tracker/policy-engine/auth/upstreams/networks wiring;ScoreMetricsWindowSizefallback var.erpc/erpc.go(opens in a new tab) —ERPCroot object: constructs shared registries and the projects registry; admin auth registry.erpc/http_server.go(opens in a new tab) — HTTP entry: aliasing,parseUrlPath,handleCORS, per-request pipeline (forwardHeaders, method gates, auth, forward), status-code mapping.erpc/request_processor.go(opens in a new tab) — shared gRPC/query-stream pipeline.erpc/grpc_server.go(opens in a new tab) — gRPC project selection viax-erpc-project/x-erpc-chain-idmetadata.erpc/networks_registry.go(opens in a new tab) — per-project network lifecycle + alias registry; project-scoped cache binding.erpc/admin.go(opens in a new tab) —erpc_project,erpc_taxonomy, API-key CRUD, cordon RPCs.erpc/healthcheck.go(opens in a new tab) — per-project/per-network health evaluation.erpc/shadow.go(opens in a new tab) — project-layer shadow request execution/comparison.erpc/block_heatmap.go(opens in a new tab) — per-project block-range heatmap metric emission.common/config.go(opens in a new tab) —ProjectConfig,CORSConfig,AuthConfig/strategy configs,AliasingConfig, rate-limiter config types,UserAgentTrackingMode, legacy project fields.common/defaults.go(opens in a new tab) — defaultmainproject injection,ProjectConfig.SetDefaults,CORSConfig.SetDefaults.common/validation.go(opens in a new tab) —Config.Validate/ProjectConfig.Validate.common/matcher.go(opens in a new tab) —WildcardMatchboolean/glob grammar.common/errors.go(opens in a new tab) —ErrProjectNotFound(404),ErrProjectAlreadyExists,ErrProjectRateLimitRuleExceeded(429),ErrAuthUnauthorized(401),ErrAuthRateLimitRuleExceeded(429).auth/registry.go(opens in a new tab),auth/authorizer.go(opens in a new tab),auth/http.go(opens in a new tab) — per-project auth strategy iteration, method filters, auth-level budgets, HTTP payload extraction.upstream/ratelimiter_registry.go(opens in a new tab),upstream/ratelimiter_budget.go(opens in a new tab) — global budget store, Envoy-based fixed-window evaluation, fail-open machinery.telemetry/metrics.go(opens in a new tab) — CORS counters, network request counters/histograms, unified rate-limit counter, auth/shadow metrics.common/legacy/translate.go(opens in a new tab) —TranslateFromConfighook: walks all projects, classifies legacy fields, synthesizesselectionPolicy.evalFunc, clears stashes.common/legacy/eval_synthesis.go(opens in a new tab) — JS source generators for score-based and round-robin legacy policies.common/legacy/warnings.go(opens in a new tab) — per-field deprecation warning strings.- Tests:
erpc/projects_test.go(opens in a new tab) (project rate limit, lazy networks, aliases),erpc/http_server_test.go(opens in a new tab) (CORS, URL-path matrix).