Skip to content
Closed
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
9 changes: 5 additions & 4 deletions control-plane/web/client/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
MCPHealthResponseModeAware,
MCPServerMetrics,
} from '../types/agentfield';
import { getErrorMessage } from './errorMessage';

const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/ui/v1';
const STORAGE_KEY = "af_api_key";
Expand Down Expand Up @@ -97,11 +98,11 @@ async function fetchWrapper<T>(url: string, options?: RequestInit & { timeout?:
clearTimeout(timeoutId);

if (!response.ok) {
const errorData = await response.json().catch(() => ({
message: 'Request failed with status ' + response.status
}));
const errorData: unknown = await response.json().catch(() => null);

throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
throw new Error(
getErrorMessage(errorData, `HTTP error! status: ${response.status}`),
);
}

return response.json() as Promise<T>;
Expand Down
34 changes: 34 additions & 0 deletions control-plane/web/client/src/services/errorMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const ERROR_MESSAGE_KEYS = ["message", "detail", "error", "reason", "msg"] as const;
const MAX_ERROR_DEPTH = 4;

function messageFromValue(value: unknown, depth = 0): string | null {
if (depth > MAX_ERROR_DEPTH || value == null) return null;

if (typeof value === "string") {
const message = value.trim();
return message || null;
}

if (value instanceof Error) return messageFromValue(value.message, depth + 1);

if (Array.isArray(value)) {
const messages = value
.map((entry) => messageFromValue(entry, depth + 1))
.filter((entry): entry is string => Boolean(entry));
return messages.length > 0 ? messages.join("; ") : null;
}

if (typeof value !== "object") return String(value);

const record = value as Record<string, unknown>;
for (const key of ERROR_MESSAGE_KEYS) {
const message = messageFromValue(record[key], depth + 1);
if (message) return message;
}

return null;
}

export function getErrorMessage(value: unknown, fallback: string): string {
return messageFromValue(value) ?? fallback;
}
7 changes: 5 additions & 2 deletions control-plane/web/client/src/services/sessionsApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getGlobalApiKey } from "./api";
import { getErrorMessage } from "./errorMessage";

const API_BASE = "/api/v1";

Expand Down Expand Up @@ -56,8 +57,10 @@ async function fetchJson<T>(url: string, options: RequestInit = {}): Promise<T>
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || `Request failed with status ${response.status}`);
const errorData: unknown = await response.json().catch(() => null);
throw new Error(
getErrorMessage(errorData, `Request failed with status ${response.status}`),
);
}
return response.json();
}
Expand Down
45 changes: 45 additions & 0 deletions control-plane/web/client/src/test/services/errorMessage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";

import { getErrorMessage } from "@/services/errorMessage";

describe("getErrorMessage", () => {
it("extracts a nested backend error message", () => {
expect(
getErrorMessage(
{
error: {
code: "session_start_failed",
message: "No active node can start this session",
},
},
"fallback",
),
).toBe("No active node can start this session");
});

it("joins FastAPI validation messages", () => {
expect(
getErrorMessage(
{
detail: [
{ loc: ["body", "target"], msg: "Field required" },
{ loc: ["body", "model"], msg: "Unsupported model" },
],
},
"fallback",
),
).toBe("Field required; Unsupported model");
});

it("uses the fallback for unknown structured errors instead of coercing them", () => {
expect(getErrorMessage({ code: "node_offline" }, "fallback")).toBe("fallback");
});

it("uses the fallback for empty and circular values", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;

expect(getErrorMessage({}, "fallback")).toBe("fallback");
expect(getErrorMessage(circular, "fallback")).toBe("fallback");
});
});
31 changes: 31 additions & 0 deletions control-plane/web/client/src/test/services/sessionsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,35 @@ describe("sessionsApi", () => {
sessionsApi.invokeTool("sess-1", "missing", { input: {} }),
).rejects.toThrow("bad tool");
});

it("extracts a message from a structured session error", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({
error: {
code: "node_offline",
message: "No active node can start this session",
},
}),
});
vi.stubGlobal("fetch", fetchMock);

await expect(sessionsApi.startSession("support.voice")).rejects.toThrow(
"No active node can start this session",
);
});

it("falls back to the HTTP status when the response has no safe message", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 503,
json: async () => ({ code: "node_offline" }),
});
vi.stubGlobal("fetch", fetchMock);

await expect(sessionsApi.startSession("support.voice")).rejects.toThrow(
"Request failed with status 503",
);
});
});