diff --git a/.changeset/fix-planning-retry-gateway-503.md b/.changeset/fix-planning-retry-gateway-503.md new file mode 100644 index 0000000000..392ec9df68 --- /dev/null +++ b/.changeset/fix-planning-retry-gateway-503.md @@ -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. diff --git a/packages/dashboard/app/__tests__/api-projects.test.ts b/packages/dashboard/app/__tests__/api-projects.test.ts index ada926ed68..32b9152265 100644 --- a/packages/dashboard/app/__tests__/api-projects.test.ts +++ b/packages/dashboard/app/__tests__/api-projects.test.ts @@ -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", () => { diff --git a/packages/dashboard/app/api/__tests__/api-gateway-unavailable.test.ts b/packages/dashboard/app/api/__tests__/api-gateway-unavailable.test.ts new file mode 100644 index 0000000000..331501a393 --- /dev/null +++ b/packages/dashboard/app/api/__tests__/api-gateway-unavailable.test.ts @@ -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: "
Not Found", + }); + + 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, "Bad Gateway", "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, + }); + }); +}); diff --git a/packages/dashboard/app/api/__tests__/deleteAiSession.test.ts b/packages/dashboard/app/api/__tests__/deleteAiSession.test.ts index f8b8f448bc..575638956a 100644 --- a/packages/dashboard/app/api/__tests__/deleteAiSession.test.ts +++ b/packages/dashboard/app/api/__tests__/deleteAiSession.test.ts @@ -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, + }); + }); }); diff --git a/packages/dashboard/app/api/client/client.ts b/packages/dashboard/app/api/client/client.ts index 8a796e7476..78b842ad2c 100644 --- a/packages/dashboard/app/api/client/client.ts +++ b/packages/dashboard/app/api/client/client.ts @@ -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(" 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