fix(l1): retry transient chain ID errors instead of shutting down the node#3771
Conversation
There was a problem hiding this comment.
Pull request overview
This PR changes the L1 client’s startup chain-ID verification so transient eth_chainId failures (rate limits/timeouts/unresponsive RPC) are retried instead of returning an error that shuts down the entire node, while keeping genuine L1/L2 network mismatches fatal.
Changes:
- Add
verifyChainIDwith an unbounded retry loop for transient chain-ID probe failures and a typed fatalchainIDMismatchError. - Make
checkChainIDsingle-attempt and updateRunto callverifyChainID(whileCatchUpL1Headcontinues to usecheckChainIDfor fail-fast behavior). - Update/add tests to validate retry-on-transient behavior and keep fail-fast assertions on the
CatchUpL1Headpath.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| l1/l1.go | Introduces retrying chain-ID verification in Run, keeps mismatches fatal via a typed error, and makes checkChainID single-shot for migration path. |
| l1/l1_test.go | Re-targets existing chain-ID failure tests to CatchUpL1Head and adds a regression test ensuring transient chain-ID errors don’t shut down the node. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… node The L1 client ran as a node service, and node.StartService brings the whole node down whenever a service's Run returns. checkChainID returned transient RPC failures (rate limits, timeouts, an unresponsive endpoint) straight out of Run, so a single eth_chainId hiccup on a free-tier provider — e.g. "daily request count exceeded, request rate limited" — killed the node. This also violated the service contract, which asks services to log and retry non-critical errors rather than return them. Add verifyChainID, used only by Run, which retries transient failures with the client's existing resubscribe-delay loop (matching subscribeToUpdates and finalisedHeight), demoting them to warnings until the check succeeds or the context is cancelled. Retries are unbounded by design: a cap would just reintroduce the shutdown. A genuine chain-ID mismatch is a misconfiguration and stays fatal, now modelled as a typed chainIDMismatchError so the retry loop can tell it apart from transient errors. checkChainID stays single-attempt because the one-shot CatchUpL1Head migration path must keep failing fast. Fixes NethermindEth#1385
de6ed75 to
d0210c0
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3771 +/- ##
==========================================
+ Coverage 74.91% 76.34% +1.42%
==========================================
Files 433 433
Lines 38815 38656 -159
==========================================
+ Hits 29079 29510 +431
+ Misses 7733 7076 -657
- Partials 2003 2070 +67 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brbrr
left a comment
There was a problem hiding this comment.
LGTM! Thanks for the contribution!
| // Transient: warn and retry, unless we're already shutting down. | ||
| if ctx.Err() != nil { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
This ctx.Err() is not required since ctx.Done() is checked two instructions after
| c.logger.Warn("Failed to verify L1 chain ID; retrying", | ||
| zap.Duration("tryAgainIn", c.resubscribeDelay), | ||
| ) | ||
| c.logger.Debug("L1 chain ID verification failed", zap.Error(err)) |
There was a problem hiding this comment.
Why do we need to logs for the same thing? Why doesn't the warn share the error as well?
| if err := c.verifyChainID(ctx); err != nil { | ||
| return err | ||
| } | ||
| // verifyChainID returns nil on ctx cancellation; don't start any further | ||
| // RPC work (which would just fail with context canceled) if we're stopping. | ||
| if ctx.Err() != nil { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
What is this patch here, why does verifyChainID returns nil on context cancellation rather than returning the context error?
| // chainIDMismatchError marks an L1/L2 network mismatch: a misconfiguration that | ||
| // retrying cannot fix, so verifyChainID treats it as fatal. (Supporting custom | ||
| // forked Starknet networks would mean warning here instead of erroring.) | ||
| type chainIDMismatchError struct { | ||
| network string | ||
| } | ||
|
|
||
| func (e *chainIDMismatchError) Error() string { | ||
| return fmt.Sprintf( | ||
| "mismatched network id between L1 and L2. L2 network is %s; "+ | ||
| "is --eth-node pointing to the right network?", | ||
| e.network, | ||
| ) | ||
| } |
There was a problem hiding this comment.
There is no need for a custom error as I can see. It is being used only in one place
| zap.Duration("tryAgainIn", c.resubscribeDelay), | ||
| ) | ||
| c.logger.Debug("L1 chain ID verification failed", zap.Error(err)) | ||
| timer.Reset(c.resubscribeDelay) |
There was a problem hiding this comment.
Why resubscribe delay is used for separating between requests?
|
@rodrodros thanks, these all make sense. Since the PR is already merged, do you want me to open a follow-up issue/PR for these, or are they minor enough to leave as-is? |
What & why
A transient L1 RPC error during the startup chain-ID check would take the whole node down. Per #1385:
eth_chainIdreturnsdaily request count exceeded, request rate limited, the L1 client'sRunreturns that error, and sincenode.StartServiceruns each service withdefer cancel(), any service'sRunreturning shuts the node down. This also violated theservice.Servicecontract, which asks services to log and retry non-critical errors rather than return them.What this changes
verifyChainID(used only byRun) retries transient chain-ID failures — rate limits, timeouts, an unresponsive node — as warnings, reusing the client's existing resubscribe-delay loop (same pattern assubscribeToUpdatesandfinalisedHeight), until the check passes orctxis cancelled. Retries are unbounded on purpose: a cap would just return an error and re-trigger the shutdown this fixes.chainIDMismatchError, same user-facing message).checkChainIDstays single-attempt — the one-shotCatchUpL1Headmigration path relies on it failing fast.After this change no transient L1 RPC error can shut the node down:
catchUpL1HeadUpdatesis already best-effort,subscribeToUpdates/finalisedHeightalready retry forever, and the only remaining fatal paths are a genuine mismatch and a local DB-write failure insetL1Head— both legitimately critical.Relationship to #3685
Complementary, not overlapping: #3685 makes
StartServicetolerate a service returningnil, but an error return still stops the node. Transient chain-ID failures are errors, so the L1 client must stop emitting them regardless. No dependency on #3685.Tests
go test ./l1/passes. NewTestTransientChainIDErrorDoesNotShutDownNodereproduces the issue's rate-limit scenario and asserts the node retries instead of shutting down (fails on the old code).TestChainIDCheckTimeout/TestChainIDFetchErrorwere re-pointed at the fail-fastCatchUpL1Headpath (same error-message assertions); the mismatch tests are unchanged.Fixes #1385