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_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) {
const model = (raw || "fdm").toLowerCase();
if (model !== "fdm" && model !== "fea" && model !== "both") {
@@ -196,7 +223,7 @@ function stableStringify(value) {
export function buildApp() {
const app = express();
app.use(cors());
app.use(cors(buildCorsOptions()));
app.use(express.json({ limit: "4mb" }));
app.get("/health", (_req, res) => {
@@ -209,7 +236,7 @@ export function buildApp() {
const parsed = await parseCaseXml(xml);
res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) });
} 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 {
const xml = req.body?.xml;
if (typeof xml !== "string" || !xml.trim()) {
return res
.status(400)
.json({ error: "Request body must include xml string", schemaVersion: 2 });
return clientError(res, 400, "Request body must include xml string", "missing_xml");
}
const parsed = await parseCaseXml(xml);
return res.json({
@@ -228,9 +253,7 @@ export function buildApp() {
fieldTraceability: buildFieldTraceability(parsed)
});
} catch (error) {
return res
.status(400)
.json({ error: String(error.message || error), schemaVersion: 2 });
return clientError(res, 400, String(error.message || error), "parse_failed");
}
});
@@ -240,7 +263,7 @@ export function buildApp() {
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
} 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 {
const xml = req.body?.xml;
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 workflow = resolveWorkflow(req.body?.workflow);
const parsed = await parseCaseXml(xml);
let surfaceCardQa = null;
if (workflow === "diagnostic") {
if (!req.body?.surfaceCard) {
return clientError(res, 400, "diagnostic workflow requires surfaceCard data", "missing_surface_card");
}
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
}
const runResults = await runRequestedModelsWithWorkflow(
@@ -304,7 +330,7 @@ export function buildApp() {
fingerprint
});
} 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 workflow = resolveWorkflow(_req.query?.workflow);
if (workflow === "diagnostic") {
return res.status(400).json({
error: "diagnostic workflow requires POST /solve with surfaceCard data",
schemaVersion: 2
});
return clientError(
res,
400,
"diagnostic workflow requires POST /solve with surfaceCard data",
"workflow_not_supported"
);
}
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
const parsed = await parseCaseXml(xml);
@@ -364,7 +392,7 @@ export function buildApp() {
fingerprint
});
} 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 outBytes = 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.stderr.setEncoding("utf8");
@@ -253,7 +266,7 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
outBytes += Buffer.byteLength(chunk, "utf8");
if (outBytes > SOLVER_STDIO_MAX_BYTES) {
child.kill("SIGKILL");
reject(new Error("solver stdout exceeded max buffer"));
failOnce(new Error("solver stdout exceeded max buffer"));
return;
}
stdout += chunk;
@@ -263,20 +276,21 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
errBytes += Buffer.byteLength(chunk, "utf8");
if (errBytes > SOLVER_STDIO_MAX_BYTES) {
child.kill("SIGKILL");
reject(new Error("solver stderr exceeded max buffer"));
failOnce(new Error("solver stderr exceeded max buffer"));
return;
}
stderr += chunk;
});
child.on("error", (error) => reject(error));
child.on("error", (error) => failOnce(error));
child.on("close", (code) => {
if (settled) return;
if (code !== 0) {
const detail = stderr.trim() || `solver exited with code ${code}`;
reject(new Error(detail));
failOnce(new Error(detail));
return;
}
resolve(stdout);
resolveOnce(stdout);
});
child.stdin.write(jsonPayload);

View File

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

View File

@@ -78,6 +78,23 @@ describe("solver-api", () => {
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 () => {
const app = buildApp();
const a = await request(app).post("/solve").send({ xml });