Summary
The failover system in internal/rpc/failover.go can get stuck routing every request to a single broken provider when that provider returns an error message not listed in analyzeError's pattern table. Observed in production: Ethereum Sepolia (and reportedly Base) indexing stalled for an extended period while the failover never switched away from a provider returning RPC error -32701.
Incident logs (Ethereum Sepolia, 2026-05-23)
WRN Switching provider due to error provider=ethereum_sepolia-2 error_type=connection_error blacklist_duration=2m0s
... emergency recovery picks sepolia-2 back up ...
DBG Provider failed but not switching provider=ethereum_sepolia-2 state=healthy
error="rpc error: RPC error -32701: Please specify an address in your request
or, to remove restrictions, order a dedicated full node here..."
consecutive_errors=0
DBG Provider failed but not switching ... state=healthy consecutive_errors=1
DBG Provider failed but not switching ... state=degraded consecutive_errors=2
WRN failed to query ERC20 transfers error="failed after 3 attempts: rpc error: RPC error -32701..."
... loop repeats, no switch ever happens ...
Root cause
analyzeError (internal/rpc/failover.go:589) only marks a provider unhealthy when the error string matches a hand-maintained pattern table (rate limit, 403, timeout, connection reset, -32007, etc.). The publicnode -32701 error matches none of these, so it falls through to:
issue := ProviderIssue{
Reason: "generic_error",
Cooldown: 0,
MarkUnhealthy: false,
}
With MarkUnhealthy=false, executeCore (failover.go:387-391) takes the handleProviderFailure branch — provider is not blacklisted, only provider.Fail() is called.
The follow-on stuck behavior is structural:
provider.Fail() only escalates state to Unhealthy after ErrorThreshold (default 5) consecutive errors.
Provider.IsAvailable() (internal/rpc/provider.go:27-31) returns true for every non-blacklisted state — including Unhealthy.
GetBestProvider returns the current provider as long as IsAvailable() is true, so currentIndex never moves.
- The retry wrapper retries 3x via
retry.Constant, all 3 attempts hit the same provider, then bubbles failed after 3 attempts to the worker.
- Sibling providers (e.g.
ethereum_sepolia-1) are never tried even when healthy.
Result: any error class not in the pattern table can cause an indefinite indexing stall on the affected chain.
Why this will keep recurring
The current design relies on a hand-maintained allowlist of error strings to decide whether to switch providers. New providers, new error codes, or changes in upstream error message text will silently re-create this incident. The pattern table is currently load-bearing for routing safety, but it has no feedback loop to catch unknown errors before they cause an outage.
Proposed fixes
1. Forced rotation on consecutive errors (recommended primary fix)
In GetBestProvider (or in the retry path), skip the current provider when consecutiveErrors >= N (e.g., 3) even if it's not blacklisted. This guarantees that N retries against the same broken endpoint can never happen, regardless of error classification.
Small, contained change in failover.go — no changes to analyzeError's classification logic.
2. Loud logging of unknown error types
When analyzeError falls through to the generic_error branch, log at WARN with the full error message and increment a dedicated unknown_error_type metric. Gives an early signal to extend the pattern table before the next outage.
Summary
The failover system in
internal/rpc/failover.gocan get stuck routing every request to a single broken provider when that provider returns an error message not listed inanalyzeError's pattern table. Observed in production: Ethereum Sepolia (and reportedly Base) indexing stalled for an extended period while the failover never switched away from a provider returningRPC error -32701.Incident logs (Ethereum Sepolia, 2026-05-23)
Root cause
analyzeError(internal/rpc/failover.go:589) only marks a provider unhealthy when the error string matches a hand-maintained pattern table (rate limit,403,timeout,connection reset,-32007, etc.). The publicnode-32701error matches none of these, so it falls through to:With
MarkUnhealthy=false,executeCore(failover.go:387-391) takes thehandleProviderFailurebranch — provider is not blacklisted, onlyprovider.Fail()is called.The follow-on stuck behavior is structural:
provider.Fail()only escalates state toUnhealthyafterErrorThreshold(default 5) consecutive errors.Provider.IsAvailable()(internal/rpc/provider.go:27-31) returns true for every non-blacklisted state — includingUnhealthy.GetBestProviderreturns the current provider as long asIsAvailable()is true, socurrentIndexnever moves.retry.Constant, all 3 attempts hit the same provider, then bubblesfailed after 3 attemptsto the worker.ethereum_sepolia-1) are never tried even when healthy.Result: any error class not in the pattern table can cause an indefinite indexing stall on the affected chain.
Why this will keep recurring
The current design relies on a hand-maintained allowlist of error strings to decide whether to switch providers. New providers, new error codes, or changes in upstream error message text will silently re-create this incident. The pattern table is currently load-bearing for routing safety, but it has no feedback loop to catch unknown errors before they cause an outage.
Proposed fixes
1. Forced rotation on consecutive errors (recommended primary fix)
In
GetBestProvider(or in the retry path), skip the current provider whenconsecutiveErrors >= N(e.g., 3) even if it's not blacklisted. This guarantees that N retries against the same broken endpoint can never happen, regardless of error classification.Small, contained change in
failover.go— no changes toanalyzeError's classification logic.2. Loud logging of unknown error types
When
analyzeErrorfalls through to thegeneric_errorbranch, log atWARNwith the full error message and increment a dedicatedunknown_error_typemetric. Gives an early signal to extend the pattern table before the next outage.