mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
The Analytics page discarded the server's error body on a non-OK response and rendered a generic "An error occurred", so users (and maintainers) could not see why /api/usage/analytics 500'd after an upgrade. Now the route returns the real reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy error shapes. Reported-by: @superti4r
This commit is contained in:
committed by
GitHub
parent
549e2aeeb8
commit
c8031be906
@@ -1321,6 +1321,12 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json(analytics);
|
||||
} catch (error) {
|
||||
console.error("Error computing analytics:", error);
|
||||
return NextResponse.json({ error: "Failed to compute analytics" }, { status: 500 });
|
||||
// Surface the real (sanitized) reason so the dashboard can show it instead of a
|
||||
// generic placeholder (#3356). buildErrorBody strips stacks/absolute paths.
|
||||
const { buildErrorBody } = await import("@omniroute/open-sse/utils/error");
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json(buildErrorBody(500, message || "Failed to compute analytics"), {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import Card from "./Card";
|
||||
import { CardSkeleton } from "./Loading";
|
||||
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
|
||||
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
|
||||
import {
|
||||
StatCard,
|
||||
CompactStatGrid,
|
||||
@@ -59,7 +60,7 @@ export default function UsageAnalytics() {
|
||||
params.set("apiKeyIds", selectedApiKeys.join(","));
|
||||
}
|
||||
const res = await fetch(`/api/usage/analytics?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(tCommon("error"));
|
||||
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
|
||||
const data = await res.json();
|
||||
setAnalytics(data);
|
||||
setError(null);
|
||||
|
||||
26
src/shared/utils/fetchError.ts
Normal file
26
src/shared/utils/fetchError.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Extract a human-readable message from a failed `fetch` Response body.
|
||||
*
|
||||
* Handles both response shapes OmniRoute routes emit:
|
||||
* - OpenAI-style `{ error: { message, type, code } }` (from `buildErrorBody`)
|
||||
* - legacy `{ error: "..." }` string bodies
|
||||
*
|
||||
* The server already sanitizes these messages (stack traces / absolute paths
|
||||
* stripped via `sanitizeErrorMessage`), so surfacing them in the UI is safe.
|
||||
* Falls back to `fallback` when the body is absent, unparseable, or carries no
|
||||
* usable message. Never throws -- safe to call directly inside a fetch guard.
|
||||
*/
|
||||
export async function readFetchErrorMessage(res: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const body = (await res.json()) as unknown;
|
||||
const err = (body as { error?: unknown } | null)?.error;
|
||||
if (typeof err === "string" && err.trim()) return err.trim();
|
||||
if (err && typeof err === "object") {
|
||||
const message = (err as { message?: unknown }).message;
|
||||
if (typeof message === "string" && message.trim()) return message.trim();
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body (e.g. an HTML 500 page) or a read failure -> use the fallback.
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
47
tests/unit/fetch-error-message.test.ts
Normal file
47
tests/unit/fetch-error-message.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { readFetchErrorMessage } from "../../src/shared/utils/fetchError.ts";
|
||||
|
||||
const FALLBACK = "An error occurred";
|
||||
|
||||
function jsonResponse(body: unknown, status = 500) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// #3356: the Analytics page rendered a generic "An error occurred" because it
|
||||
// discarded the server's error body. The page must surface the real (already
|
||||
// sanitized server-side) message instead.
|
||||
|
||||
test("reads the OpenAI-style { error: { message } } shape from buildErrorBody", async () => {
|
||||
const res = jsonResponse({ error: { message: "Failed to compute analytics", type: "api_error" } });
|
||||
assert.equal(await readFetchErrorMessage(res, FALLBACK), "Failed to compute analytics");
|
||||
});
|
||||
|
||||
test("reads the legacy { error: '...' } string shape", async () => {
|
||||
const res = jsonResponse({ error: "no such column: combo_name" });
|
||||
assert.equal(await readFetchErrorMessage(res, FALLBACK), "no such column: combo_name");
|
||||
});
|
||||
|
||||
test("trims surrounding whitespace from the extracted message", async () => {
|
||||
const res = jsonResponse({ error: { message: " boom " } });
|
||||
assert.equal(await readFetchErrorMessage(res, FALLBACK), "boom");
|
||||
});
|
||||
|
||||
test("falls back when the error message is blank", async () => {
|
||||
const res = jsonResponse({ error: { message: " " } });
|
||||
assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("falls back when there is no error field", async () => {
|
||||
const res = jsonResponse({ data: 1 });
|
||||
assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK);
|
||||
});
|
||||
|
||||
test("falls back on a non-JSON body without throwing", async () => {
|
||||
const res = new Response("<html>500 Internal Server Error</html>", { status: 500 });
|
||||
assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK);
|
||||
});
|
||||
Reference in New Issue
Block a user