feat(KB-180): add planning route diagnostics and harden JSON error handling

- Add API diagnostics to planning routes for better debugging
- Harden API JSON error handling with improved validation
- Add comprehensive tests for planning route JSON responses
- Update server routes and API layer for robustness
This commit is contained in:
gsxdsm
2026-03-31 01:38:43 -07:00
parent 9e03c47b91
commit aef82c2df9
5 changed files with 144 additions and 53 deletions

View File

@@ -16,6 +16,8 @@ import {
fetchWorkspaceFileList,
fetchWorkspaceFileContent,
saveWorkspaceFileContent,
startPlanningStreaming,
fetchTasks,
} from "./api";
import type { Task, TaskDetail, BatchStatusResponse } from "@kb/core";
@@ -1109,7 +1111,7 @@ describe("API Error Handling", () => {
});
describe("Non-JSON success responses", () => {
it("throws error for successful non-JSON response", async () => {
it("throws a descriptive HTML fallback error including the endpoint URL", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
Promise.resolve({
ok: true,
@@ -1124,11 +1126,10 @@ describe("API Error Handling", () => {
} as unknown as Response)
);
await expect(fetchTasks()).rejects.toThrow("Unexpected response format: expected JSON, got text/html");
await expect(fetchTasks()).rejects.toThrow("API returned HTML instead of JSON for /api/tasks");
});
it("includes response preview for unexpected success format", async () => {
const htmlResponse = "<html><body>SPA Fallback</body></html>";
it("includes planning endpoint URL and status when HTML is returned", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
Promise.resolve({
ok: true,
@@ -1139,16 +1140,18 @@ describe("API Error Handling", () => {
name.toLowerCase() === "content-type" ? "text/html" : null,
},
json: () => Promise.reject(new Error("JSON parse error")),
text: () => Promise.resolve(htmlResponse),
text: () => Promise.resolve("<!DOCTYPE html><html><body>SPA Fallback</body></html>"),
} as unknown as Response)
);
await expect(fetchTasks()).rejects.toThrow("(Response: <html><body>SPA Fallback</body></html>)");
await expect(startPlanningStreaming("Build auth")).rejects.toThrow(
"API returned HTML instead of JSON for /api/planning/start-streaming. The endpoint may not be properly configured. (200 OK)"
);
});
});
describe("JSON parsing edge cases", () => {
it("handles invalid JSON in error response with JSON content-type", async () => {
it("reports invalid JSON with the endpoint URL", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
Promise.resolve({
ok: false,
@@ -1163,9 +1166,9 @@ describe("API Error Handling", () => {
} as unknown as Response)
);
// Should fall back to text-based error when JSON parsing fails
await expect(fetchTasks()).rejects.toThrow("Request failed: 500 Internal Server Error");
await expect(fetchTasks()).rejects.toThrow("Response:");
await expect(fetchTasks()).rejects.toThrow(
"API returned invalid JSON for /api/tasks. (500 Internal Server Error)"
);
});
});
});

View File

@@ -16,60 +16,55 @@ import type {
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "@kb/core";
function looksLikeHtml(body: string): boolean {
const trimmed = body.trim();
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
}
function buildApiUrl(path: string): string {
return `/api${path}`;
}
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${path}`, {
const url = buildApiUrl(path);
const res = await fetch(url, {
headers: { "Content-Type": "application/json" },
...opts,
});
// Check if response is JSON before attempting to parse
const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text();
const isJson = contentType.includes("application/json");
const isHtml = contentType.includes("text/html");
const isHtml = contentType.includes("text/html") || looksLikeHtml(bodyText);
if (isHtml) {
throw new Error(
`API returned HTML instead of JSON for ${url}. ` +
`The endpoint may not be properly configured. (${res.status} ${res.statusText})`
);
}
if (!isJson) {
const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText;
throw new Error(
`API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` +
`(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}`
);
}
let data: unknown;
try {
data = bodyText ? JSON.parse(bodyText) : null;
} catch {
throw new Error(
`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`
);
}
if (!res.ok) {
// For error responses, read as text first
const text = await res.text();
// Try to extract error from JSON if content-type indicates JSON
if (isJson) {
try {
const data = JSON.parse(text) as { error?: string };
throw new Error(data.error || `Request failed: ${res.status} ${res.statusText}`);
} catch {
// JSON parsing failed - fall through to text-based error below
}
}
// HTML error response (SPA fallback case) - show user-friendly message
if (isHtml || text.trim().startsWith("<!DOCTYPE") || text.trim().startsWith("<html")) {
throw new Error("Server returned an unexpected response. Please refresh and try again.");
}
// Non-JSON error response: use status text with truncated body preview
const textPreview = text.length > 100 ? text.slice(0, 100) + "..." : text;
const message = textPreview
? `Request failed: ${res.status} ${res.statusText} (Response: ${textPreview})`
: `Request failed: ${res.status} ${res.statusText}`;
throw new Error(message);
throw new Error((data as { error?: string } | null)?.error || `Request failed for ${url}: ${res.status} ${res.statusText}`);
}
// For successful responses, parse as JSON if content-type indicates JSON
if (!isJson) {
// Unexpected non-JSON success response (likely HTML from SPA fallback)
const text = await res.text();
// Check if it looks like HTML
if (isHtml || text.trim().startsWith("<!DOCTYPE") || text.trim().startsWith("<html")) {
throw new Error("Server returned an unexpected response. Please refresh and try again.");
}
// Other non-JSON response
const textPreview = text.length > 100 ? text.slice(0, 100) + "..." : text;
throw new Error(`Unexpected response format: expected JSON, got ${contentType || "unknown"}${textPreview ? ` (Response: ${textPreview})` : ""}`);
}
const data = await res.json();
return data as T;
}