Keep dashboard control failures contained
All checks were successful
deploy / deploy (push) Successful in 34s
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:
parent
430c8b3521
commit
4d9347d55f
4 changed files with 80 additions and 11 deletions
|
|
@ -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 result = await invokeControl(control, body || {});
|
const serviceDefinition = listDashboardServices(config)
|
||||||
const serviceSnapshot = await loadServiceSnapshot(
|
.find((definition) => definition.service === control.service);
|
||||||
listDashboardServices(config).find((definition) => definition.service === control.service),
|
try {
|
||||||
);
|
const result = await invokeControl(control, body || {});
|
||||||
return sendJson(res, 200, {
|
const serviceSnapshot = await loadServiceSnapshot(serviceDefinition);
|
||||||
ok: true,
|
return sendJson(res, 200, {
|
||||||
control,
|
ok: true,
|
||||||
result,
|
control,
|
||||||
service_snapshot: serviceSnapshot,
|
result,
|
||||||
});
|
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' });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
16
test/operator-dashboard-app-static.test.mjs
Normal file
16
test/operator-dashboard-app-static.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue