Skip to content

Commit ac4ee32

Browse files
NateIsernclaude
andcommitted
feat(health): expose whether the tip we serve is still the chain's tip
The explorer reads everything from one faircoind. When that node stops following the chain the RPC keeps answering perfectly, so every endpoint keeps returning 200 with a frozen height and nothing looks wrong — which is how the node sat 80+ blocks behind for over an hour today without a single alarm. /api/health compares our height against the heights our peers report and says so out loud, answering 503 when a peer is demonstrably ahead and our tip has gone cold. Two rules keep it honest: ordinary propagation delay (a peer ahead, tip still recent) is "lagging", not an outage; and a cold tip with no peer heights at all reports "unknown" rather than "ok", because there is nothing to compare against and claiming health is exactly the failure being fixed. A quiet network — every peer agreeing with us — is never a stall. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bf99986 commit ac4ee32

3 files changed

Lines changed: 199 additions & 0 deletions

File tree

server/index.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { handleRouteError, parseNetwork, parseLimit, parseOffset, parseBlockOffs
1616
import { computeCirculatingSupply, currentBlockReward } from '../shared/supply'
1717
import { logger } from './lib/logger'
1818
import { toPublicNetworkInfo } from './lib/network-info'
19+
import { assessNodeHealth } from './lib/node-health'
1920
import { rpcWithNetwork } from '@fairco.in/rpc-client'
2021
import priceRouter from './routes/price'
2122
import statsHistoryRouter from './routes/stats-history'
@@ -476,6 +477,45 @@ app.get('/api/peers', async (req, res) => {
476477
}
477478
})
478479

480+
/**
481+
* Is the tip we are serving still the chain's tip?
482+
*
483+
* Every other endpoint answers 200 with whatever height the node reports, which
484+
* is indistinguishable from a healthy answer when the node has silently stopped
485+
* following the chain. This is the one endpoint that compares our height against
486+
* what our peers claim and says so out loud — and it answers 503 when we are
487+
* demonstrably behind, so an uptime check catches it without a human noticing.
488+
*/
489+
app.get('/api/health', async (req, res) => {
490+
const network = parseNetwork(req.query.network)
491+
try {
492+
// Short TTLs: a health probe that reads a minute-old cache cannot detect a
493+
// node that stopped a minute ago.
494+
const [nodeHeight, tipHash, peers] = await Promise.all([
495+
blockCache.get<number>('getblockcount', [], { network, ttl: 10 }),
496+
blockCache.get<string>('getbestblockhash', [], { network, ttl: 10 }),
497+
blockCache
498+
.get<Array<{ synced_headers?: number; startingheight?: number }>>('getpeerinfo', [], { network, ttl: 20 })
499+
.catch(() => []),
500+
])
501+
const tip = await blockCache.getBlock(tipHash, network, true)
502+
const health = assessNodeHealth({
503+
nodeHeight,
504+
tipTime: Number(tip?.time ?? 0),
505+
peerHeights: (Array.isArray(peers) ? peers : []).flatMap((peer) => [
506+
Number(peer?.synced_headers ?? -1),
507+
Number(peer?.startingheight ?? -1),
508+
]),
509+
now: Math.floor(Date.now() / 1000),
510+
})
511+
res.status(health.status === 'stalled' ? 503 : 200).json({ ...health, network })
512+
} catch (error) {
513+
logger.error('Health check failed:', error)
514+
// An unreachable node is never "ok" — fail loud rather than answer 200.
515+
res.status(503).json({ status: 'unknown', error: 'node unreachable', network })
516+
}
517+
})
518+
479519
app.get('/api/stats', async (req, res) => {
480520
try {
481521
const network = parseNetwork(req.query.network)

server/lib/node-health.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { assessNodeHealth, STALE_TIP_SECONDS } from './node-health'
3+
4+
/**
5+
* The explorer used to serve its node's tip with no indication of whether that
6+
* tip was still current. When the node stopped following the chain, every
7+
* endpoint kept answering 200 with a frozen height and nothing looked wrong —
8+
* the outage was invisible until a human happened to compare against another
9+
* node. These tests lock the rule that makes that impossible: a tip is only
10+
* "ok" when nobody we are connected to has more chain than we do.
11+
*/
12+
13+
const NOW = 1_800_000_000
14+
const FRESH = NOW - 60
15+
16+
describe('assessNodeHealth', () => {
17+
it('is ok when the node matches the best peer', () => {
18+
const health = assessNodeHealth({ nodeHeight: 100, tipTime: FRESH, peerHeights: [100, 99], now: NOW })
19+
expect(health.status).toBe('ok')
20+
expect(health.lagBlocks).toBe(0)
21+
expect(health.networkHeight).toBe(100)
22+
})
23+
24+
it('is ok when the node is ahead of every peer', () => {
25+
const health = assessNodeHealth({ nodeHeight: 101, tipTime: FRESH, peerHeights: [100], now: NOW })
26+
expect(health.status).toBe('ok')
27+
expect(health.lagBlocks).toBe(0)
28+
})
29+
30+
it('is lagging when a peer is ahead but the tip is still fresh', () => {
31+
// A block or two behind with a recent tip is normal propagation, not an outage.
32+
const health = assessNodeHealth({ nodeHeight: 98, tipTime: FRESH, peerHeights: [100], now: NOW })
33+
expect(health.status).toBe('lagging')
34+
expect(health.lagBlocks).toBe(2)
35+
})
36+
37+
it('is stalled when a peer is ahead AND our tip has gone stale', () => {
38+
const health = assessNodeHealth({
39+
nodeHeight: 81534,
40+
tipTime: NOW - STALE_TIP_SECONDS - 1,
41+
peerHeights: [81603],
42+
now: NOW,
43+
})
44+
expect(health.status).toBe('stalled')
45+
expect(health.lagBlocks).toBe(69)
46+
expect(health.tipAgeSeconds).toBeGreaterThan(STALE_TIP_SECONDS)
47+
})
48+
49+
it('does not call a quiet network a stall', () => {
50+
// Every peer agrees with us; the chain itself simply is not producing.
51+
const health = assessNodeHealth({
52+
nodeHeight: 100,
53+
tipTime: NOW - STALE_TIP_SECONDS - 1,
54+
peerHeights: [100, 100],
55+
now: NOW,
56+
})
57+
expect(health.status).toBe('ok')
58+
expect(health.lagBlocks).toBe(0)
59+
})
60+
61+
it('reports unknown, never ok, when the tip is stale and we have no peer heights', () => {
62+
// With nothing to compare against we cannot claim health either way, and
63+
// claiming "ok" is exactly the failure this endpoint exists to prevent.
64+
const health = assessNodeHealth({
65+
nodeHeight: 100,
66+
tipTime: NOW - STALE_TIP_SECONDS - 1,
67+
peerHeights: [],
68+
now: NOW,
69+
})
70+
expect(health.status).toBe('unknown')
71+
})
72+
73+
it('is ok with no peers while the tip is fresh', () => {
74+
const health = assessNodeHealth({ nodeHeight: 100, tipTime: FRESH, peerHeights: [], now: NOW })
75+
expect(health.status).toBe('ok')
76+
})
77+
78+
it('ignores the -1 sentinel peers report before they have synced with us', () => {
79+
const health = assessNodeHealth({ nodeHeight: 100, tipTime: FRESH, peerHeights: [-1, -1], now: NOW })
80+
expect(health.networkHeight).toBe(100)
81+
expect(health.status).toBe('ok')
82+
})
83+
84+
it('never reports a negative lag', () => {
85+
const health = assessNodeHealth({ nodeHeight: 200, tipTime: FRESH, peerHeights: [100], now: NOW })
86+
expect(health.lagBlocks).toBe(0)
87+
})
88+
})

server/lib/node-health.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* Node health — is the tip we are serving still the chain's tip?
3+
*
4+
* The explorer reads everything from one faircoind over RPC. When that node
5+
* stops following the chain the RPC keeps answering perfectly, so every endpoint
6+
* keeps returning 200 with a frozen height and nothing looks wrong. That is how
7+
* a node parked 80+ blocks behind went unnoticed for over an hour: the site was
8+
* confidently serving a stale chain.
9+
*
10+
* The only local signal that can catch it is the peers' own heights, which the
11+
* node already reports (`getpeerinfo` → `synced_headers` / `startingheight`).
12+
* If somebody we are connected to has more chain than we do and our tip has gone
13+
* cold, we are the problem — not the network.
14+
*/
15+
16+
/** A tip older than this is cold. Target spacing is 120s; this is ~7 blocks. */
17+
export const STALE_TIP_SECONDS = 900
18+
19+
export interface NodeHealthInput {
20+
/** Height of the node's active chain. */
21+
nodeHeight: number
22+
/** Unix seconds of the tip block's timestamp. */
23+
tipTime: number
24+
/** Heights reported by connected peers. `-1` (not yet known) is ignored. */
25+
peerHeights: number[]
26+
/** Unix seconds now. */
27+
now: number
28+
}
29+
30+
export type NodeHealthStatus =
31+
/** Serving the chain tip, as far as anyone we can see knows. */
32+
| 'ok'
33+
/** A peer is ahead but our tip is recent — ordinary propagation delay. */
34+
| 'lagging'
35+
/** A peer is ahead AND our tip is cold: we have stopped following the chain. */
36+
| 'stalled'
37+
/** Tip is cold and no peer height is known, so health cannot be established. */
38+
| 'unknown'
39+
40+
export interface NodeHealth {
41+
status: NodeHealthStatus
42+
nodeHeight: number
43+
/** The best height anyone (us included) is known to have. */
44+
networkHeight: number
45+
/** How far behind `networkHeight` we are; never negative. */
46+
lagBlocks: number
47+
tipAgeSeconds: number
48+
}
49+
50+
export function assessNodeHealth(input: NodeHealthInput): NodeHealth {
51+
const { nodeHeight, tipTime, peerHeights, now } = input
52+
53+
// Peers report -1 until the handshake settles; those carry no information.
54+
const knownPeerHeights = peerHeights.filter((height) => Number.isFinite(height) && height >= 0)
55+
const networkHeight = Math.max(nodeHeight, ...knownPeerHeights)
56+
const lagBlocks = Math.max(0, networkHeight - nodeHeight)
57+
const tipAgeSeconds = now - tipTime
58+
const tipIsCold = tipAgeSeconds > STALE_TIP_SECONDS
59+
60+
if (lagBlocks > 0) {
61+
return { status: tipIsCold ? 'stalled' : 'lagging', nodeHeight, networkHeight, lagBlocks, tipAgeSeconds }
62+
}
63+
64+
// Nobody is ahead of us. A cold tip with no peer heights at all is the one case
65+
// we must not call healthy: there is simply nothing to compare against.
66+
if (tipIsCold && knownPeerHeights.length === 0) {
67+
return { status: 'unknown', nodeHeight, networkHeight, lagBlocks, tipAgeSeconds }
68+
}
69+
70+
return { status: 'ok', nodeHeight, networkHeight, lagBlocks, tipAgeSeconds }
71+
}

0 commit comments

Comments
 (0)