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");
|
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", () => {
|
||||||
|
|||||||
@@ -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");
|
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 {
|
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})`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user