From 61b3fd57a695614dc4a37e2303f426a34a9795cf Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:31:05 -0500 Subject: [PATCH 1/3] fix(ui): recover gracefully during server restarts (#14560) Show a clear reconnecting state during server restarts and retry safe access checks every five seconds. Preserve open drafts, wait for initial startup readiness, and keep authentication failures separate from temporary outages. Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 11 + doc/design/COMPONENT-INVENTORY.md | 7 + ui/src/App.test.tsx | 7 +- ui/src/api/auth.ts | 6 +- ui/src/api/client.ts | 5 +- ui/src/api/health.ts | 13 +- ui/src/api/response.test.ts | 94 +++++++ ui/src/api/response.ts | 29 +++ ui/src/components/CloudAccessGate.test.tsx | 237 ++++++++++++++++++ ui/src/components/CloudAccessGate.tsx | 133 +++++++--- ui/src/pages/DesignGuide.tsx | 13 + .../stories/cloud-access-recovery.stories.tsx | 16 ++ 12 files changed, 533 insertions(+), 38 deletions(-) create mode 100644 ui/src/api/response.test.ts create mode 100644 ui/src/api/response.ts create mode 100644 ui/src/components/CloudAccessGate.test.tsx create mode 100644 ui/storybook/stories/cloud-access-recovery.stories.tsx diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c830de4bcb..bfede406df 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -238,6 +238,17 @@ owns committed updates. ## Hot-Restart Deploys +During a restart, the board's health, session, and access checks retry temporary +network/gateway failures and non-JSON API responses every five seconds. A new +page shows **Reconnecting to Paperclip** with a **Try again** action and waits +for startup health to become ready. Valid startup metadata remains available to +sign-in and invitation pages. An already +open page stays mounted during temporary background failures so unsaved edits +survive. Successful checks resume the same route and refresh other failed reads; +this recovery does not reload the browser or replay mutations. Authorization +failures still require sign-in or an explicit retry. Storybook **App / Connection +recovery** shows the startup recovery states. + Primary-instance rebuilds that restart `paperclip.service` can request one-shot live-run adoption instead of using the normal graceful shutdown drain. Before restarting the service, write the marker from the newly staged app with the current service PID: ```sh diff --git a/doc/design/COMPONENT-INVENTORY.md b/doc/design/COMPONENT-INVENTORY.md index 91bf50d192..e9623703be 100644 --- a/doc/design/COMPONENT-INVENTORY.md +++ b/doc/design/COMPONENT-INVENTORY.md @@ -6,6 +6,13 @@ Run scope: `ui/src/components/` and `ui/src/pages/` on branch `design/token-extr ## Counts +### Connection recovery — 2026-09-29 + +`CloudAccessGate.tsx` contains the startup `CloudAccessError` composition, using +the existing Button and typography tokens. It covers automatic reconnection, +an in-flight check, and manual retry after access-check failures. See +`/design-guide` and Storybook **App / Connection recovery**. + ### Independent MCP connection setup — 2026-09-21 `ui/src/features/connections/remote-mcp/RemoteMcpConnectionSetup.tsx` is the controlled diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 4730e3b782..23e55bd37e 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -121,7 +121,9 @@ describe("CloudAccessGate", () => { it("does not mistake a session service failure for a signed-out user", async () => { mockAuthApi.getSession.mockRejectedValue(new Error("Session service unavailable")); const root = renderGate(container); - await waitForText(container, "Session service unavailable"); + await waitForText(container, "Unable to load Paperclip"); + expect(container.querySelector("button")?.textContent).toBe("Try again"); + expect(container.textContent).not.toContain("Outlet content"); expect(container.textContent).not.toContain("Navigate:/auth"); expect(beginCloudSignInMock).not.toHaveBeenCalled(); unmountRoot(root); @@ -193,7 +195,8 @@ describe("CloudAccessGate", () => { mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } }); mockAccessApi.getCurrentBoardAccess.mockRejectedValueOnce(new Error("Access check unavailable")); const root = renderGate(container, true); - await waitForText(container, "Access check unavailable"); + await waitForText(container, "Unable to load Paperclip"); + expect(container.querySelector("button")?.textContent).toBe("Try again"); expect(container.textContent).not.toContain("Outlet content"); unmountRoot(root); }); diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 0923f37ddf..2a66e77539 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -10,6 +10,7 @@ import { } from "@paperclipai/shared"; import { redactUrlSecrets } from "@/lib/redact-url-secrets"; import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; +import { readApiJson } from "./response"; type AuthErrorBody = | { @@ -158,13 +159,14 @@ export const authApi = { const res = await fetch("/api/auth/get-session", { credentials: "include", headers: { Accept: "application/json" }, + cache: "no-store", }); - const payload = await res.json().catch(() => null); + const payload = await readApiJson(res); if (!res.ok) { const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); if (recovery) return recovery; if (res.status === 401) return null; - throw new Error(`Failed to load session (${res.status})`); + throw extractAuthError(payload as AuthErrorBody, res.status); } const direct = toSession(payload); if (direct) return direct; diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 88bb9a9cd1..1b4aab6f81 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,5 +1,6 @@ import { getPageVisibility, getVisibilityHeaderValue } from "@/lib/page-visibility"; import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; +import { readApiJson } from "./response"; const BASE = "/api"; @@ -56,7 +57,7 @@ async function request(path: string, init?: RequestInit): Promise { ...init, }); if (!res.ok) { - const errorBody = await res.json().catch(() => null); + const errorBody = await readApiJson(res); const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, errorBody); if (recovery) return recovery; throw new ApiError( @@ -66,7 +67,7 @@ async function request(path: string, init?: RequestInit): Promise { ); } if (res.status === 204) return undefined as T; - return res.json(); + return readApiJson(res); } // --- In-tab request coalescing for identical safe GETs ----------------------- diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index df5c3b5031..2b6a2a08aa 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,5 +1,7 @@ import type { ServerInfoSnapshot } from "@paperclipai/shared"; import { tenantSessionRecovery } from "@/lib/tenant-session-recovery"; +import { ApiError } from "./client"; +import { ApiUnavailableError, readApiJson } from "./response"; export type DevServerHealthStatus = { enabled: true; @@ -24,7 +26,7 @@ export type CloudInstanceHealthStatus = { }; export type HealthStatus = { - status: "ok"; + status: "ok" | "starting"; version?: string; /** Commit of the running server; null when build metadata is unavailable. */ commit?: string | null; @@ -52,14 +54,17 @@ export const healthApi = { const res = await fetch("/api/health", { credentials: "include", headers: { Accept: "application/json" }, + cache: "no-store", }); + const payload = await readApiJson(res); if (!res.ok) { - const payload = await res.json().catch(() => null) as { error?: string } | null; const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, payload); if (recovery) return recovery; - throw new Error(payload?.error ?? `Failed to load health (${res.status})`); + throw new ApiError(payload?.error ?? `Failed to load health (${res.status})`, res.status, payload); } - return res.json(); + // Startup recovery can still serve sign-in and deployment metadata. + if (payload?.status !== "ok" && payload?.status !== "starting") throw new ApiUnavailableError(res.status); + return payload; }, requestDevServerRestart: async (): Promise => { const res = await fetch("/api/health/dev-server/restart", { diff --git a/ui/src/api/response.test.ts b/ui/src/api/response.test.ts new file mode 100644 index 0000000000..b826ec2c22 --- /dev/null +++ b/ui/src/api/response.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, ApiError } from "./client"; +import { authApi } from "./auth"; +import { healthApi } from "./health"; +import { ApiUnavailableError, isTemporaryApiError, readApiJson } from "./response"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("API responses during a restart", () => { + it.each([200, 502, 503, 504])("handles HTML with status %i on every startup API", async (status) => { + const fetchMock = vi.fn().mockImplementation(async () => new Response("

Restarting

", { + status, + headers: { "Content-Type": "text/html" }, + })); + vi.stubGlobal("fetch", fetchMock); + + for (const read of [healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")]) { + await expect(read()).rejects.toMatchObject({ + name: "ApiUnavailableError", + message: "Paperclip is temporarily unavailable. Please try again in a moment.", + status, + }); + } + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("does not replay a mutation after an HTML response", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + await expect(api.post("/issues", { title: "Save once" })).rejects.toBeInstanceOf(ApiUnavailableError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("preserves empty mutation successes", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); + await expect(api.delete("/issues/example")).resolves.toBeUndefined(); + }); + + it("preserves valid startup health metadata for sign-in and invitations", async () => { + const payload = { + status: "starting", + deploymentMode: "authenticated", + cloud: { managed: true, managedBy: "paperclip-cloud", stackSlug: "example", cloudBaseUrl: "https://example.com" }, + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload))); + await expect(healthApi.get()).resolves.toEqual(payload); + }); + + it.each([null, {}, { status: "unhealthy" }])("rejects an unusable health payload: %j", async (payload) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json(payload))); + await expect(healthApi.get()).rejects.toBeInstanceOf(ApiUnavailableError); + }); + + it.each([healthApi.get, authApi.getSession, () => api.get("/cli-auth/me")])( + "retains the status of JSON gateway errors for retry classification", + async (read) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json({ error: "Service unavailable" }, { status: 503 }))); + const error = await read().catch((error: unknown) => error); + expect(isTemporaryApiError(error)).toBe(true); + }, + ); + + it("preserves HTTP denials with or without JSON", async () => { + vi.stubGlobal("fetch", vi.fn() + .mockResolvedValueOnce(new Response("Forbidden", { status: 403 })) + .mockResolvedValueOnce(Response.json({ error: "Access denied" }, { status: 403 }))); + await expect(api.get("/denied")).rejects.toMatchObject({ name: "ApiError", status: 403 }); + await expect(api.get("/denied")).rejects.toMatchObject({ message: "Access denied", status: 403 }); + }); + + it("does not swallow an aborted response body", async () => { + const error = new DOMException("Aborted", "AbortError"); + const response = new Response(); + vi.spyOn(response, "json").mockRejectedValue(error); + await expect(readApiJson(response)).rejects.toBe(error); + }); + + it.each(["Failed to fetch", "NetworkError when attempting to fetch resource.", "Load failed"])( + "recognizes the browser network failure: %s", (message) => { + expect(isTemporaryApiError(new TypeError(message))).toBe(true); + }, + ); + + it.each([ + new ApiError("Unauthorized", 401, null), + new ApiError("Forbidden", 403, null), + new ApiError("Not found", 404, null), + new ApiError("Internal error", 500, null), + new TypeError("Cannot read properties of undefined"), + new DOMException("Aborted", "AbortError"), + ])("does not automatically retry non-transport errors: %s", (error) => { + expect(isTemporaryApiError(error)).toBe(false); + }); +}); diff --git a/ui/src/api/response.ts b/ui/src/api/response.ts new file mode 100644 index 0000000000..c491f30467 --- /dev/null +++ b/ui/src/api/response.ts @@ -0,0 +1,29 @@ +/** A proxy or restarting server answered an API request without usable JSON. */ +export class ApiUnavailableError extends Error { + constructor(public readonly status: number) { + super("Paperclip is temporarily unavailable. Please try again in a moment."); + this.name = "ApiUnavailableError"; + } +} + +export function isTemporaryApiError(error: unknown): boolean { + if (error instanceof ApiUnavailableError) return true; + if (!(error instanceof Error)) return false; + if ("status" in error && [502, 503, 504].includes(error.status as number)) return true; + // Fetch uses different network-failure messages across browsers. Do not + // classify arbitrary TypeErrors (programming bugs) or aborts as outages. + return error instanceof TypeError && /fetch|network|load failed/i.test(error.message); +} + +export async function readApiJson(response: Response): Promise { + try { + return await response.json() as T; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + // An HTML fallback can have status 200 during a deployment. Never expose + // its parser error or treat it as a successful, empty API response. + if (response.ok || response.status >= 500) throw new ApiUnavailableError(response.status); + // Preserve HTTP/auth error handling even when a 4xx response has no JSON. + return null as T; + } +} diff --git a/ui/src/components/CloudAccessGate.test.tsx b/ui/src/components/CloudAccessGate.test.tsx new file mode 100644 index 0000000000..9d62e3f31d --- /dev/null +++ b/ui/src/components/CloudAccessGate.test.tsx @@ -0,0 +1,237 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { focusManager, QueryClient, QueryClientProvider, QueryObserver } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudAccessGate } from "./CloudAccessGate"; +import { queryKeys } from "@/lib/queryKeys"; +import { ApiUnavailableError } from "@/api/response"; + +vi.mock("@/lib/router", () => ({ + useLocation: () => ({ pathname: "/CON/agents/conan/instructions", search: "?tab=edit", hash: "#draft" }), + Navigate: ({ to }: { to: string }) =>
, + Outlet: () =>