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:
7
.changeset/fix-planning-retry-gateway-503.md
Normal file
7
.changeset/fix-planning-retry-gateway-503.md
Normal 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.
|
||||
@@ -284,6 +284,28 @@ describe("API Error Handling", () => {
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,4 +51,19 @@ describe("deleteAiSession", () => {
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
const trimmed = body.trim();
|
||||
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 {
|
||||
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 bodyText = await res.text();
|
||||
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) {
|
||||
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}` : ""}`
|
||||
);
|
||||
throw errorFromUnparseableApiResponse({
|
||||
url,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
contentType,
|
||||
bodyText,
|
||||
});
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = bodyText ? JSON.parse(bodyText) : null;
|
||||
} 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})`
|
||||
);
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
* AI / planning session client API peeled from legacy.ts.
|
||||
*/
|
||||
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 { withTokenHeader } from "../../auth";
|
||||
|
||||
@@ -141,27 +148,29 @@ export async function deleteAiSession(id: string): Promise<void> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const bodyText = await res.text();
|
||||
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) {
|
||||
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}` : ""}`
|
||||
);
|
||||
throw errorFromUnparseableApiResponse({
|
||||
url,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
contentType,
|
||||
bodyText,
|
||||
});
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = bodyText ? JSON.parse(bodyText) : null;
|
||||
} 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})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
|
||||
import { withTokenHeader } from "../../auth";
|
||||
import {
|
||||
ApiRequestError,
|
||||
SERVER_UNAVAILABLE_MESSAGE,
|
||||
isGatewayUnavailableStatus,
|
||||
} from "../client/client.js";
|
||||
|
||||
// --- AI Summarization API ---
|
||||
|
||||
@@ -40,6 +45,10 @@ export async function summarizeTitle(
|
||||
const isJson = contentType.includes("application/json");
|
||||
|
||||
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)}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user