solver-api: standardize error contracts and harden parsing paths

Add structured 4xx responses and defensive parser/process behavior so clients receive consistent failures and malformed inputs are rejected deterministically.

Made-with: Cursor
This commit is contained in:
2026-04-17 08:23:25 -06:00
parent bf5e15b909
commit 01710af161
4 changed files with 90 additions and 25 deletions

View File

@@ -13,6 +13,33 @@ const DEFAULT_XML = path.join(ROOT, "data/cases/base-case.xml");
const SOLVER_BINARY_FDM = path.join(ROOT, "solver-c/solver_main"); const SOLVER_BINARY_FDM = path.join(ROOT, "solver-c/solver_main");
const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_main"); const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_main");
function parseCorsOrigins(raw) {
if (!raw || typeof raw !== "string") return null;
const origins = raw
.split(",")
.map((v) => v.trim())
.filter(Boolean);
return origins.length > 0 ? origins : null;
}
function buildCorsOptions() {
const allow = parseCorsOrigins(process.env.CORS_ORIGINS);
if (!allow) return {};
return {
origin(origin, cb) {
if (!origin || allow.includes(origin)) {
cb(null, true);
return;
}
cb(new Error("origin not allowed"));
}
};
}
function clientError(res, status, error, code = "bad_request") {
return res.status(status).json({ error, code, schemaVersion: 2 });
}
function resolveSolverModel(raw) { function resolveSolverModel(raw) {
const model = (raw || "fdm").toLowerCase(); const model = (raw || "fdm").toLowerCase();
if (model !== "fdm" && model !== "fea" && model !== "both") { if (model !== "fdm" && model !== "fea" && model !== "both") {
@@ -196,7 +223,7 @@ function stableStringify(value) {
export function buildApp() { export function buildApp() {
const app = express(); const app = express();
app.use(cors()); app.use(cors(buildCorsOptions()));
app.use(express.json({ limit: "4mb" })); app.use(express.json({ limit: "4mb" }));
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
@@ -209,7 +236,7 @@ export function buildApp() {
const parsed = await parseCaseXml(xml); const parsed = await parseCaseXml(xml);
res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) }); res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) });
} catch (error) { } catch (error) {
res.status(400).json({ error: String(error.message || error) }); clientError(res, 400, String(error.message || error), "parse_failed");
} }
}); });
@@ -217,9 +244,7 @@ export function buildApp() {
try { try {
const xml = req.body?.xml; const xml = req.body?.xml;
if (typeof xml !== "string" || !xml.trim()) { if (typeof xml !== "string" || !xml.trim()) {
return res return clientError(res, 400, "Request body must include xml string", "missing_xml");
.status(400)
.json({ error: "Request body must include xml string", schemaVersion: 2 });
} }
const parsed = await parseCaseXml(xml); const parsed = await parseCaseXml(xml);
return res.json({ return res.json({
@@ -228,9 +253,7 @@ export function buildApp() {
fieldTraceability: buildFieldTraceability(parsed) fieldTraceability: buildFieldTraceability(parsed)
}); });
} catch (error) { } catch (error) {
return res return clientError(res, 400, String(error.message || error), "parse_failed");
.status(400)
.json({ error: String(error.message || error), schemaVersion: 2 });
} }
}); });
@@ -240,7 +263,7 @@ export function buildApp() {
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {}); const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
return res.json({ ok: qa.ok, qa, schemaVersion: 2 }); return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
} catch (error) { } catch (error) {
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 }); return clientError(res, 400, String(error.message || error), "invalid_surface_card");
} }
}); });
@@ -248,13 +271,16 @@ export function buildApp() {
try { try {
const xml = req.body?.xml; const xml = req.body?.xml;
if (!xml || typeof xml !== "string") { if (!xml || typeof xml !== "string") {
return res.status(400).json({ error: "Request body must include xml string" }); return clientError(res, 400, "Request body must include xml string", "missing_xml");
} }
const solverModel = resolveSolverModel(req.body?.solverModel); const solverModel = resolveSolverModel(req.body?.solverModel);
const workflow = resolveWorkflow(req.body?.workflow); const workflow = resolveWorkflow(req.body?.workflow);
const parsed = await parseCaseXml(xml); const parsed = await parseCaseXml(xml);
let surfaceCardQa = null; let surfaceCardQa = null;
if (workflow === "diagnostic") { if (workflow === "diagnostic") {
if (!req.body?.surfaceCard) {
return clientError(res, 400, "diagnostic workflow requires surfaceCard data", "missing_surface_card");
}
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard); surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
} }
const runResults = await runRequestedModelsWithWorkflow( const runResults = await runRequestedModelsWithWorkflow(
@@ -304,7 +330,7 @@ export function buildApp() {
fingerprint fingerprint
}); });
} catch (error) { } catch (error) {
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 }); return clientError(res, 400, String(error.message || error), "solve_failed");
} }
}); });
@@ -313,10 +339,12 @@ export function buildApp() {
const solverModel = resolveSolverModel(_req.query?.solverModel); const solverModel = resolveSolverModel(_req.query?.solverModel);
const workflow = resolveWorkflow(_req.query?.workflow); const workflow = resolveWorkflow(_req.query?.workflow);
if (workflow === "diagnostic") { if (workflow === "diagnostic") {
return res.status(400).json({ return clientError(
error: "diagnostic workflow requires POST /solve with surfaceCard data", res,
schemaVersion: 2 400,
}); "diagnostic workflow requires POST /solve with surfaceCard data",
"workflow_not_supported"
);
} }
const xml = fs.readFileSync(DEFAULT_XML, "utf-8"); const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
const parsed = await parseCaseXml(xml); const parsed = await parseCaseXml(xml);
@@ -364,7 +392,7 @@ export function buildApp() {
fingerprint fingerprint
}); });
} catch (error) { } catch (error) {
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 }); return clientError(res, 400, String(error.message || error), "solve_failed");
} }
}); });

View File

@@ -245,6 +245,19 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
let stderr = ""; let stderr = "";
let outBytes = 0; let outBytes = 0;
let errBytes = 0; let errBytes = 0;
let settled = false;
const failOnce = (error) => {
if (settled) return;
settled = true;
reject(error);
};
const resolveOnce = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
child.stdout.setEncoding("utf8"); child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8"); child.stderr.setEncoding("utf8");
@@ -253,7 +266,7 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
outBytes += Buffer.byteLength(chunk, "utf8"); outBytes += Buffer.byteLength(chunk, "utf8");
if (outBytes > SOLVER_STDIO_MAX_BYTES) { if (outBytes > SOLVER_STDIO_MAX_BYTES) {
child.kill("SIGKILL"); child.kill("SIGKILL");
reject(new Error("solver stdout exceeded max buffer")); failOnce(new Error("solver stdout exceeded max buffer"));
return; return;
} }
stdout += chunk; stdout += chunk;
@@ -263,20 +276,21 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
errBytes += Buffer.byteLength(chunk, "utf8"); errBytes += Buffer.byteLength(chunk, "utf8");
if (errBytes > SOLVER_STDIO_MAX_BYTES) { if (errBytes > SOLVER_STDIO_MAX_BYTES) {
child.kill("SIGKILL"); child.kill("SIGKILL");
reject(new Error("solver stderr exceeded max buffer")); failOnce(new Error("solver stderr exceeded max buffer"));
return; return;
} }
stderr += chunk; stderr += chunk;
}); });
child.on("error", (error) => reject(error)); child.on("error", (error) => failOnce(error));
child.on("close", (code) => { child.on("close", (code) => {
if (settled) return;
if (code !== 0) { if (code !== 0) {
const detail = stderr.trim() || `solver exited with code ${code}`; const detail = stderr.trim() || `solver exited with code ${code}`;
reject(new Error(detail)); failOnce(new Error(detail));
return; return;
} }
resolve(stdout); resolveOnce(stdout);
}); });
child.stdin.write(jsonPayload); child.stdin.write(jsonPayload);

View File

@@ -8,10 +8,16 @@ const PSI_TO_PA = 6894.757293168;
const MPSI_TO_PA = 6.894757293168e9; const MPSI_TO_PA = 6.894757293168e9;
function parseArrayValue(raw) { function parseArrayValue(raw) {
return String(raw) const out = [];
.split(":") for (const item of String(raw).split(":")) {
.filter(Boolean) if (!item) continue;
.map((item) => Number(item)); const value = Number(item);
if (!Number.isFinite(value)) {
throw new Error(`Invalid numeric array value: ${item}`);
}
out.push(value);
}
return out;
} }
function parseNumeric(raw) { function parseNumeric(raw) {

View File

@@ -78,6 +78,23 @@ describe("solver-api", () => {
expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true); expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true);
}); });
it("rejects /solve without xml and returns schema metadata", async () => {
const app = buildApp();
const response = await request(app).post("/solve").send({});
expect(response.status).toBe(400);
expect(response.body.schemaVersion).toBe(2);
expect(response.body.code).toBe("missing_xml");
});
it("rejects diagnostic workflow without surface card", async () => {
const app = buildApp();
const response = await request(app)
.post("/solve")
.send({ xml, solverModel: "fdm", workflow: "diagnostic" });
expect(response.status).toBe(400);
expect(response.body.code).toBe("missing_surface_card");
});
it("is deterministic for the same input", async () => { it("is deterministic for the same input", async () => {
const app = buildApp(); const app = buildApp();
const a = await request(app).post("/solve").send({ xml }); const a = await request(app).post("/solve").send({ xml });