Skip to content

[r3.6] cl/beacon: report a syncing node as 503, not 500 - #23608

Open
lystopad wants to merge 1 commit into
release/3.6from
feature/lystopad/cp-23464-to-3.6
Open

[r3.6] cl/beacon: report a syncing node as 503, not 500#23608
lystopad wants to merge 1 commit into
release/3.6from
feature/lystopad/cp-23464-to-3.6

Conversation

@lystopad

Copy link
Copy Markdown
Member

Cherry-pick of #23464 to release/3.6. Refs #23462.

Applies cleanly — no release-branch adaptations needed.

A node with no head state (starting up, restarting, catching up) answered 500 Internal Server Error where beacon-APIs declares 503 CurrentlySyncing. It is transiently unavailable, not faulty, and 500 reads as a node fault to operators and alerting.

The same node already returned 503 on POST /eth/v1/beacon/pool/attestations while returning 500 on the committee subscription endpoints for the identical cause, which is what marks this as an oversight rather than a decision.

Scope

This is a conformance and observability fix, not a behavioural one for validator clients. Lighthouse does not branch on 503 anywhere in its fallback or publish path — health comes from /eth/v1/node/health and /eth/v1/node/syncing, and both codes arrive as the same RequestFailed(ServerMessage(..)). Client routing will not change.

What it does change: a restarting node stops producing spurious 500s in error dashboards during rolling upgrades, and Caplin no longer logs the syncing case at Error.

Worth a reviewer's note, carried over from the main PR: the spec declares 503 on two of the affected endpoints (beacon_committee_subscriptions, duties/attester), while the central mapping also covers states/{state_id}/committees, pool/sync_committees and sync_committee_subscriptions, where beacon-APIs lists no 503. The main PR went with consistency — same condition, same code, unchanged error body.

Test

Verified on this branch rather than inherited from main. ./cl/beacon/... ./cl/validator/... green (18 packages), and each guard re-checked by mutation here:

Mutation Test that caught it
503 mapping → 500 TestWrapEndpointErrorStatusCodes, both subscription 503 tests
sentinel → bare errors.New TestAddAttestationSubscriptionReportsNotSyncedWhileSyncing
pointer match removed TestWrapEndpointErrorStatusCodes/explicit_code_is_preserved

make lintci run against a cleared lint cache.

Fixes #23462.

A node with no head state — starting up, restarting, catching up —
answered **500 Internal Server Error** on endpoints where `beacon-APIs`
declares **503 CurrentlySyncing**. It is transiently unavailable, not
faulty, and 500 reads as a node fault to operators and alerting.

Same node, same cause, two different codes today: `POST
/eth/v1/beacon/pool/attestations` already returns 503 (`pool.go` sets it
explicitly), while the committee subscription endpoints return 500. That
inconsistency is what makes this an oversight rather than a decision.

## Change

Two paths fed the same wrong code.

**The sentinel was discarded.** `AddAttestationSubscription` built a
fresh error, so nothing downstream could classify it:

```go
-    return errors.New("head state not available")
+    return synced_data.ErrNotSynced
```

**The central mapper had no case for it.** `WrapEndpointError` already
special-cases one sentinel, so this sits next to it — one change
covering every endpoint that surfaces `ErrNotSynced` raw
(`getAttesterDuties`, `getCommittees`,
`PostEthV1BeaconPoolSyncCommittees`, `blockRootFromBlockId`,
`PostEthV1ValidatorSyncCommitteeSubscriptions`):

```go
 if errors.Is(err, fork_graph.ErrStateNotFound) {
     return NewEndpointError(http.StatusNotFound, ErrorCantFindBeaconState)
 }
+if errors.Is(err, synced_data.ErrNotSynced) {
+    return NewEndpointError(http.StatusServiceUnavailable, err)
+}
```

Both subscription handlers now route through `WrapEndpointError` instead
of hardcoding 500, and the syncing case no longer logs at `Error` — a
node that is merely catching up should not fill the log with failures.

Also in this PR:

- **`WrapEndpointError` matched `*EndpointError` by value.** `errors.As`
with a value target never matches the pointer `NewEndpointError`
returns, so an explicit code reaching this helper collapsed to 500.
`HandleEndpoint` checks the pointer form first, so **no endpoint hit
this in practice** — it is latent, not live, and it is a trap for the
direct callers this PR adds. Both forms now match.
- **`ApiHandler.committeeSub` held the concrete
`*CommitteeSubscribeMgmt`**, so the already-generated
`MockCommitteeSubscribe` could not be injected. It now takes the
existing `CommitteeSubscribe` interface. No caller changes — the
concrete type satisfies it.
- Removed a commented-out block in `subscription.go` that proposed this
exact 503 check.

## Spec

[`types/http.yaml`](https://github.com/ethereum/beacon-APIs/blob/master/types/http.yaml)
— `CurrentlySyncing`: *"Beacon node is currently syncing, try again
later."*

Declared on two affected endpoints:
[`beacon_committee_subscriptions.yaml`](https://github.com/ethereum/beacon-APIs/blob/master/apis/validator/beacon_committee_subscriptions.yaml)
and
[`duties/attester.yaml`](https://github.com/ethereum/beacon-APIs/blob/master/apis/validator/duties/attester.yaml).

**Worth a reviewer's opinion:** the spec does *not* declare 503 for
`states/{state_id}/committees`, `pool/sync_committees` or
`sync_committee_subscriptions`, which the central mapping also changes.
I went with consistency — the condition is equally reachable there, the
error body shape is unchanged, and only strict response-code validation
would notice. Happy to narrow it to the two spec-declared endpoints if
you would rather stay literal.

## Scope

This is a conformance and observability fix, not a high-availability
one. Lighthouse does not branch on 503 anywhere in its fallback or
publish path — it derives health from `/eth/v1/node/health` and
`/eth/v1/node/syncing`, and both codes arrive as the same
`RequestFailed(ServerMessage(..))`. Client routing behaviour will not
change.

## Test

TDD, and each guard was verified by mutation rather than by reading:

| Mutation | Test that caught it |
| --- | --- |
| 503 mapping → 500 | `TestWrapEndpointErrorStatusCodes`, both
subscription 503 tests |
| sentinel → bare `errors.New` |
`TestAddAttestationSubscriptionReportsNotSyncedWhileSyncing` |
| pointer match removed |
`TestWrapEndpointErrorStatusCodes/explicit_code_is_preserved` |

The handler tests mock the subscription call, so they cannot catch a
regression in `committee_subscription.go` — hence the separate
package-level test for the sentinel. Coverage also pins the cases that
must *not* change: an unrelated failure still returns 500, and a synced
node still returns 200.

`./cl/beacon/... ./cl/validator/...` green (18 packages). `make lintci`
reports `0 issues`.

(cherry picked from commit ef0a6ce)
@lystopad lystopad self-assigned this Aug 26, 2026
@lystopad lystopad added the Caplin Caplin: Consensus Layer, Beacon API label Aug 26, 2026
@lystopad
lystopad enabled auto-merge August 26, 2026 15:39
@yperbasis yperbasis added this to the 3.6.1 milestone Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Caplin Caplin: Consensus Layer, Beacon API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants