From c4b241525df2e0582e3f031b6624191e7df4cf5e Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 15 Jul 2026 16:40:44 +0800 Subject: [PATCH 1/5] feat(node): add beacon RPC fallback for blob sidecar fetching The primary beacon node occasionally fails to serve blob sidecars in production (transport errors or 5xx/404 responses), stalling derivation. Support configuring multiple beacon endpoints so derivation can fall back to another beacon when one temporarily fails. Add FallbackHTTPClient, which implements the existing HTTP interface and tries each endpoint in order, transparently falling through on a transport error or non-200 response. Because it sits at the HTTP layer, the fallback covers all beacon calls (genesis, spec, blob sidecars) uniformly and requires no change to L1BeaconClient. Endpoints are supplied as a comma-separated --l1.beaconrpc value (L1_ETH_BEACON_RPC env), tried primary-first. A single URL yields a one-element list, so existing configs keep working unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- node/derivation/base_client.go | 72 ++++++++++++++++++++ node/derivation/base_client_test.go | 102 ++++++++++++++++++++++++++++ node/derivation/config.go | 18 ++++- node/derivation/config_test.go | 21 ++++++ node/derivation/derivation.go | 2 +- node/flags/flags.go | 2 +- 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 node/derivation/base_client_test.go diff --git a/node/derivation/base_client.go b/node/derivation/base_client.go index 98a5810f1..f44f55158 100644 --- a/node/derivation/base_client.go +++ b/node/derivation/base_client.go @@ -48,3 +48,75 @@ func (cl *BasicHTTPClient) Get(ctx context.Context, p string, headers http.Heade } return cl.client.Do(req) } + +var _ HTTP = (*FallbackHTTPClient)(nil) + +// FallbackHTTPClient wraps several beacon HTTP endpoints and queries them in +// order. When one endpoint fails with a transport error or a non-200 response, +// it transparently falls back to the next. This lets derivation keep making +// progress when the primary beacon node temporarily fails to serve blob +// sidecars (a recurring issue in production), as long as one of the configured +// beacons can serve the request. +// +// The first endpoint returning a 200 response wins. If every endpoint fails, +// the last non-200 response is returned (so the caller produces its usual +// "failed request with status" error), or the last transport error when no +// response was obtained at all. Behaviour with a single endpoint is identical +// to using a BasicHTTPClient directly. +type FallbackHTTPClient struct { + clients []*BasicHTTPClient + log tmlog.Logger +} + +func NewFallbackHTTPClient(endpoints []string, log tmlog.Logger) *FallbackHTTPClient { + clients := make([]*BasicHTTPClient, 0, len(endpoints)) + for _, endpoint := range endpoints { + clients = append(clients, NewBasicHTTPClient(endpoint, log)) + } + return &FallbackHTTPClient{clients: clients, log: log} +} + +func (cl *FallbackHTTPClient) Get(ctx context.Context, p string, headers http.Header) (*http.Response, error) { + var ( + lastResp *http.Response + lastErr error + ) + for i, client := range cl.clients { + // Stop early on cancellation/deadline instead of hammering every + // fallback endpoint with a request that is already doomed. + if err := ctx.Err(); err != nil { + if lastErr == nil { + lastErr = err + } + break + } + resp, err := client.Get(ctx, p, headers) + if err != nil { + lastErr = err + if cl.log != nil { + cl.log.Debug("beacon endpoint request failed, trying fallback", + "index", i, "endpoint", client.endpoint, "err", err) + } + continue + } + if resp.StatusCode == http.StatusOK { + return resp, nil + } + // Non-200: keep it as the representative failure and fall through to + // the next endpoint. Close any previously retained non-200 body so the + // underlying connection can be reused. + lastErr = fmt.Errorf("beacon endpoint %s returned status %d", client.endpoint, resp.StatusCode) + if lastResp != nil { + _ = lastResp.Body.Close() + } + lastResp = resp + if cl.log != nil { + cl.log.Debug("beacon endpoint returned non-200, trying fallback", + "index", i, "endpoint", client.endpoint, "status", resp.StatusCode) + } + } + if lastResp != nil { + return lastResp, nil + } + return nil, lastErr +} diff --git a/node/derivation/base_client_test.go b/node/derivation/base_client_test.go new file mode 100644 index 000000000..49af5f2d4 --- /dev/null +++ b/node/derivation/base_client_test.go @@ -0,0 +1,102 @@ +package derivation + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" +) + +// newBeaconStub returns a server that always answers with the given status and +// body, and a counter recording how many requests it received. +func newBeaconStub(t *testing.T, status int, body string) (string, *int32) { + t.Helper() + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + return srv.URL, &hits +} + +func doGet(t *testing.T, cl HTTP) (*http.Response, error) { + t.Helper() + return cl.Get(context.Background(), "eth/v1/beacon/genesis", http.Header{}) +} + +// The primary endpoint is used when healthy and the fallback is never touched. +func TestFallbackHTTPClient_PrimaryHealthy(t *testing.T) { + primary, primaryHits := newBeaconStub(t, http.StatusOK, "ok") + fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "fallback") + + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil) + resp, err := doGet(t, cl) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, "ok", string(body)) + require.EqualValues(t, 1, atomic.LoadInt32(primaryHits)) + require.EqualValues(t, 0, atomic.LoadInt32(fallbackHits), "fallback must not be queried while primary is healthy") +} + +// A non-200 from the primary transparently falls through to a healthy fallback. +func TestFallbackHTTPClient_FallsBackOnNon200(t *testing.T) { + primary, primaryHits := newBeaconStub(t, http.StatusInternalServerError, "boom") + fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "recovered") + + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil) + resp, err := doGet(t, cl) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, "recovered", string(body)) + require.EqualValues(t, 1, atomic.LoadInt32(primaryHits)) + require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) +} + +// A transport error (dead endpoint) also triggers fallback. +func TestFallbackHTTPClient_FallsBackOnTransportError(t *testing.T) { + fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "recovered") + + // http://127.0.0.1:0 is not listenable, so the first Get fails at the + // transport layer before any response is produced. + cl := NewFallbackHTTPClient([]string{"http://127.0.0.1:0", fallback}, nil) + resp, err := doGet(t, cl) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) +} + +// When every endpoint returns non-200, the last response is surfaced so the +// caller (apiReq) can produce its usual "failed request with status" error. +func TestFallbackHTTPClient_AllNon200ReturnsLastResponse(t *testing.T) { + primary, _ := newBeaconStub(t, http.StatusServiceUnavailable, "down") + fallback, _ := newBeaconStub(t, http.StatusNotFound, "missing") + + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil) + resp, err := doGet(t, cl) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// When every endpoint fails at the transport layer, an error is returned. +func TestFallbackHTTPClient_AllTransportErrorsReturnError(t *testing.T) { + cl := NewFallbackHTTPClient([]string{"http://127.0.0.1:0", "http://127.0.0.1:0"}, nil) + resp, err := doGet(t, cl) + require.Error(t, err) + require.Nil(t, resp) +} diff --git a/node/derivation/config.go b/node/derivation/config.go index 2f73092ce..862b53428 100644 --- a/node/derivation/config.go +++ b/node/derivation/config.go @@ -83,6 +83,22 @@ type Config struct { ReorgCheckDepth uint64 `json:"reorg_check_depth"` } +// BeaconRpcList splits the configured beacon RPC endpoint(s) on commas and +// returns them trimmed, in order (primary first). Operators can supply several +// beacon nodes via a single comma-separated --l1.beaconrpc value; derivation +// then falls back to the next endpoint when one temporarily fails to serve +// blob sidecars. A plain single URL yields a one-element slice, so existing +// configs keep working unchanged. +func (c *Config) BeaconRpcList() []string { + var endpoints []string + for _, endpoint := range strings.Split(c.BeaconRpc, ",") { + if endpoint = strings.TrimSpace(endpoint); endpoint != "" { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} + func DefaultConfig() *Config { return &Config{ L1: &types.L1Config{ @@ -122,7 +138,7 @@ func (c *Config) SetCliContext(ctx *cli.Context) error { c.RollupContractAddress = types.HoodiRollupContractAddress } c.BeaconRpc = ctx.GlobalString(flags.L1BeaconAddr.Name) - if c.BeaconRpc == "" { + if len(c.BeaconRpcList()) == 0 { return errors.New("invalid L1BeaconAddr") } diff --git a/node/derivation/config_test.go b/node/derivation/config_test.go index f18819784..8701196a5 100644 --- a/node/derivation/config_test.go +++ b/node/derivation/config_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" "github.com/urfave/cli" "morph-l2/node/flags" @@ -89,6 +90,26 @@ func TestVerifyMode_RejectsUnknown(t *testing.T) { } } +func TestBeaconRpcList(t *testing.T) { + for _, tc := range []struct { + name string + in string + want []string + }{ + {"single", "http://a", []string{"http://a"}}, + {"multiple", "http://a,http://b,http://c", []string{"http://a", "http://b", "http://c"}}, + {"trims spaces", " http://a , http://b ", []string{"http://a", "http://b"}}, + {"drops empties", "http://a,,http://b,", []string{"http://a", "http://b"}}, + {"empty", "", nil}, + {"only separators", " , , ", nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := (&Config{BeaconRpc: tc.in}).BeaconRpcList() + require.Equal(t, tc.want, got) + }) + } +} + func validateAndDefaultVerifyModeErr(t *testing.T, s string) error { t.Helper() _, err := validateAndDefaultVerifyMode(s) diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 4a5ce004a..76b6bfd18 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -124,7 +124,7 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, // itself is started once at the top level (cmd/node/main.go) so every // verify-mode and sequencer-mode produces exactly one /metrics URL. metrics := PrometheusMetrics("morphnode") - baseHttp := NewBasicHTTPClient(cfg.BeaconRpc, logger) + baseHttp := NewFallbackHTTPClient(cfg.BeaconRpcList(), logger) l1BeaconClient := NewL1BeaconClient(baseHttp) l2Client := types.NewRetryableClient(aClient, eClient, logger) diff --git a/node/flags/flags.go b/node/flags/flags.go index 7ee931d48..cb433cb59 100644 --- a/node/flags/flags.go +++ b/node/flags/flags.go @@ -66,7 +66,7 @@ var ( L1BeaconAddr = cli.StringFlag{ Name: "l1.beaconrpc", - Usage: "Address of L1 Beacon JSON-RPC endpoint to use (eth namespace required)", + Usage: "Address of L1 Beacon JSON-RPC endpoint(s) to use (eth namespace required). Supports a comma-separated list; endpoints are tried in order and derivation falls back to the next one when a beacon temporarily fails to serve blob sidecars", EnvVar: prefixEnvVar("L1_ETH_BEACON_RPC"), } From 73d4c5b3ba05aad216245278d39acef3f8bfc049 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 15 Jul 2026 16:48:01 +0800 Subject: [PATCH 2/5] refactor(node): simplify beacon fallback and expose failures via metrics Simplify FallbackHTTPClient.Get to a plain in-order loop: return the first endpoint that answers 200, otherwise return the last error. Drop the response-retention bookkeeping. Record each failing endpoint in the new beacon_request_failure_total counter (labelled by endpoint) so a flaky beacon is visible on dashboards. Co-Authored-By: Claude Opus 4.8 (1M context) --- node/derivation/base_client.go | 70 +++++++++-------------------- node/derivation/base_client_test.go | 44 ++++++++++-------- node/derivation/derivation.go | 2 +- node/derivation/metrics.go | 18 ++++++++ 4 files changed, 65 insertions(+), 69 deletions(-) diff --git a/node/derivation/base_client.go b/node/derivation/base_client.go index f44f55158..c5cefab21 100644 --- a/node/derivation/base_client.go +++ b/node/derivation/base_client.go @@ -51,72 +51,42 @@ func (cl *BasicHTTPClient) Get(ctx context.Context, p string, headers http.Heade var _ HTTP = (*FallbackHTTPClient)(nil) -// FallbackHTTPClient wraps several beacon HTTP endpoints and queries them in -// order. When one endpoint fails with a transport error or a non-200 response, -// it transparently falls back to the next. This lets derivation keep making -// progress when the primary beacon node temporarily fails to serve blob -// sidecars (a recurring issue in production), as long as one of the configured -// beacons can serve the request. -// -// The first endpoint returning a 200 response wins. If every endpoint fails, -// the last non-200 response is returned (so the caller produces its usual -// "failed request with status" error), or the last transport error when no -// response was obtained at all. Behaviour with a single endpoint is identical -// to using a BasicHTTPClient directly. +// FallbackHTTPClient wraps several beacon HTTP endpoints and tries them in the +// configured order. It returns the first endpoint that answers with a 200; on +// a transport error or a non-200 it records the failing endpoint in metrics and +// moves on to the next. This keeps derivation making progress when a beacon +// temporarily fails to serve blob sidecars, and surfaces the flaky node via the +// beacon_request_failure_total metric. With a single endpoint it behaves like a +// BasicHTTPClient. type FallbackHTTPClient struct { clients []*BasicHTTPClient - log tmlog.Logger + metrics *Metrics } -func NewFallbackHTTPClient(endpoints []string, log tmlog.Logger) *FallbackHTTPClient { +func NewFallbackHTTPClient(endpoints []string, log tmlog.Logger, metrics *Metrics) *FallbackHTTPClient { clients := make([]*BasicHTTPClient, 0, len(endpoints)) for _, endpoint := range endpoints { clients = append(clients, NewBasicHTTPClient(endpoint, log)) } - return &FallbackHTTPClient{clients: clients, log: log} + return &FallbackHTTPClient{clients: clients, metrics: metrics} } func (cl *FallbackHTTPClient) Get(ctx context.Context, p string, headers http.Header) (*http.Response, error) { - var ( - lastResp *http.Response - lastErr error - ) - for i, client := range cl.clients { - // Stop early on cancellation/deadline instead of hammering every - // fallback endpoint with a request that is already doomed. - if err := ctx.Err(); err != nil { - if lastErr == nil { - lastErr = err - } - break - } + var lastErr error + for _, client := range cl.clients { resp, err := client.Get(ctx, p, headers) - if err != nil { - lastErr = err - if cl.log != nil { - cl.log.Debug("beacon endpoint request failed, trying fallback", - "index", i, "endpoint", client.endpoint, "err", err) - } - continue - } - if resp.StatusCode == http.StatusOK { + if err == nil && resp.StatusCode == http.StatusOK { return resp, nil } - // Non-200: keep it as the representative failure and fall through to - // the next endpoint. Close any previously retained non-200 body so the - // underlying connection can be reused. - lastErr = fmt.Errorf("beacon endpoint %s returned status %d", client.endpoint, resp.StatusCode) - if lastResp != nil { - _ = lastResp.Body.Close() + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("beacon endpoint %s returned status %d", client.endpoint, resp.StatusCode) + _ = resp.Body.Close() } - lastResp = resp - if cl.log != nil { - cl.log.Debug("beacon endpoint returned non-200, trying fallback", - "index", i, "endpoint", client.endpoint, "status", resp.StatusCode) + if cl.metrics != nil { + cl.metrics.IncBeaconRequestFailure(client.endpoint) } } - if lastResp != nil { - return lastResp, nil - } return nil, lastErr } diff --git a/node/derivation/base_client_test.go b/node/derivation/base_client_test.go index 49af5f2d4..cca408f98 100644 --- a/node/derivation/base_client_test.go +++ b/node/derivation/base_client_test.go @@ -35,7 +35,7 @@ func TestFallbackHTTPClient_PrimaryHealthy(t *testing.T) { primary, primaryHits := newBeaconStub(t, http.StatusOK, "ok") fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "fallback") - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil) + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, nil) resp, err := doGet(t, cl) require.NoError(t, err) defer resp.Body.Close() @@ -52,7 +52,7 @@ func TestFallbackHTTPClient_FallsBackOnNon200(t *testing.T) { primary, primaryHits := newBeaconStub(t, http.StatusInternalServerError, "boom") fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "recovered") - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil) + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, nil) resp, err := doGet(t, cl) require.NoError(t, err) defer resp.Body.Close() @@ -70,7 +70,7 @@ func TestFallbackHTTPClient_FallsBackOnTransportError(t *testing.T) { // http://127.0.0.1:0 is not listenable, so the first Get fails at the // transport layer before any response is produced. - cl := NewFallbackHTTPClient([]string{"http://127.0.0.1:0", fallback}, nil) + cl := NewFallbackHTTPClient([]string{"http://127.0.0.1:0", fallback}, nil, nil) resp, err := doGet(t, cl) require.NoError(t, err) defer resp.Body.Close() @@ -79,24 +79,32 @@ func TestFallbackHTTPClient_FallsBackOnTransportError(t *testing.T) { require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) } -// When every endpoint returns non-200, the last response is surfaced so the -// caller (apiReq) can produce its usual "failed request with status" error. -func TestFallbackHTTPClient_AllNon200ReturnsLastResponse(t *testing.T) { - primary, _ := newBeaconStub(t, http.StatusServiceUnavailable, "down") - fallback, _ := newBeaconStub(t, http.StatusNotFound, "missing") +// When every endpoint fails (non-200 or transport error), the last error is +// returned so the caller (apiReq) reports a failure. +func TestFallbackHTTPClient_AllFailReturnsError(t *testing.T) { + primary, primaryHits := newBeaconStub(t, http.StatusServiceUnavailable, "down") + fallback, fallbackHits := newBeaconStub(t, http.StatusNotFound, "missing") - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil) + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, nil) resp, err := doGet(t, cl) - require.NoError(t, err) - defer resp.Body.Close() - - require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.Error(t, err) + require.Nil(t, resp) + // Both endpoints are tried before giving up. + require.EqualValues(t, 1, atomic.LoadInt32(primaryHits)) + require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) } -// When every endpoint fails at the transport layer, an error is returned. -func TestFallbackHTTPClient_AllTransportErrorsReturnError(t *testing.T) { - cl := NewFallbackHTTPClient([]string{"http://127.0.0.1:0", "http://127.0.0.1:0"}, nil) +// A per-endpoint failure is exposed via metrics so a flaky beacon is visible on +// dashboards. +func TestFallbackHTTPClient_RecordsFailureMetric(t *testing.T) { + primary, _ := newBeaconStub(t, http.StatusServiceUnavailable, "down") + fallback, _ := newBeaconStub(t, http.StatusOK, "recovered") + + m := PrometheusMetrics("morphnode_test_" + t.Name()) + cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, m) resp, err := doGet(t, cl) - require.Error(t, err) - require.Nil(t, resp) + require.NoError(t, err) + resp.Body.Close() + // Just assert the increment path does not panic with a real Metrics; the + // counter value is scraped from /metrics in production. } diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 76b6bfd18..d354b47d2 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -124,7 +124,7 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, // itself is started once at the top level (cmd/node/main.go) so every // verify-mode and sequencer-mode produces exactly one /metrics URL. metrics := PrometheusMetrics("morphnode") - baseHttp := NewFallbackHTTPClient(cfg.BeaconRpcList(), logger) + baseHttp := NewFallbackHTTPClient(cfg.BeaconRpcList(), logger, metrics) l1BeaconClient := NewL1BeaconClient(baseHttp) l2Client := types.NewRetryableClient(aClient, eClient, logger) diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index dce79ca13..cc82f9eb5 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -36,6 +36,12 @@ type Metrics struct { FinalizedL2BlockNumber metrics.Gauge L1ReorgResetTotal metrics.Counter TagInvariantViolationTotal metrics.Counter + + // BeaconRequestFailure counts beacon requests that failed (transport error + // or non-200), labelled by endpoint, before the FallbackHTTPClient moved on + // to the next configured beacon. A rising count for one endpoint pinpoints + // the flaky beacon node. + BeaconRequestFailure metrics.Counter } func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { @@ -122,6 +128,14 @@ func PrometheusMetrics(namespace string, labelsAndValues ...string) *Metrics { Name: "tag_invariant_violation_total", Help: "Times the finalized <= safe <= unsafe invariant failed; SetBlockTags is skipped on each occurrence.", }, labels).With(labelsAndValues...), + // The endpoint label is bound per-increment in IncBeaconRequestFailure, + // so it is declared here rather than pre-bound via With. + BeaconRequestFailure: prometheus.NewCounterFrom(stdprometheus.CounterOpts{ + Namespace: namespace, + Subsystem: metricsSubsystem, + Name: "beacon_request_failure_total", + Help: "Beacon requests that failed (transport error or non-200) per endpoint, before falling back to the next configured beacon.", + }, append(append([]string{}, labels...), "endpoint")), } } @@ -176,3 +190,7 @@ func (m *Metrics) IncL1ReorgReset() { func (m *Metrics) IncTagInvariantViolation() { m.TagInvariantViolationTotal.Add(1) } + +func (m *Metrics) IncBeaconRequestFailure(endpoint string) { + m.BeaconRequestFailure.With("endpoint", endpoint).Add(1) +} From e4481d324173db38ffd8a7324940b3bec9087ec6 Mon Sep 17 00:00:00 2001 From: corey Date: Wed, 15 Jul 2026 17:03:40 +0800 Subject: [PATCH 3/5] fix(node): move beacon fallback to the blob-fetch layer The HTTP-layer FallbackHTTPClient only fell back on transport errors and non-200 responses. But the common production failure is a beacon that answers 200 with an empty or partial sidecar list (slot pruned or not yet indexed) -- that surfaces as an application-level "insufficient sidecars" error *after* the HTTP call returns, so the transport-level fallback never triggered. Replace it with FallbackBeaconClient, which wraps one L1BeaconClient per endpoint and iterates GetBlobSidecarsEnhanced: a beacon is skipped when it errors, is unreachable, or returns fewer sidecars than requested, and the next configured beacon is tried. This covers all failure modes uniformly, including the empty/partial-result case. Each failing endpoint is recorded in beacon_request_failure_total. Co-Authored-By: Claude Opus 4.8 (1M context) --- node/derivation/base_client.go | 42 ------- node/derivation/base_client_test.go | 172 ++++++++++++++++------------ node/derivation/beacon.go | 56 +++++++++ node/derivation/derivation.go | 5 +- node/derivation/metrics.go | 8 +- 5 files changed, 163 insertions(+), 120 deletions(-) diff --git a/node/derivation/base_client.go b/node/derivation/base_client.go index c5cefab21..98a5810f1 100644 --- a/node/derivation/base_client.go +++ b/node/derivation/base_client.go @@ -48,45 +48,3 @@ func (cl *BasicHTTPClient) Get(ctx context.Context, p string, headers http.Heade } return cl.client.Do(req) } - -var _ HTTP = (*FallbackHTTPClient)(nil) - -// FallbackHTTPClient wraps several beacon HTTP endpoints and tries them in the -// configured order. It returns the first endpoint that answers with a 200; on -// a transport error or a non-200 it records the failing endpoint in metrics and -// moves on to the next. This keeps derivation making progress when a beacon -// temporarily fails to serve blob sidecars, and surfaces the flaky node via the -// beacon_request_failure_total metric. With a single endpoint it behaves like a -// BasicHTTPClient. -type FallbackHTTPClient struct { - clients []*BasicHTTPClient - metrics *Metrics -} - -func NewFallbackHTTPClient(endpoints []string, log tmlog.Logger, metrics *Metrics) *FallbackHTTPClient { - clients := make([]*BasicHTTPClient, 0, len(endpoints)) - for _, endpoint := range endpoints { - clients = append(clients, NewBasicHTTPClient(endpoint, log)) - } - return &FallbackHTTPClient{clients: clients, metrics: metrics} -} - -func (cl *FallbackHTTPClient) Get(ctx context.Context, p string, headers http.Header) (*http.Response, error) { - var lastErr error - for _, client := range cl.clients { - resp, err := client.Get(ctx, p, headers) - if err == nil && resp.StatusCode == http.StatusOK { - return resp, nil - } - if err != nil { - lastErr = err - } else { - lastErr = fmt.Errorf("beacon endpoint %s returned status %d", client.endpoint, resp.StatusCode) - _ = resp.Body.Close() - } - if cl.metrics != nil { - cl.metrics.IncBeaconRequestFailure(client.endpoint) - } - } - return nil, lastErr -} diff --git a/node/derivation/base_client_test.go b/node/derivation/base_client_test.go index cca408f98..65bd146ec 100644 --- a/node/derivation/base_client_test.go +++ b/node/derivation/base_client_test.go @@ -2,109 +2,139 @@ package derivation import ( "context" - "io" + "fmt" "net/http" "net/http/httptest" + "strings" "sync/atomic" "testing" + "github.com/morph-l2/go-ethereum/common" "github.com/stretchr/testify/require" ) -// newBeaconStub returns a server that always answers with the given status and -// body, and a counter recording how many requests it received. -func newBeaconStub(t *testing.T, status int, body string) (string, *int32) { +// zero-value but correctly-sized hex so the beacon JSON decodes into a +// BlobSidecar. GetBlobSidecarsEnhanced does not verify blob contents (that +// happens downstream), it only counts sidecars, so dummy bytes are fine here. +var ( + hex32 = "0x" + strings.Repeat("00", 32) + hex48 = "0x" + strings.Repeat("00", 48) +) + +func sidecarJSON(index int) string { + return fmt.Sprintf(`{"block_root":%q,"slot":"1","blob":"0x00","index":"%d","kzg_commitment":%q,"kzg_proof":%q}`, + hex32, index, hex48, hex48) +} + +// beaconBehavior controls what a stub beacon returns for blob_sidecars. +type beaconBehavior int + +const ( + beaconServesBlob beaconBehavior = iota // 200 with one sidecar + beaconServesEmpty // 200 with an empty list (pruned / not indexed) + beaconServerError // 500 +) + +// newStubBeacon serves the genesis + spec endpoints (needed for slot math) and +// answers blob_sidecars according to behavior. It returns the base URL and a +// counter of blob_sidecars requests received. +func newStubBeacon(t *testing.T, behavior beaconBehavior) (string, *int32) { t.Helper() - var hits int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&hits, 1) - w.WriteHeader(status) - _, _ = io.WriteString(w, body) + var blobHits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, genesisMethod): + _, _ = w.Write([]byte(`{"data":{"genesis_time":"0"}}`)) + case strings.Contains(r.URL.Path, specMethod): + _, _ = w.Write([]byte(`{"data":{"SECONDS_PER_SLOT":"12"}}`)) + case strings.Contains(r.URL.Path, sidecarsMethodPrefix): + atomic.AddInt32(&blobHits, 1) + switch behavior { + case beaconServesBlob: + _, _ = w.Write([]byte(`{"data":[` + sidecarJSON(0) + `]}`)) + case beaconServesEmpty: + _, _ = w.Write([]byte(`{"data":[]}`)) + case beaconServerError: + w.WriteHeader(http.StatusInternalServerError) + } + default: + w.WriteHeader(http.StatusNotFound) + } })) t.Cleanup(srv.Close) - return srv.URL, &hits + return srv.URL, &blobHits +} + +func oneHash() []IndexedBlobHash { + return []IndexedBlobHash{{Index: 0, Hash: common.Hash{}}} } -func doGet(t *testing.T, cl HTTP) (*http.Response, error) { +func fetch(t *testing.T, c *FallbackBeaconClient) ([]*BlobSidecar, error) { t.Helper() - return cl.Get(context.Background(), "eth/v1/beacon/genesis", http.Header{}) + return c.GetBlobSidecarsEnhanced(context.Background(), L1BlockRef{Time: 12}, oneHash()) } -// The primary endpoint is used when healthy and the fallback is never touched. -func TestFallbackHTTPClient_PrimaryHealthy(t *testing.T) { - primary, primaryHits := newBeaconStub(t, http.StatusOK, "ok") - fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "fallback") +// The primary serves the blob and the fallback is never queried. +func TestFallbackBeacon_PrimaryServesBlob(t *testing.T) { + primary, primaryHits := newStubBeacon(t, beaconServesBlob) + fallback, fallbackHits := newStubBeacon(t, beaconServesBlob) - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, nil) - resp, err := doGet(t, cl) + c := NewFallbackBeaconClient([]string{primary, fallback}, nil, nil) + sidecars, err := fetch(t, c) require.NoError(t, err) - defer resp.Body.Close() - - require.Equal(t, http.StatusOK, resp.StatusCode) - body, _ := io.ReadAll(resp.Body) - require.Equal(t, "ok", string(body)) + require.Len(t, sidecars, 1) require.EqualValues(t, 1, atomic.LoadInt32(primaryHits)) - require.EqualValues(t, 0, atomic.LoadInt32(fallbackHits), "fallback must not be queried while primary is healthy") + require.EqualValues(t, 0, atomic.LoadInt32(fallbackHits), "fallback must not be queried while primary serves the blob") } -// A non-200 from the primary transparently falls through to a healthy fallback. -func TestFallbackHTTPClient_FallsBackOnNon200(t *testing.T) { - primary, primaryHits := newBeaconStub(t, http.StatusInternalServerError, "boom") - fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "recovered") +// The key case: primary answers 200 but with NO sidecars (pruned / not indexed). +// This must fall back to a beacon that actually has the blob. +func TestFallbackBeacon_FallsBackOnEmptyResult(t *testing.T) { + primary, primaryHits := newStubBeacon(t, beaconServesEmpty) + fallback, fallbackHits := newStubBeacon(t, beaconServesBlob) - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, nil) - resp, err := doGet(t, cl) + c := NewFallbackBeaconClient([]string{primary, fallback}, nil, nil) + sidecars, err := fetch(t, c) require.NoError(t, err) - defer resp.Body.Close() - - require.Equal(t, http.StatusOK, resp.StatusCode) - body, _ := io.ReadAll(resp.Body) - require.Equal(t, "recovered", string(body)) - require.EqualValues(t, 1, atomic.LoadInt32(primaryHits)) - require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) + require.Len(t, sidecars, 1) + require.Positive(t, atomic.LoadInt32(primaryHits)) + require.Positive(t, atomic.LoadInt32(fallbackHits)) } -// A transport error (dead endpoint) also triggers fallback. -func TestFallbackHTTPClient_FallsBackOnTransportError(t *testing.T) { - fallback, fallbackHits := newBeaconStub(t, http.StatusOK, "recovered") +// A 5xx on the primary also falls back. +func TestFallbackBeacon_FallsBackOnServerError(t *testing.T) { + primary, _ := newStubBeacon(t, beaconServerError) + fallback, fallbackHits := newStubBeacon(t, beaconServesBlob) - // http://127.0.0.1:0 is not listenable, so the first Get fails at the - // transport layer before any response is produced. - cl := NewFallbackHTTPClient([]string{"http://127.0.0.1:0", fallback}, nil, nil) - resp, err := doGet(t, cl) + c := NewFallbackBeaconClient([]string{primary, fallback}, nil, nil) + sidecars, err := fetch(t, c) require.NoError(t, err) - defer resp.Body.Close() - - require.Equal(t, http.StatusOK, resp.StatusCode) - require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) + require.Len(t, sidecars, 1) + require.Positive(t, atomic.LoadInt32(fallbackHits)) } -// When every endpoint fails (non-200 or transport error), the last error is -// returned so the caller (apiReq) reports a failure. -func TestFallbackHTTPClient_AllFailReturnsError(t *testing.T) { - primary, primaryHits := newBeaconStub(t, http.StatusServiceUnavailable, "down") - fallback, fallbackHits := newBeaconStub(t, http.StatusNotFound, "missing") +// An unreachable primary (transport error) falls back. +func TestFallbackBeacon_FallsBackOnTransportError(t *testing.T) { + fallback, fallbackHits := newStubBeacon(t, beaconServesBlob) - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, nil) - resp, err := doGet(t, cl) - require.Error(t, err) - require.Nil(t, resp) - // Both endpoints are tried before giving up. - require.EqualValues(t, 1, atomic.LoadInt32(primaryHits)) - require.EqualValues(t, 1, atomic.LoadInt32(fallbackHits)) + c := NewFallbackBeaconClient([]string{"http://127.0.0.1:0", fallback}, nil, nil) + sidecars, err := fetch(t, c) + require.NoError(t, err) + require.Len(t, sidecars, 1) + require.Positive(t, atomic.LoadInt32(fallbackHits)) } -// A per-endpoint failure is exposed via metrics so a flaky beacon is visible on -// dashboards. -func TestFallbackHTTPClient_RecordsFailureMetric(t *testing.T) { - primary, _ := newBeaconStub(t, http.StatusServiceUnavailable, "down") - fallback, _ := newBeaconStub(t, http.StatusOK, "recovered") +// When every beacon fails to serve the blob, an error is returned and every +// endpoint's failure is recorded in metrics (exercised via a real *Metrics). +func TestFallbackBeacon_AllFailReturnsError(t *testing.T) { + primary, primaryHits := newStubBeacon(t, beaconServesEmpty) + fallback, fallbackHits := newStubBeacon(t, beaconServerError) m := PrometheusMetrics("morphnode_test_" + t.Name()) - cl := NewFallbackHTTPClient([]string{primary, fallback}, nil, m) - resp, err := doGet(t, cl) - require.NoError(t, err) - resp.Body.Close() - // Just assert the increment path does not panic with a real Metrics; the - // counter value is scraped from /metrics in production. + c := NewFallbackBeaconClient([]string{primary, fallback}, nil, m) + sidecars, err := fetch(t, c) + require.Error(t, err) + require.Nil(t, sidecars) + require.Positive(t, atomic.LoadInt32(primaryHits)) + require.Positive(t, atomic.LoadInt32(fallbackHits)) } diff --git a/node/derivation/beacon.go b/node/derivation/beacon.go index ac663241f..1de364ade 100644 --- a/node/derivation/beacon.go +++ b/node/derivation/beacon.go @@ -16,6 +16,7 @@ import ( "github.com/morph-l2/go-ethereum/core/types" "github.com/morph-l2/go-ethereum/crypto/kzg4844" "github.com/morph-l2/go-ethereum/params" + tmlog "github.com/tendermint/tendermint/libs/log" ) const ( @@ -208,6 +209,61 @@ func dataAndHashesFromTxs(txs types.Transactions, targetTx *types.Transaction) [ return hashes } +// FallbackBeaconClient queries several beacon nodes in order for blob sidecars. +// A beacon is skipped and the next one tried when it errors, is unreachable, or +// answers with too few sidecars — a beacon that pruned the slot or has not +// indexed it yet still replies 200 with an empty/partial list, which is exactly +// the "temporarily failed to fetch blob" case seen in production and which a +// transport-level fallback would miss. The failing endpoint is recorded in the +// beacon_request_failure_total metric so a flaky node is visible on dashboards. +// With a single endpoint it behaves like a bare L1BeaconClient. +type FallbackBeaconClient struct { + clients []*L1BeaconClient + endpoints []string // parallel to clients, used only for logs/metrics + log tmlog.Logger + metrics *Metrics +} + +func NewFallbackBeaconClient(endpoints []string, log tmlog.Logger, metrics *Metrics) *FallbackBeaconClient { + clients := make([]*L1BeaconClient, 0, len(endpoints)) + for _, endpoint := range endpoints { + clients = append(clients, NewL1BeaconClient(NewBasicHTTPClient(endpoint, log))) + } + return &FallbackBeaconClient{ + clients: clients, + endpoints: endpoints, + log: log, + metrics: metrics, + } +} + +// GetBlobSidecarsEnhanced tries each configured beacon in order and returns the +// first response that actually carries the requested blobs (at least len(hashes) +// sidecars, or at least one when no explicit hashes are requested). A beacon +// that errors or returns an incomplete set is recorded as a failure and skipped. +// If every beacon fails, the last error is returned. +func (c *FallbackBeaconClient) GetBlobSidecarsEnhanced(ctx context.Context, ref L1BlockRef, hashes []IndexedBlobHash) ([]*BlobSidecar, error) { + var lastErr error + for i, cl := range c.clients { + sidecars, err := cl.GetBlobSidecarsEnhanced(ctx, ref, hashes) + if err == nil && len(sidecars) > 0 && len(sidecars) >= len(hashes) { + return sidecars, nil + } + if err == nil { + err = fmt.Errorf("beacon returned %d sidecars, want at least %d", len(sidecars), len(hashes)) + } + if c.metrics != nil { + c.metrics.IncBeaconRequestFailure(c.endpoints[i]) + } + if c.log != nil { + c.log.Error("beacon failed to serve blob sidecars, trying next endpoint", + "endpoint", c.endpoints[i], "err", err) + } + lastErr = err + } + return nil, lastErr +} + // Note: ForceGetAllBlobs is defined in derivation.go in the same package // GetBlobSidecarsEnhanced is an enhanced version of GetBlobSidecars method, combining two approaches to fetch blob data diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index d354b47d2..0db663ffe 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -47,7 +47,7 @@ type Derivation struct { logger tmlog.Logger rollup *bindings.Rollup metrics *Metrics - l1BeaconClient *L1BeaconClient + l1BeaconClient *FallbackBeaconClient L2ToL1MessagePasser *bindings.L2ToL1MessagePasser rollupABI *abi.ABI @@ -124,8 +124,7 @@ func NewDerivationClient(ctx context.Context, cfg *Config, syncer *sync.Syncer, // itself is started once at the top level (cmd/node/main.go) so every // verify-mode and sequencer-mode produces exactly one /metrics URL. metrics := PrometheusMetrics("morphnode") - baseHttp := NewFallbackHTTPClient(cfg.BeaconRpcList(), logger, metrics) - l1BeaconClient := NewL1BeaconClient(baseHttp) + l1BeaconClient := NewFallbackBeaconClient(cfg.BeaconRpcList(), logger, metrics) l2Client := types.NewRetryableClient(aClient, eClient, logger) tagAdv := newTagAdvancer(l2Client, metrics, logger) diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index cc82f9eb5..4dc9864c9 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -37,10 +37,10 @@ type Metrics struct { L1ReorgResetTotal metrics.Counter TagInvariantViolationTotal metrics.Counter - // BeaconRequestFailure counts beacon requests that failed (transport error - // or non-200), labelled by endpoint, before the FallbackHTTPClient moved on - // to the next configured beacon. A rising count for one endpoint pinpoints - // the flaky beacon node. + // BeaconRequestFailure counts beacons that failed to serve blob sidecars + // (unreachable, non-200, or an incomplete/empty result), labelled by + // endpoint, before the FallbackBeaconClient moved on to the next configured + // beacon. A rising count for one endpoint pinpoints the flaky beacon node. BeaconRequestFailure metrics.Counter } From 43b42596079c9628f0291155df53c35f9c0d4378 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 16 Jul 2026 15:04:10 +0800 Subject: [PATCH 4/5] fix(node): remove unused derivation context parameter Co-authored-by: Cursor --- node/derivation/derivation.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/node/derivation/derivation.go b/node/derivation/derivation.go index 0db663ffe..b49ed1264 100644 --- a/node/derivation/derivation.go +++ b/node/derivation/derivation.go @@ -388,7 +388,7 @@ func (d *Derivation) derivationBlock(ctx context.Context) { // or fails — without it, a deriveForce error would leave // reactors stopped indefinitely (Stop is idempotent on // retry, but Start is never reached). - err = d.withReactorsQuiesced(ctx, batchInfo.batchIndex, func() error { + err = d.withReactorsQuiesced(batchInfo.batchIndex, func() error { var derErr error lastHeader, derErr = d.deriveForce(batchInfoFull) return derErr @@ -432,7 +432,7 @@ func (d *Derivation) derivationBlock(ctx context.Context) { // so the deferred Start runs whether deriveForce succeeds // or fails — without it, a deriveForce error would leave // reactors stopped indefinitely. - err = d.withReactorsQuiesced(ctx, batchInfo.batchIndex, func() error { + err = d.withReactorsQuiesced(batchInfo.batchIndex, func() error { var derErr error lastHeader, derErr = d.deriveForce(batchInfoFull) return derErr @@ -837,7 +837,7 @@ func (d *Derivation) derive(rollupData *BatchInfo) (*eth.Header, error) { // zero (which would tell blocksync to re-fetch from genesis). HA // sequencers and mock-mode skip (d.node == nil): sequencers don't // auto-reorg, mock has no reactors. -func (d *Derivation) withReactorsQuiesced(ctx context.Context, batchIndex uint64, body func() error) error { +func (d *Derivation) withReactorsQuiesced(batchIndex uint64, body func() error) error { if d.node == nil { return body() } From 1b15817dbe0e25031664d06e1220505d9fa02e96 Mon Sep 17 00:00:00 2001 From: corey Date: Thu, 16 Jul 2026 15:11:34 +0800 Subject: [PATCH 5/5] fix(node): correct beacon metrics comment spelling Co-authored-by: Cursor --- node/derivation/metrics.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/derivation/metrics.go b/node/derivation/metrics.go index 4dc9864c9..57e180518 100644 --- a/node/derivation/metrics.go +++ b/node/derivation/metrics.go @@ -38,7 +38,7 @@ type Metrics struct { TagInvariantViolationTotal metrics.Counter // BeaconRequestFailure counts beacons that failed to serve blob sidecars - // (unreachable, non-200, or an incomplete/empty result), labelled by + // (unreachable, non-200, or an incomplete/empty result), labeled by // endpoint, before the FallbackBeaconClient moved on to the next configured // beacon. A rising count for one endpoint pinpoints the flaky beacon node. BeaconRequestFailure metrics.Counter