Skip to content

Commit e3d2cff

Browse files
authored
fix(feature-gate): measure slot time instead of assuming 400ms (solana-foundation#1279)
1 parent 4231880 commit e3d2cff

16 files changed

Lines changed: 1077 additions & 36 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { MEASURED_SAMPLES } from '@entities/slot-time/server';
2+
import {
3+
createSolanaRpc,
4+
SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR,
5+
SOLANA_ERROR__JSON_RPC__METHOD_NOT_FOUND,
6+
SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR,
7+
SolanaError,
8+
} from '@solana/kit';
9+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10+
11+
import { Logger } from '@/app/shared/lib/logger';
12+
import { ROUTE_TIMEOUT_MS, UPSTREAM_TIMEOUT_MS } from '@/app/shared/lib/timeouts';
13+
import { Cluster } from '@/app/utils/cluster';
14+
15+
import { GET, maxDuration } from '../route';
16+
17+
const mocks = vi.hoisted(() => ({ getRecentPerformanceSamples: vi.fn(), send: vi.fn() }));
18+
19+
vi.mock('@solana/kit', async () => {
20+
const actual = await vi.importActual<typeof import('@solana/kit')>('@solana/kit');
21+
return {
22+
...actual,
23+
createSolanaRpc: vi.fn(() => ({ getRecentPerformanceSamples: mocks.getRecentPerformanceSamples })),
24+
};
25+
});
26+
27+
// Stubbed, so a local `.env` cannot decide what the route resolves to — an empty one would fail these
28+
// tests for a reason that has nothing to do with the route.
29+
const MAINNET_RPC_URL = 'https://mainnet.test/rpc';
30+
const TESTNET_RPC_URL = 'https://testnet.test/rpc';
31+
32+
const NO_STORE = 'no-store, max-age=0';
33+
const ERROR_CACHE = 'public, max-age=30, s-maxage=30';
34+
35+
// Testnet in September 2026: 319 slots a minute, the rate the 200 ms gate produced there.
36+
const SAMPLES = [{ numSlots: 319n, samplePeriodSecs: 60 }];
37+
38+
describe('GET /api/slot-time', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
vi.stubEnv('MAINNET_RPC_URL', MAINNET_RPC_URL);
42+
vi.stubEnv('TESTNET_RPC_URL', TESTNET_RPC_URL);
43+
mocks.getRecentPerformanceSamples.mockReturnValue({ send: mocks.send });
44+
mocks.send.mockResolvedValue(SAMPLES);
45+
});
46+
47+
afterEach(() => {
48+
vi.restoreAllMocks();
49+
vi.unstubAllEnvs();
50+
});
51+
52+
// Otherwise a route that resolved every cluster to mainnet would pass the whole suite, and testnet
53+
// visitors would count down at mainnet's rate — the very mix-up this route exists to end.
54+
it.each([
55+
['mainnet-beta', Cluster.MainnetBeta, MAINNET_RPC_URL],
56+
['testnet', Cluster.Testnet, TESTNET_RPC_URL],
57+
])('should dial the endpoint configured for %s', async (_name, cluster, expectedUrl) => {
58+
await GET(createRequest(cluster));
59+
60+
expect(createSolanaRpc).toHaveBeenCalledWith(expectedUrl);
61+
});
62+
63+
it('should return the measured rate', async () => {
64+
const response = await GET(createRequest(Cluster.Testnet));
65+
66+
expect(response.status).toBe(200);
67+
expect(await response.json()).toEqual({ msPerSlot: 188 });
68+
});
69+
70+
it('should measure over the window both fetch paths use', async () => {
71+
await GET(createRequest(Cluster.MainnetBeta));
72+
73+
expect(mocks.getRecentPerformanceSamples).toHaveBeenCalledWith(MEASURED_SAMPLES);
74+
});
75+
76+
it('should cache a successful response at the CDN', async () => {
77+
const response = await GET(createRequest(Cluster.MainnetBeta));
78+
79+
expect(response.headers.get('Cache-Control')).toBe(
80+
'public, max-age=60, s-maxage=300, stale-while-revalidate=3600',
81+
);
82+
});
83+
84+
// The value, not just the presence: a signal is a signal at any duration, and one set above the
85+
// function's own budget lets the platform kill the invocation before the classified answer is written.
86+
it('should bound the call with a deadline, so a wedged node cannot hold the function open', async () => {
87+
const timeout = vi.spyOn(AbortSignal, 'timeout');
88+
89+
await GET(createRequest(Cluster.MainnetBeta));
90+
91+
expect(mocks.send).toHaveBeenCalledWith({ abortSignal: expect.any(AbortSignal) });
92+
expect(timeout).toHaveBeenCalledWith(UPSTREAM_TIMEOUT_MS);
93+
});
94+
95+
it('should keep the RPC bound under the function duration, and that under the browser’s', () => {
96+
expect(UPSTREAM_TIMEOUT_MS).toBeLessThan(maxDuration * 1000);
97+
expect(maxDuration * 1000).toBeLessThan(ROUTE_TIMEOUT_MS);
98+
});
99+
100+
describe('requests it will not answer', () => {
101+
it('should return an uncached 400 when the cluster param is missing', async () => {
102+
const response = await GET(new Request('http://localhost:3000/api/slot-time'));
103+
104+
expect(response.status).toBe(400);
105+
expect(await response.json()).toEqual({ error: 'Invalid query params' });
106+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE);
107+
expect(mocks.getRecentPerformanceSamples).not.toHaveBeenCalled();
108+
});
109+
110+
// The CDN keys on the whole URL, so every extra spelling of one request is another miss. One
111+
// shape gets answered, and nothing else reaches the node.
112+
it.each([
113+
['an unexpected extra param', `?cluster=${Cluster.MainnetBeta}&bust=1`],
114+
['a repeated param', `?cluster=${Cluster.MainnetBeta}&cluster=${Cluster.MainnetBeta}`],
115+
['a percent-encoded digit', '?cluster=%30'],
116+
['the param in another position', `?bust=1&cluster=${Cluster.MainnetBeta}`],
117+
])('should refuse %s without asking the node', async (_reason, query) => {
118+
const response = await GET(new Request(`http://localhost:3000/api/slot-time${query}`));
119+
120+
expect(response.status).toBe(400);
121+
expect(await response.json()).toEqual({ error: 'Invalid query params' });
122+
expect(mocks.getRecentPerformanceSamples).not.toHaveBeenCalled();
123+
});
124+
125+
// The server must never resolve a custom endpoint: its URL comes from the caller. An unknown
126+
// cluster is the same refusal for the same reason.
127+
it.each([
128+
['the custom cluster', Cluster.Custom.toString()],
129+
['an unknown cluster', '999'],
130+
['a param that is not an integer', '0x0'],
131+
['a leading-zero param, which Number() would coerce', '01'],
132+
])('should return an uncached 400 for %s', async (_reason, cluster) => {
133+
const response = await GET(new Request(`http://localhost:3000/api/slot-time?cluster=${cluster}`));
134+
135+
expect(response.status).toBe(400);
136+
expect(await response.json()).toEqual({ error: 'Invalid cluster' });
137+
expect(response.headers.get('Cache-Control')).toBe(NO_STORE);
138+
expect(mocks.getRecentPerformanceSamples).not.toHaveBeenCalled();
139+
});
140+
141+
// Anyone can ask for these, so a report would be a way for anyone to raise an alert. Pinned on the
142+
// call shape rather than the level: a warning reaches Sentry too, the moment it is handed a
143+
// context, so asserting the level alone would let one be added here unnoticed.
144+
it.each([
145+
['a query that is not the canonical shape', `?cluster=${Cluster.MainnetBeta}&bust=1`],
146+
['a cluster the server must not resolve', '?cluster=999'],
147+
])('should keep the refusal for %s out of Sentry', async (_reason, query) => {
148+
await GET(new Request(`http://localhost:3000/api/slot-time${query}`));
149+
150+
expect(Logger.warn).toHaveBeenCalled();
151+
expect(vi.mocked(Logger.warn).mock.calls.map(([, context]) => context)).not.toContainEqual(
152+
expect.objectContaining({ sentry: true }),
153+
);
154+
expect(Logger.error).not.toHaveBeenCalled();
155+
expect(Logger.panic).not.toHaveBeenCalled();
156+
});
157+
});
158+
159+
// A cluster we own with no endpoint set: every countdown on it is absent until someone fixes that,
160+
// and no caller can provoke it, so this is the one refusal that has to reach Sentry.
161+
it('should report a known cluster with no endpoint configured', async () => {
162+
vi.stubEnv('TESTNET_RPC_URL', '');
163+
164+
const response = await GET(createRequest(Cluster.Testnet));
165+
166+
expect(response.status).toBe(500);
167+
expect(await response.json()).toEqual({ error: 'Cluster not configured' });
168+
expect(response.headers.get('Cache-Control')).toBe(ERROR_CACHE);
169+
expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), reported({ cluster: Cluster.Testnet.toString() }));
170+
expect(mocks.getRecentPerformanceSamples).not.toHaveBeenCalled();
171+
});
172+
173+
describe('upstream failures', () => {
174+
// The route is the only place that can catch this. Served as a 200 it would be cached for
175+
// everyone, and every countdown would then be drawn against a rate nothing measured.
176+
it.each([
177+
['samples covering no slot', [{ numSlots: 0n, samplePeriodSecs: 60 }]],
178+
['no samples at all', []],
179+
])('should refuse %s rather than serve a rate', async (_reason, samples) => {
180+
mocks.send.mockResolvedValueOnce(samples);
181+
182+
const response = await GET(createRequest(Cluster.MainnetBeta));
183+
184+
// Unclassified: one node behind a balancer may have history the next one lacks, so it stays
185+
// re-askable.
186+
expect(response.status).toBe(503);
187+
expect(response.headers.get('Cache-Control')).toBe(ERROR_CACHE);
188+
expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), reported({ reason: 'unclassified' }));
189+
});
190+
191+
// Warned, never escalated: this route is public, so a slow node must not become a way to page
192+
// anyone. Cached briefly, so one visitor's retries cost a cache hit rather than another call.
193+
it('should warn and briefly cache a 504 when the node misses the deadline', async () => {
194+
mocks.send.mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError'));
195+
196+
const response = await GET(createRequest(Cluster.Testnet));
197+
198+
expect(response.status).toBe(504);
199+
expect(response.headers.get('Cache-Control')).toBe(ERROR_CACHE);
200+
expect(Logger.warn).toHaveBeenCalledWith(
201+
expect.any(String),
202+
reported({ cluster: Cluster.Testnet.toString() }),
203+
);
204+
expect(Logger.error).not.toHaveBeenCalled();
205+
expect(Logger.panic).not.toHaveBeenCalled();
206+
});
207+
208+
// 503 rather than 502: the client retries this tier and leaves the refusal tier alone, which it
209+
// cannot do if both answer with one status.
210+
it('should warn and briefly cache a 503 on a transient RPC error', async () => {
211+
mocks.send.mockRejectedValueOnce(
212+
new SolanaError(SOLANA_ERROR__JSON_RPC__INTERNAL_ERROR, { __serverMessage: 'Internal error' }),
213+
);
214+
215+
const response = await GET(createRequest(Cluster.MainnetBeta));
216+
217+
expect(response.status).toBe(503);
218+
expect(await response.json()).toEqual({ error: 'Upstream RPC error' });
219+
expect(response.headers.get('Cache-Control')).toBe(ERROR_CACHE);
220+
expect(Logger.warn).toHaveBeenCalledWith(expect.any(String), reported({ rpcError: expect.any(String) }));
221+
expect(Logger.error).not.toHaveBeenCalled();
222+
});
223+
224+
// Needs a configuration change, not a page — a node that serves no performance samples at all is
225+
// the likely one here.
226+
it.each([
227+
[
228+
'a method the node will not serve',
229+
new SolanaError(SOLANA_ERROR__JSON_RPC__METHOD_NOT_FOUND, { __serverMessage: 'Method not found' }),
230+
],
231+
['credentials the node will not take', httpError(401)],
232+
])('should report, and briefly cache, %s', async (_reason, error) => {
233+
mocks.send.mockRejectedValueOnce(error);
234+
235+
const response = await GET(createRequest(Cluster.MainnetBeta));
236+
237+
expect(response.status).toBe(502);
238+
expect(response.headers.get('Cache-Control')).toBe(ERROR_CACHE);
239+
expect(Logger.error).toHaveBeenCalledWith(expect.anything(), reported({ reason: 'rpc-refused' }));
240+
expect(Logger.panic).not.toHaveBeenCalled();
241+
});
242+
243+
// Nothing here can say the next attempt fails the same way, so 502 stays reserved for a refusal.
244+
it('should report an unrecognised connection failure, and leave it re-askable', async () => {
245+
mocks.send.mockRejectedValueOnce(connectionFailure('ENOTFOUND'));
246+
247+
const response = await GET(createRequest(Cluster.MainnetBeta));
248+
249+
expect(response.status).toBe(503);
250+
expect(await response.json()).toEqual({ error: 'Failed to measure slot time' });
251+
expect(Logger.error).toHaveBeenCalledWith(expect.any(Error), reported({ reason: 'unclassified' }));
252+
expect(Logger.panic).not.toHaveBeenCalled();
253+
});
254+
});
255+
});
256+
257+
function createRequest(cluster: Cluster) {
258+
return new Request(`http://localhost:3000/api/slot-time?cluster=${cluster}`);
259+
}
260+
261+
/** A node answering over HTTP rather than in JSON-RPC: a gateway, a key check, a wrong path. */
262+
function httpError(statusCode: number) {
263+
return new SolanaError(SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, {
264+
headers: new Headers(),
265+
message: `HTTP ${statusCode}`,
266+
statusCode,
267+
});
268+
}
269+
270+
/** Shaped the way undici reports one: the code sits on an `Error` nested as `cause`. */
271+
function connectionFailure(code: string) {
272+
const cause = Object.assign(new Error(`${code} on connect`), { code });
273+
return Object.assign(new TypeError('fetch failed'), { cause });
274+
}
275+
276+
/**
277+
* What has to be on a log call for the failure to reach Sentry at all: the flag, and the reason under
278+
* `sentryExtras`, which is the only part of a context Sentry receives. A bare `toHaveBeenCalled` passes
279+
* with both dropped, and the tier then fails silently for everyone.
280+
*/
281+
function reported(extras: Record<string, unknown>) {
282+
return expect.objectContaining({ sentry: true, sentryExtras: expect.objectContaining(extras) });
283+
}

app/api/slot-time/route.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { resolveServerClusterUrl } from '@entities/cluster/server';
2+
import { MEASURED_SAMPLES, toSlotTimePayload } from '@entities/slot-time/server';
3+
import { isRetryableError, isRpcMisconfigError } from '@shared/lib/errors';
4+
import { ERROR_CACHE_HEADERS, isTimeoutError, NO_STORE_HEADERS } from '@shared/lib/http-utils';
5+
import { Logger } from '@shared/lib/logger';
6+
import { UPSTREAM_TIMEOUT_MS } from '@shared/lib/timeouts';
7+
import { createSolanaRpc } from '@solana/kit';
8+
import { NextResponse } from 'next/server';
9+
10+
// The rate moves only when a slot-time feature gate activates, at an epoch boundary. A few minutes of
11+
// staleness costs a countdown nothing, and every visitor asks for the same figure.
12+
const SLOT_TIME_CACHE_HEADERS = {
13+
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=3600',
14+
};
15+
16+
// Above the RPC bound this route passes to the node, so that bound is what fires and the classified
17+
// branches are what answer, rather than the platform killing the invocation with no headers on the way out.
18+
export const maxDuration = 35;
19+
20+
/** Known clusters only. A custom URL comes from the caller, so the server must never be aimed at it. */
21+
export async function GET(request: Request) {
22+
const { search, searchParams } = new URL(request.url);
23+
const clusterParam = searchParams.get('cluster');
24+
25+
// The CDN keys on the whole URL, so a second spelling of one request is a fresh miss. Comparing the
26+
// raw query against the param it parsed to leaves one URL that reaches the node: the one every
27+
// visitor's client already sends.
28+
if (clusterParam === null || search !== `?cluster=${clusterParam}`) {
29+
// Console only, like every refusal a caller can provoke: reporting one would hand anyone a way
30+
// to raise alerts.
31+
Logger.warn('[api:slot-time] Rejected a query that is not the canonical shape');
32+
return NextResponse.json({ error: 'Invalid query params' }, { headers: NO_STORE_HEADERS, status: 400 });
33+
}
34+
35+
const context = { cluster: clusterParam };
36+
const resolved = resolveServerClusterUrl(clusterParam);
37+
38+
if (resolved.kind === 'refused') {
39+
Logger.warn('[api:slot-time] Refused a cluster the server must not resolve', context);
40+
return NextResponse.json({ error: 'Invalid cluster' }, { headers: NO_STORE_HEADERS, status: 400 });
41+
}
42+
43+
if (resolved.kind === 'unconfigured') {
44+
// Ours, and no caller can provoke it: every countdown on this cluster is absent until someone
45+
// sets an endpoint for it, and nothing else would say so.
46+
Logger.error(new Error('[api:slot-time] No endpoint configured for cluster'), {
47+
sentry: true,
48+
sentryExtras: context,
49+
});
50+
return NextResponse.json({ error: 'Cluster not configured' }, { headers: ERROR_CACHE_HEADERS, status: 500 });
51+
}
52+
53+
try {
54+
const rpc = createSolanaRpc(resolved.url);
55+
const samples = await rpc
56+
.getRecentPerformanceSamples(MEASURED_SAMPLES)
57+
// A wedged node would otherwise hold the function open long past any useful answer.
58+
.send({ abortSignal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) });
59+
60+
return NextResponse.json(toSlotTimePayload(samples), { headers: SLOT_TIME_CACHE_HEADERS });
61+
} catch (error) {
62+
if (isTimeoutError(error)) {
63+
Logger.warn('[api:slot-time] RPC request timed out', { sentry: true, sentryExtras: context });
64+
return NextResponse.json(
65+
{ error: 'Upstream request timed out' },
66+
{ headers: ERROR_CACHE_HEADERS, status: 504 },
67+
);
68+
}
69+
70+
if (isRetryableError(error)) {
71+
Logger.warn('[api:slot-time] RPC error fetching performance samples', {
72+
sentry: true,
73+
sentryExtras: { ...context, rpcError: error instanceof Error ? error.message : String(error) },
74+
});
75+
return NextResponse.json({ error: 'Upstream RPC error' }, { headers: ERROR_CACHE_HEADERS, status: 503 });
76+
}
77+
78+
// 502 is what tells the client not to ask again: someone has to change configuration first.
79+
if (isRpcMisconfigError(error)) {
80+
Logger.error(error, { sentry: true, sentryExtras: { ...context, reason: 'rpc-refused' } });
81+
return NextResponse.json({ error: 'Upstream RPC error' }, { headers: ERROR_CACHE_HEADERS, status: 502 });
82+
}
83+
84+
// A node answering with samples that state no rate lands here too, alongside the connection
85+
// failures nothing above recognises. Both are worth looking at, and neither says the next attempt
86+
// fails the same way, so 502 stays reserved for a node that refuses.
87+
Logger.error(new Error('[api:slot-time] Request failed', { cause: error }), {
88+
sentry: true,
89+
sentryExtras: {
90+
...context,
91+
reason: 'unclassified',
92+
rpcError: error instanceof Error ? error.message : String(error),
93+
},
94+
});
95+
return NextResponse.json(
96+
{ error: 'Failed to measure slot time' },
97+
{ headers: ERROR_CACHE_HEADERS, status: 503 },
98+
);
99+
}
100+
}

0 commit comments

Comments
 (0)