diff --git a/pkg/rpc/server/http.go b/pkg/rpc/server/http.go index 88fe395a99..fb34c978c0 100644 --- a/pkg/rpc/server/http.go +++ b/pkg/rpc/server/http.go @@ -78,8 +78,9 @@ func RegisterCustomHTTPEndpoints(mux *http.ServeMux, s store.Store, pm p2p.P2PRP // 2. Block production/sync status: // - Confirms node state is accessible // - Verifies at least one block has been produced/synced - // 3. Aggregator-specific checks (for aggregator nodes only): - // - Validates blocks are being produced at expected rate (within 5x block_time) + // 3. Block execution liveness: + // - Aggregator nodes: validates blocks are being produced at expected rate (within 5x block_time) + // - Non-aggregator nodes: validates a block has been executed within readiness_window_seconds // 4. Sync status (for all nodes): // - Compares local height with best known network height // - Ensures node is not falling behind by more than readiness_max_blocks_behind @@ -134,6 +135,16 @@ func RegisterCustomHTTPEndpoints(mux *http.ServeMux, s store.Store, pm p2p.P2PRP http.Error(w, "UNREADY: aggregator not producing blocks at expected rate", http.StatusServiceUnavailable) return } + } else { + readinessWindowSeconds := cfg.Node.ReadinessWindowSeconds + if readinessWindowSeconds == 0 { + readinessWindowSeconds = 15 // fallback to default 15s window + } + maxAllowedDelay := time.Duration(readinessWindowSeconds) * time.Second + if time.Since(state.LastBlockTime) > maxAllowedDelay { + http.Error(w, "UNREADY: node not executing blocks", http.StatusServiceUnavailable) + return + } } if bestKnownHeightProvider == nil { diff --git a/pkg/rpc/server/http_test.go b/pkg/rpc/server/http_test.go index 23e0e180f3..67dfcdd0da 100644 --- a/pkg/rpc/server/http_test.go +++ b/pkg/rpc/server/http_test.go @@ -232,3 +232,71 @@ func TestHealthReady_aggregatorBlockDelay(t *testing.T) { }) } } + +func TestHealthReady_nonAggregatorBlockDelay(t *testing.T) { + logger := zerolog.Nop() + + type spec struct { + readinessWindowSeconds uint64 + delay time.Duration + expStatusCode int + expBody string + } + + specs := map[string]spec{ + "within readiness window": { + readinessWindowSeconds: 10, + delay: 5 * time.Second, + expStatusCode: http.StatusOK, + expBody: "READY\n", + }, + "exceeds readiness window": { + readinessWindowSeconds: 10, + delay: 15 * time.Second, + expStatusCode: http.StatusServiceUnavailable, + expBody: "UNREADY: node not executing blocks\n", + }, + "zero readiness window falls back to default 15s": { + readinessWindowSeconds: 0, + delay: 10 * time.Second, + expStatusCode: http.StatusOK, + expBody: "READY\n", + }, + } + + for name, tc := range specs { + t.Run(name, func(t *testing.T) { + mux := http.NewServeMux() + + cfg := config.DefaultConfig() + cfg.Node.Aggregator = false + cfg.Node.ReadinessWindowSeconds = tc.readinessWindowSeconds + + mockStore := mocks.NewMockStore(t) + state := types.State{ + LastBlockHeight: 10, + LastBlockTime: time.Now().Add(-tc.delay), + } + mockStore.On("GetState", mock.Anything).Return(state, nil) + + bestKnownHeightProvider := func() uint64 { return state.LastBlockHeight } + + RegisterCustomHTTPEndpoints(mux, mockStore, nil, cfg, bestKnownHeightProvider, logger, nil) + + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + req, err := http.NewRequest(http.MethodGet, ts.URL+"/health/ready", nil) + require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // ok to use default client in tests + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, tc.expStatusCode, resp.StatusCode) + assert.Equal(t, tc.expBody, string(body)) + }) + } +}