Keep dashboard control failures contained
All checks were successful
deploy / deploy (push) Successful in 34s

Proof: Live dashboard preflight returned an empty client response because an upstream control timeout rejected outside the server catch path and restarted operator-dashboard. The server now awaits API handlers, wraps control proxy failures, and returns structured JSON errors instead of crashing.

Assumptions: A timed-out control proxy should be observable as a dashboard control failure, not as process death. Returning 504/502 JSON is safer than treating the action as successful or hiding the upstream state.

Still fake: This does not make unanswered solver quote requests fill; it only makes the dashboard control surface truthful and non-crashing for failed or timed-out controls.
This commit is contained in:
philipp 2026-04-12 19:11:40 +02:00
parent 430c8b3521
commit 4d9347d55f
4 changed files with 80 additions and 11 deletions

View file

@ -10,6 +10,7 @@ import { parseEventMessage } from '../core/event-envelope.mjs';
import { import {
applyDashboardLiveEvent, applyDashboardLiveEvent,
buildDashboardBootstrap, buildDashboardBootstrap,
buildDashboardControlErrorResponse,
buildLiveStatusBar, buildLiveStatusBar,
createDashboardLiveState, createDashboardLiveState,
listDashboardServices, listDashboardServices,
@ -212,7 +213,7 @@ const server = http.createServer(async (req, res) => {
if (!auth) return; if (!auth) return;
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
return handleApiRequest({ req, res, url, auth }); return await handleApiRequest({ req, res, url, auth });
} }
if (req.method === 'GET' && staticAssets.has(url.pathname)) { if (req.method === 'GET' && staticAssets.has(url.pathname)) {
@ -318,16 +319,37 @@ async function handleApiRequest({ req, res, url, auth }) {
}); });
} }
const serviceDefinition = listDashboardServices(config)
.find((definition) => definition.service === control.service);
try {
const result = await invokeControl(control, body || {}); const result = await invokeControl(control, body || {});
const serviceSnapshot = await loadServiceSnapshot( const serviceSnapshot = await loadServiceSnapshot(serviceDefinition);
listDashboardServices(config).find((definition) => definition.service === control.service),
);
return sendJson(res, 200, { return sendJson(res, 200, {
ok: true, ok: true,
control, control,
result, result,
service_snapshot: serviceSnapshot, service_snapshot: serviceSnapshot,
}); });
} catch (error) {
logger.warn('dashboard_control_failed', {
details: {
control,
error: serializeError(error),
},
});
const serviceSnapshot = await loadServiceSnapshot(serviceDefinition).catch((snapshotError) => ({
...serviceDefinition,
reachable: false,
state: null,
health: null,
error: serializeError(snapshotError),
}));
const failure = buildDashboardControlErrorResponse(error, { control });
return sendJson(res, failure.statusCode, {
...failure.payload,
service_snapshot: serviceSnapshot,
});
}
} }
return sendJson(res, 404, { error: 'not_found' }); return sendJson(res, 404, { error: 'not_found' });

View file

@ -245,6 +245,21 @@ export function resolveDashboardControl({ service, action }) {
)) || null; )) || null;
} }
export function buildDashboardControlErrorResponse(error, { control = null } = {}) {
const name = String(error?.name || 'Error');
const message = String(error?.message || 'Control request failed.');
const timedOut = name === 'TimeoutError' || /timeout|aborted/i.test(message);
return {
statusCode: timedOut ? 504 : 502,
payload: {
ok: false,
error: timedOut ? 'control_timeout' : 'control_failed',
reason: message,
control,
},
};
}
export function listDashboardServices(config) { export function listDashboardServices(config) {
return SERVICE_DEFINITIONS.map(([service, label, configKey]) => ({ return SERVICE_DEFINITIONS.map(([service, label, configKey]) => ({
service, service,

View file

@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('../src/apps/operator-dashboard.mjs', import.meta.url), 'utf8');
test('operator dashboard awaits API handler promises so request errors reach the top-level catch', () => {
assert.equal(source.includes('return await handleApiRequest({ req, res, url, auth });'), true);
assert.equal(source.includes('return handleApiRequest({ req, res, url, auth });'), false);
});
test('operator dashboard control proxy catches upstream failures before sending JSON response', () => {
assert.match(source, /dashboard_control_failed/);
assert.match(source, /buildDashboardControlErrorResponse/);
assert.match(source, /failure.statusCode/);
});

View file

@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import { import {
applyDashboardLiveEvent, applyDashboardLiveEvent,
buildDashboardBootstrap, buildDashboardBootstrap,
buildDashboardControlErrorResponse,
buildLiveStatusBar, buildLiveStatusBar,
buildProfitabilitySummary, buildProfitabilitySummary,
createDashboardLiveState, createDashboardLiveState,
@ -104,6 +105,21 @@ test('profitability summary flags cash-flow-adjusted benchmarks after later fund
assert.match(summary.caveats[0], /external cash flows/); assert.match(summary.caveats[0], /external cash flows/);
}); });
test('dashboard control errors become structured responses instead of uncaught failures', () => {
const control = resolveDashboardControl({
service: 'trade-executor',
action: 'intent-request-preflight',
});
const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
const response = buildDashboardControlErrorResponse(timeout, { control });
assert.equal(response.statusCode, 504);
assert.equal(response.payload.ok, false);
assert.equal(response.payload.error, 'control_timeout');
assert.equal(response.payload.control.action, 'intent-request-preflight');
assert.match(response.payload.reason, /timeout/i);
});
test('control routing only resolves the allowlisted safe dashboard actions', () => { test('control routing only resolves the allowlisted safe dashboard actions', () => {
const refresh = resolveDashboardControl({ const refresh = resolveDashboardControl({
service: 'liquidity-manager', service: 'liquidity-manager',