mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-11 17:37:40 +00:00
Follow-up to #2127: @dborup's point 2 there, and @efiten's non-blocking note on the approval ("worth checking the node page's reach panel shows a retry hint on 429 rather than a generic error"). ## What changes - **The reach page retries a 429 itself.** It shows "Reach is busy computing other reports, retrying in N s", waits `Retry-After` seconds, and tries again. - When the header is missing or is not a number of seconds (an HTTP-date, 0, a negative number), it waits 2 s. It never waits more than 30 s. - After 5 retries it shows the old error ("Failed to load reach: API 429 …"). - Leaving the page stops the retries, both while it waits and while a request is still in flight. Picking another window while a request is in flight also drops that request's retries. - **`api()` does not retry 429.** Login, signup, activation, email change, companion linking, settings PUT and channel proposals also answer 429 from their own limits, and those must not auto-retry. `api()` now puts `status` and `retryAfter` on the errors it throws, so a caller can decide; the message text is unchanged. A unit test pins that `api()` makes exactly one request on a 429. - **Fix in `api()`:** the `_inflight` cleanup chain (`promise.finally(...)`) rejected unhandled on every failed call, so each API error also logged "Uncaught (in promise)". That would have added up to 5 more per busy reach load. The chain now catches; callers still get the rejection through the returned promise. This works with the server as it is on master (instant 429, `Retry-After: 5`), and with the bounded wait in #2249 (429 after 3 s, `Retry-After: 2`). Neither PR depends on the other. ## What I checked - **Suites.** `sh test-all.sh` passes, the XSS sink gate passes, and eslint has 0 errors and no new warnings. - **Unit tests** (`test-frontend-helpers.js`): - `reachRetryDelayMs`: Retry-After values; fallback on missing, zero, negative or HTTP-date values; the 30 s cap; giving up after 5 retries; no retry on 400, 404, 500, 503 or network errors. - `api()`: a 429 error carries `status` and `retryAfter`, makes exactly one request, and leaves no unhandled rejection. - The block sits above the file's `Promise.allSettled(pendingTests)` summary. Async tests registered below that line are never awaited, so they cannot fail the run; nothing below it is async today. - **E2E** (`test-node-reach-e2e.js`, already in CI suite 2): reach requests are answered through `page.route`, so the server does not have to be made busy. It checks: - two 429s, then the real report: the hint shows "retrying in 1 s", the retries wait (at least 1.8 s between the first and third request), and no error is shown; - always 429: 6 requests (1 + 5 retries), then the error; - leaving while it waits: no more requests; - leaving while the first request is still in flight: no hint written, no retry; - a 500 with Retry-After: one request, no retry. On master's frontend the new section fails at the first busy hint. Locally against the freshened `e2e-fixture.db`, the whole file takes about 17 s (2 runs). - **Mutations.** I wrote 15 from the code, and the tests caught all 15. They include: `api()` drops `status` or `retryAfter`, `api()` retries 429 itself, retry any error, retry limit off by one, fallback or cap changed, Retry-After 0 accepted, either "left the page" check removed, the wait not honoured, the hint showing ms, the retry count ignored, no error after giving up, and the cleanup chain unhandled again. Each mutation was checked against the file the server actually served before the tests ran. - **Real browser** (Chromium via Playwright, on a copy of a 10.5M-observation database): - Current master's server, 6 tabs opened at once on uncached 14-day reports: 4 got 429 with `Retry-After: 5` and showed the hint, and all 6 reports rendered within 11.5 s (one tab retried twice). Without this change those 4 tabs show "Failed to load reach: API 429". - With #2249's server: 4 of 6 got 429 with `Retry-After: 2`, and all rendered within 8.1 s. - The only console errors were Chrome's own "Failed to load resource" lines for the 429s; there was no "Uncaught (in promise)". ## Not checked - **Mobile layout.** The hint reuses the existing `.nq-load` style, so I did not take a screenshot at phone width. - **Other browsers.** Firefox and Safari were not tried; the code is plain `setTimeout` / `await`. - **Window switches.** While the hint is showing, the time-range buttons are not on screen; the same is true of the existing error and loading states. Picking a new window means waiting, or going back and opening reach again. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: nullrouten0 <201262769+nullrouten0@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
7.7 KiB
JavaScript
155 lines
7.7 KiB
JavaScript
// E2E for the per-node Reach page (#/nodes/<pubkey>/reach).
|
|
// Defaults to localhost:3000 — NEVER point at prod (AGENTS.md). CI sets BASE_URL.
|
|
const { chromium } = require('playwright');
|
|
const BASE = process.env.BASE_URL || 'http://localhost:3000';
|
|
|
|
async function getJson(page, url) {
|
|
const resp = await page.request.get(url);
|
|
if (!resp.ok()) throw new Error('GET ' + url + ' → HTTP ' + resp.status());
|
|
return resp.json();
|
|
}
|
|
|
|
(async () => {
|
|
const browser = await chromium.launch();
|
|
const page = await browser.newPage();
|
|
|
|
// A repeater is most likely to have reach data (it relays).
|
|
const nodes = await getJson(page, BASE + '/api/nodes?role=repeater&limit=1');
|
|
if (!nodes.nodes || !nodes.nodes.length) {
|
|
console.log('node-reach E2E SKIP (no repeater in dataset)');
|
|
await browser.close();
|
|
return;
|
|
}
|
|
const pk = nodes.nodes[0].public_key;
|
|
|
|
// 1. The endpoint returns the documented shape.
|
|
const reach = await getJson(page, BASE + '/api/nodes/' + pk + '/reach?days=7');
|
|
for (const k of ['node', 'window', 'reliable_tokens', 'importance', 'links', 'direct_observers']) {
|
|
if (!(k in reach)) throw new Error('reach response missing key: ' + k);
|
|
}
|
|
if (!Array.isArray(reach.links)) throw new Error('reach.links must be an array');
|
|
|
|
// 2. The page renders.
|
|
await page.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await page.waitForSelector('.nq-head', { timeout: 20000 });
|
|
if (!(await page.locator('h2', { hasText: 'Reach' }).count())) {
|
|
throw new Error('Reach header missing');
|
|
}
|
|
|
|
// 3. If this node is identifiable, exercise the table, toggles and links.
|
|
if (reach.reliable_tokens.length && (await page.locator('#nqRows').count())) {
|
|
await page.waitForSelector('#nqIncoming');
|
|
await page.waitForSelector('#nqOutgoing');
|
|
|
|
// Derive the EXACT expected row counts from the API so the toggles are
|
|
// verified, not just "didn't shrink" (tautology). Base shows two-way only;
|
|
// incoming adds we-only links; +outgoing adds the rest (= all links).
|
|
const twoWayExp = reach.links.filter(l => l.bidir).length;
|
|
const weOnlyExp = reach.links.filter(l => !l.bidir && l.we_hear > 0 && l.they_hear === 0).length;
|
|
const allExp = reach.links.length;
|
|
|
|
const base = await page.locator('#nqRows tr').count();
|
|
if (base !== twoWayExp) throw new Error(`base rows ${base} != two-way ${twoWayExp}`);
|
|
await page.check('#nqIncoming');
|
|
const withIncoming = await page.locator('#nqRows tr').count();
|
|
if (withIncoming !== twoWayExp + weOnlyExp) {
|
|
throw new Error(`incoming rows ${withIncoming} != two-way+we-only ${twoWayExp + weOnlyExp}`);
|
|
}
|
|
await page.check('#nqOutgoing');
|
|
const withBoth = await page.locator('#nqRows tr').count();
|
|
if (withBoth !== allExp) throw new Error(`both-toggles rows ${withBoth} != all links ${allExp}`);
|
|
|
|
// Neighbour rows link to a node detail page.
|
|
if (await page.locator('#nqRows a.nq-link').count()) {
|
|
const href = await page.locator('#nqRows a.nq-link').first().getAttribute('href');
|
|
if (!href || !href.startsWith('#/nodes/')) throw new Error('neighbour link malformed: ' + href);
|
|
}
|
|
|
|
// Leaflet puts .leaflet-container on the element you hand L.map(), so the
|
|
// class lands on #nqMap itself. The old selector had a space in it and so
|
|
// waited for a DESCENDANT carrying the class, which never exists: that is
|
|
// why this suite timed out here every time it was run.
|
|
//
|
|
// The condition is the node's own coordinates, not a link's:
|
|
// public/node-reach.js calls NodeReachMap.render only when n.lat != null,
|
|
// so a node with positioned neighbours but no position of its own has no
|
|
// map to wait for.
|
|
if (reach.node && reach.node.lat != null && reach.node.lon != null) {
|
|
await page.waitForSelector('#nqMap.leaflet-container', { timeout: 10000 });
|
|
}
|
|
}
|
|
|
|
// 4. A busy server (429 + Retry-After) is retried by the reach page itself:
|
|
// a "retrying" hint, then the report, with no error. The reach requests are
|
|
// answered by a route so the server does not have to be made busy.
|
|
const reachPath = '/api/nodes/' + pk + '/reach';
|
|
async function withReachRoute(answer, fn) {
|
|
const p = await browser.newPage();
|
|
const seen = { n: 0, at: [] };
|
|
await p.route((url) => new URL(url).pathname === reachPath, async (route) => {
|
|
seen.n++;
|
|
seen.at.push(Date.now());
|
|
const a = await answer(seen.n);
|
|
if (a === 'pass') return route.continue();
|
|
return route.fulfill(a);
|
|
});
|
|
try { await fn(p, seen); } finally { await p.close(); }
|
|
}
|
|
const busy = { status: 429, headers: { 'Retry-After': '1', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ error: 'reach is busy computing other reports', retryAfter: 1 }) };
|
|
|
|
await withReachRoute((n) => (n <= 2 ? busy : 'pass'), async (p, seen) => {
|
|
await p.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await p.waitForSelector('.nq-busy', { timeout: 10000 });
|
|
const hint = await p.locator('.nq-busy').textContent();
|
|
if (!hint.includes('retrying in 1 s')) throw new Error('busy hint text: ' + hint);
|
|
await p.waitForSelector('.nq-head', { timeout: 20000 });
|
|
if (await p.locator('.nq-error').count()) throw new Error('error shown after a successful retry');
|
|
if (seen.n !== 3) throw new Error('reach requests ' + seen.n + ' != 3 (2 busy + 1 served)');
|
|
// Two waits of Retry-After: 1 s each between the first and third request.
|
|
const gap = seen.at[2] - seen.at[0];
|
|
if (gap < 1800) throw new Error('retries did not wait for Retry-After: ' + gap + ' ms for two retries');
|
|
});
|
|
|
|
// 5. A server that stays busy: 5 retries, then the error.
|
|
await withReachRoute(() => busy, async (p, seen) => {
|
|
await p.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await p.waitForSelector('.nq-error', { timeout: 20000 });
|
|
const msg = await p.locator('.nq-error').textContent();
|
|
if (!msg.includes('API 429')) throw new Error('give-up error text: ' + msg);
|
|
if (seen.n !== 6) throw new Error('reach requests ' + seen.n + ' != 6 (1 + 5 retries)');
|
|
});
|
|
|
|
// 6. Leaving the page while it waits stops the retries.
|
|
await withReachRoute(() => busy, async (p, seen) => {
|
|
await p.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await p.waitForSelector('.nq-busy', { timeout: 10000 });
|
|
const atLeave = seen.n;
|
|
await p.evaluate(() => { location.hash = '#/nodes'; });
|
|
await p.waitForTimeout(2500);
|
|
if (seen.n !== atLeave) throw new Error('reach retried after leaving the page: ' + atLeave + ' → ' + seen.n);
|
|
});
|
|
|
|
// 6b. Leaving while the request is still in flight: the late 429 must not
|
|
// start a retry or write the hint into the page that replaced this one.
|
|
await withReachRoute(async () => { await new Promise((r) => setTimeout(r, 1000)); return busy; }, async (p, seen) => {
|
|
await p.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await p.waitForFunction(() => document.querySelector('.nq-load') !== null, null, { timeout: 10000 });
|
|
await p.evaluate(() => { location.hash = '#/nodes'; });
|
|
await p.waitForTimeout(2500);
|
|
if (await p.locator('.nq-busy').count()) throw new Error('busy hint written after leaving the page');
|
|
if (seen.n !== 1) throw new Error('reach retried after leaving mid-request: ' + seen.n + ' requests');
|
|
});
|
|
|
|
// 7. Other errors are not retried.
|
|
await withReachRoute(() => ({ status: 500, body: '{"error":"reach computation failed"}', headers: { 'Retry-After': '1' } }), async (p, seen) => {
|
|
await p.goto(BASE + '/#/nodes/' + pk + '/reach');
|
|
await p.waitForSelector('.nq-error', { timeout: 10000 });
|
|
await p.waitForTimeout(1500);
|
|
if (seen.n !== 1) throw new Error('a 500 was retried: ' + seen.n + ' requests');
|
|
});
|
|
|
|
console.log('node-reach E2E OK');
|
|
await browser.close();
|
|
})().catch((e) => { console.error(e); process.exit(1); });
|