fix(dashboard): show a retryable message when Planning Retry hits a down server

Gateway 502/503/504 non-JSON bodies (for example Traefik "no available server")
were dumped as content-type diagnostics into the Planning error banner.
This commit is contained in:
gsxdsm
2026-08-15 21:41:47 -07:00
parent 086cd0a505
commit 59dc5df5f4
7 changed files with 261 additions and 27 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Show a clear retryable message when Planning Retry hits a down server.
category: fix
dev: Gateway 502/503/504 non-JSON bodies (for example Traefik "no available server") no longer dump content-type diagnostics into Planning Retry and other dashboard API surfaces.

View File

@@ -284,6 +284,28 @@ describe("API Error Handling", () => {
await expect(fetchTasks()).rejects.toThrow("API returned HTML instead of JSON"); await expect(fetchTasks()).rejects.toThrow("API returned HTML instead of JSON");
}); });
it("maps Traefik 503 text/plain to an operator-facing unavailable message", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
Promise.resolve({
ok: false,
status: 503,
statusText: "",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? "text/plain; charset=utf-8" : null,
},
json: () => Promise.reject(new Error("JSON parse error")),
text: () => Promise.resolve("no available server"),
} as unknown as Response),
);
await expect(fetchTasks()).rejects.toMatchObject({
name: "ApiRequestError",
message: "The server is temporarily unavailable. Please try again.",
status: 503,
});
});
}); });
describe("Non-JSON success responses", () => { describe("Non-JSON success responses", () => {

View File

@@ -0,0 +1,133 @@
/*
FNXC:DashboardApi 2026-08-16-03:09:
Planning Retry dumped the raw content-type diagnostic when a reverse proxy returned 503
text/plain "no available server". These cases pin the invariant across every dashboard
fetch parser that previously echoed that body: `api()` (Planning retry), deleteAiSession,
and summarizeTitle.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ApiRequestError,
SERVER_UNAVAILABLE_MESSAGE,
errorFromUnparseableApiResponse,
isGatewayUnavailableStatus,
} from "../client/client.js";
import { deleteAiSession } from "../planning/ai-sessions.js";
import { summarizeTitle } from "../planning/ai-summarize.js";
import { retryPlanningSession } from "../planning/planning.js";
function mockPlainResponse(status: number, body: string, contentType: string, statusText = ""): Response {
return new Response(body, {
status,
statusText,
headers: { "content-type": contentType },
});
}
describe("gateway unavailable API errors", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("classifies 502/503/504 as gateway unavailable", () => {
expect(isGatewayUnavailableStatus(502)).toBe(true);
expect(isGatewayUnavailableStatus(503)).toBe(true);
expect(isGatewayUnavailableStatus(504)).toBe(true);
expect(isGatewayUnavailableStatus(500)).toBe(false);
expect(isGatewayUnavailableStatus(404)).toBe(false);
});
it("maps Traefik-style 503 text/plain to the operator-facing unavailable message", () => {
const error = errorFromUnparseableApiResponse({
url: "/api/planning/11583415-c13c-4075-9373-4ed3cf69cd3f/retry?projectId=proj_5102b90f58514a4e",
status: 503,
statusText: "",
contentType: "text/plain; charset=utf-8",
bodyText: "no available server",
});
expect(error).toBeInstanceOf(ApiRequestError);
expect(error.message).toBe(SERVER_UNAVAILABLE_MESSAGE);
expect((error as ApiRequestError).status).toBe(503);
expect(error.message).not.toContain("text/plain");
expect(error.message).not.toContain("no available server");
});
it("keeps HTML misconfiguration diagnostics for non-gateway statuses", () => {
const error = errorFromUnparseableApiResponse({
url: "/api/tasks",
status: 404,
statusText: "Not Found",
contentType: "text/html",
bodyText: "<!doctype html><html><body>Not Found</body></html>",
});
expect(error.message).toContain("API returned HTML instead of JSON for /api/tasks");
expect(error.message).toContain("404 Not Found");
});
it("maps Planning retry 503 text/plain to the unavailable message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
mockPlainResponse(503, "no available server", "text/plain; charset=utf-8"),
);
await expect(
retryPlanningSession("11583415-c13c-4075-9373-4ed3cf69cd3f", "proj_5102b90f58514a4e"),
).rejects.toMatchObject({
name: "ApiRequestError",
message: SERVER_UNAVAILABLE_MESSAGE,
status: 503,
});
});
it("maps deleteAiSession 503 text/plain to the unavailable message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
mockPlainResponse(503, "no available server", "text/plain; charset=utf-8"),
);
await expect(deleteAiSession("session-1")).rejects.toMatchObject({
name: "ApiRequestError",
message: SERVER_UNAVAILABLE_MESSAGE,
status: 503,
});
});
it("maps summarizeTitle 503 text/plain to the unavailable message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
mockPlainResponse(503, "no available server", "text/plain; charset=utf-8"),
);
await expect(summarizeTitle("a".repeat(201))).rejects.toMatchObject({
name: "ApiRequestError",
message: SERVER_UNAVAILABLE_MESSAGE,
status: 503,
});
});
it("maps HTML 502 gateway pages to the unavailable message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
mockPlainResponse(502, "<html>Bad Gateway</html>", "text/html"),
);
await expect(retryPlanningSession("session-1")).rejects.toMatchObject({
name: "ApiRequestError",
message: SERVER_UNAVAILABLE_MESSAGE,
status: 502,
});
});
it("still surfaces JSON 503 error fields from Fusion itself", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "Session store not available" }), {
status: 503,
headers: { "content-type": "application/json" },
}),
);
await expect(retryPlanningSession("session-1")).rejects.toMatchObject({
name: "ApiRequestError",
message: "Session store not available",
status: 503,
});
});
});

View File

@@ -51,4 +51,19 @@ describe("deleteAiSession", () => {
await expect(deleteAiSession("session-1")).rejects.toThrow("Failed to fetch"); await expect(deleteAiSession("session-1")).rejects.toThrow("Failed to fetch");
}); });
it("maps gateway 503 text/plain to the operator-facing unavailable message", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("no available server", {
status: 503,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
);
await expect(deleteAiSession("session-1")).rejects.toMatchObject({
name: "ApiRequestError",
message: "The server is temporarily unavailable. Please try again.",
status: 503,
});
});
}); });

View File

@@ -26,11 +26,53 @@ export class ApiRequestError extends Error {
} }
} }
/*
FNXC:DashboardApi 2026-08-16-03:09:
Planning Retry showed the raw fetch diagnostic "API returned text/plain; charset=utf-8 instead of JSON
for /api/planning/:id/retry ... (503 ) Response: no available server". Fusion never emits that body —
it is a reverse-proxy 502/503/504 (Traefik's exact phrase is "no available server") when no healthy
backend is in the pool. Operators need a retryable unavailable message, not a content-type dump.
The same parse path is shared by every dashboard `api()` caller, so classify here rather than only in
the Planning banner.
*/
export const SERVER_UNAVAILABLE_MESSAGE =
"The server is temporarily unavailable. Please try again.";
export function isGatewayUnavailableStatus(status: number): boolean {
return status === 502 || status === 503 || status === 504;
}
export function looksLikeHtml(body: string): boolean { export function looksLikeHtml(body: string): boolean {
const trimmed = body.trim(); const trimmed = body.trim();
return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML"); return trimmed.startsWith("<!DOCTYPE") || trimmed.startsWith("<html") || trimmed.startsWith("<HTML");
} }
export function errorFromUnparseableApiResponse(args: {
url: string;
status: number;
statusText: string;
contentType: string;
bodyText: string;
}): Error {
if (isGatewayUnavailableStatus(args.status)) {
return new ApiRequestError(SERVER_UNAVAILABLE_MESSAGE, args.status);
}
const isHtml = args.contentType.includes("text/html") || looksLikeHtml(args.bodyText);
if (isHtml) {
return new Error(
`API returned HTML instead of JSON for ${args.url}. ` +
`The endpoint may not be properly configured. (${args.status} ${args.statusText})`
);
}
const preview = args.bodyText.length > 160 ? `${args.bodyText.slice(0, 160)}...` : args.bodyText;
return new Error(
`API returned ${args.contentType || "an unknown content type"} instead of JSON for ${args.url}. ` +
`(${args.status} ${args.statusText})${preview ? ` Response: ${preview}` : ""}`
);
}
export function buildApiUrl(path: string): string { export function buildApiUrl(path: string): string {
return `/api${path}`; return `/api${path}`;
} }
@@ -95,27 +137,24 @@ export async function api<T = unknown>(path: string, opts: RequestInit = {}): Pr
const contentType = res.headers.get("content-type") ?? ""; const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text(); const bodyText = await res.text();
const isJson = contentType.includes("application/json"); const isJson = contentType.includes("application/json");
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) { if (!isJson) {
const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText; throw errorFromUnparseableApiResponse({
throw new Error( url,
`API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` + status: res.status,
`(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}` statusText: res.statusText,
); contentType,
bodyText,
});
} }
let data: unknown; let data: unknown;
try { try {
data = bodyText ? JSON.parse(bodyText) : null; data = bodyText ? JSON.parse(bodyText) : null;
} catch { } catch {
if (isGatewayUnavailableStatus(res.status)) {
throw new ApiRequestError(SERVER_UNAVAILABLE_MESSAGE, res.status);
}
throw new Error( throw new Error(
`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})` `API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`
); );

View File

@@ -3,7 +3,14 @@
* AI / planning session client API peeled from legacy.ts. * AI / planning session client API peeled from legacy.ts.
*/ */
import type { PlanningQuestion, ThinkingLevel } from "@fusion/core"; import type { PlanningQuestion, ThinkingLevel } from "@fusion/core";
import { api, buildApiUrl, ApiRequestError, looksLikeHtml } from "../client/client.js"; import {
api,
buildApiUrl,
ApiRequestError,
SERVER_UNAVAILABLE_MESSAGE,
errorFromUnparseableApiResponse,
isGatewayUnavailableStatus,
} from "../client/client.js";
import { withProjectId } from "../client/health.js"; import { withProjectId } from "../client/health.js";
import { withTokenHeader } from "../../auth"; import { withTokenHeader } from "../../auth";
@@ -141,27 +148,29 @@ export async function deleteAiSession(id: string): Promise<void> {
const contentType = res.headers.get("content-type") ?? ""; const contentType = res.headers.get("content-type") ?? "";
const bodyText = await res.text(); const bodyText = await res.text();
const isJson = contentType.includes("application/json"); const isJson = contentType.includes("application/json");
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})`
);
}
/*
FNXC:DashboardApi 2026-08-16-03:09:
Same gateway-unavailable classification as `api()`. deleteAiSession has its own fetch parser
and must not dump Traefik/plain-text 503 bodies into the Planning session list.
*/
if (!isJson) { if (!isJson) {
const preview = bodyText.length > 160 ? `${bodyText.slice(0, 160)}...` : bodyText; throw errorFromUnparseableApiResponse({
throw new Error( url,
`API returned ${contentType || "an unknown content type"} instead of JSON for ${url}. ` + status: res.status,
`(${res.status} ${res.statusText})${preview ? ` Response: ${preview}` : ""}` statusText: res.statusText,
); contentType,
bodyText,
});
} }
let data: unknown; let data: unknown;
try { try {
data = bodyText ? JSON.parse(bodyText) : null; data = bodyText ? JSON.parse(bodyText) : null;
} catch { } catch {
if (isGatewayUnavailableStatus(res.status)) {
throw new ApiRequestError(SERVER_UNAVAILABLE_MESSAGE, res.status);
}
throw new Error(`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`); throw new Error(`API returned invalid JSON for ${url}. (${res.status} ${res.statusText})`);
} }

View File

@@ -4,6 +4,11 @@
*/ */
import { withTokenHeader } from "../../auth"; import { withTokenHeader } from "../../auth";
import {
ApiRequestError,
SERVER_UNAVAILABLE_MESSAGE,
isGatewayUnavailableStatus,
} from "../client/client.js";
// --- AI Summarization API --- // --- AI Summarization API ---
@@ -40,6 +45,10 @@ export async function summarizeTitle(
const isJson = contentType.includes("application/json"); const isJson = contentType.includes("application/json");
if (!isJson) { if (!isJson) {
// FNXC:DashboardApi 2026-08-16-03:09: title summarize has its own fetch parser; gateway 5xx must match `api()`.
if (isGatewayUnavailableStatus(res.status)) {
throw new ApiRequestError(SERVER_UNAVAILABLE_MESSAGE, res.status);
}
throw new Error(`API returned non-JSON response: ${bodyText.slice(0, 100)}`); throw new Error(`API returned non-JSON response: ${bodyText.slice(0, 100)}`);
} }