Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/app/api/usage/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Comment on lines +1326 to +1330

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Robustness Issue in Error Handling

There are two main concerns with the current error handling implementation in the catch block:

  1. Handling of non-Error and falsy values: If error is null or undefined (which can happen if a promise is rejected with a falsy value or throw null is executed), String(error) will evaluate to "null" or "undefined". Since these are truthy strings, the fallback "Failed to compute analytics" will not be used, and the API will return an unhelpful "null" or "undefined" error message to the client. Additionally, if a plain object with a message property is thrown, it will be serialized as "[object Object]".
  2. Dynamic Import inside catch: Performing a dynamic await import(...) inside a catch block is risky. If the system is under memory pressure or there is a module resolution issue, the import itself can fail, throwing a secondary error that completely masks the original error and prevents a clean 500 response from being returned.

Recommendation

  • Statically import buildErrorBody at the top of the file.
  • Enhance the error message extraction to safely handle falsy values and plain objects with a message property.
    const { buildErrorBody } = await import("@omniroute/open-sse/utils/error");
    const message = error instanceof Error
      ? error.message
      : (error && typeof error === "object" && "message" in error && typeof (error as any).message === "string"
        ? (error as any).message
        : (error != null ? String(error) : ""));
    return NextResponse.json(buildErrorBody(500, message || "Failed to compute analytics"), {
      status: 500,
    });

}
}
3 changes: 2 additions & 1 deletion src/shared/components/UsageAnalytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions src/shared/utils/fetchError.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 47 additions & 0 deletions tests/unit/fetch-error-message.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});