mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board reads API data and keeps the current task on screen. > - A restart or temporary network failure can interrupt those reads. > - Separate page error handlers can hide cached data or expose raw server codes. > - The app needs one connection state, readable error copy and a consistent read policy. > - This change provides that foundation and moves affected read surfaces onto it. > - The dependent write layer in #17960 preserves edits and submissions across the same failures. ## Linked Issues or Issue Description **What happened?** Temporary API failures could replace loaded content with errors. Different surfaces handled the same outage differently. Some showed raw server codes. A successful query to an unrelated service could also falsely report that Paperclip had recovered. **Expected behavior** Keep cached content visible. Show one connection banner during sustained trouble. Confirm recovery through the Paperclip health probe or its WebSocket. Retry transient reads and show readable errors for permanent failures. **Steps to reproduce** 1. Open a populated task or workspace page. 2. Interrupt API requests with a 503 or a dropped connection. 3. Keep an unrelated external request in flight. 4. Confirm that cached content remains and that the external result cannot clear the outage. 5. Restore the API and check that reads refresh. The write layer first merged into this feature branch in #15769 now has its own dependent PR, #17960. This PR contains the shared foundation and read changes. Merge this PR first. ## What Changed - Classify API errors and provide readable error copy, including non-JSON proxy responses and Retry-After handling. - Use one connection store and health probe with bounded backoff. Confirm recovery from the server health probe or live-updates WebSocket. - Pause reads while disconnected, retry transient errors, and refresh active reads after recovery. - Keep cached data visible through shared query views and affected page, panel and plugin read surfaces. - Add one app-level connection banner and support the pending-write count consumed by the dependent write layer. - Add a query-error rendering guard. Call it directly from the existing CI step. Update development documentation. ## Verification - Historical-chat lookup now follows the same ready/stale roster state as rendering. Three regressions fail before the fix; the six new cases pass after it, including working Retry, fresh data, no-cache error, permanent 403 and company isolation. All six focused query/recovery suites pass 200 tests. Final-commit CI passes and Apex is 5/5 with no unresolved findings. Canary Dry Run initially refused a preview version that was already on npm. It passed after one rerun with fresh registry metadata; no check was disabled. - Integrated current master and retained both transcript interest registration and connectivity reporting on socket open. The seven focused recovery/query suites pass 288 tests, and UI typecheck and production build pass. All 9,451 UI tests across 799 files pass on the merge commit. Final-commit CI passes and Apex is 5/5 with no unresolved findings. - Current head `70d9335d732219352ef0f98414439766bbcb33df`: all hosted CI checks pass. Apex is **5/5** on that head with no unresolved findings. The branch has no merge conflict. GitHub still requires code-owner approval. - The foundation passes 9,430 UI tests in 804 files. After integrating current master page paths, 190 focused page tests pass. - Two unrelated-success regressions fail before the latest fix. With the fix, 62 focused UI tests, all six guard tests and UI TypeScript pass. - The composed foundation and write layer pass **9,505 UI tests in 808 files**, UI TypeScript, a production UI build, and query-error, token and map guards. - A real Chromium journey on the composed source injects a 503 during title save. The loaded task stays visible; one reconnect banner appears; the edit saves after recovery; another task stays unchanged; the saved title survives reload. - Full recursive local checks were not repeated because Runner compilation exhausted local disk during another repair. Hosted checks must pass before merge. No new staging deployment is claimed. - Latest review follow-up: a cached CaseDetail now survives a failed HTTP 429 refresh. Its page regression fails before the fix and passes after it. Eighty focused foundation tests, UI TypeScript, build and guards pass after that change. - Earlier CI evidence is retained: several foundation jobs received a runner shutdown signal together. The write-layer head also failed an unchanged Runner descriptor close test with `provider_transport_failed`. The same Runner source passed that suite in the foundation run. New CI runs cover the updated heads; no check or assertion was weakened. ## Risks - Shared retry defaults change the time before transient read errors appear. Permanent errors still fail promptly. - ApiError.message now contains readable copy. Callers that compare raw codes must use ApiError.code. - Cache success can come from another service or local data. It must not confirm Paperclip recovery. - Durable writes are reviewed separately in #17960. This PR does not change database or mutation contracts. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) produced the original work with tool use. OpenAI Codex, GPT-6 family, assisted with restoration, integration, tests and review fixes. The Codex session does not expose its exact serving model ID or context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Amy (Opus) <claude@users.noreply.github.com>
295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
/**
|
||
* Shared error classification and user-facing copy.
|
||
*
|
||
* Every surface that reacts to a failed request asks two questions: "is this a
|
||
* blip that will fix itself?" and "what do I tell the user?". This module is
|
||
* the single answer to both, so retry policy, the connectivity banner, toasts,
|
||
* and inline errors agree with each other.
|
||
*
|
||
* Classification is structural on purpose: it reads `status`, `code`, `body`,
|
||
* and `name` instead of importing `ApiError`/`ApiUnavailableError`, so the API
|
||
* client can depend on this module without an import cycle, and errors thrown
|
||
* by hand-rolled fetches (health, uploads) classify the same way.
|
||
*/
|
||
|
||
export type ErrorKind =
|
||
| "transient"
|
||
| "auth"
|
||
| "forbidden"
|
||
| "not_found"
|
||
| "conflict"
|
||
| "invalid"
|
||
| "aborted"
|
||
| "unknown";
|
||
|
||
export interface ErrorDescription {
|
||
title: string;
|
||
body: string;
|
||
/** True when trying the same request again later can succeed. */
|
||
retryable: boolean;
|
||
}
|
||
|
||
export interface DescribeErrorOptions {
|
||
/** What the user was doing, as a verb phrase: "save the comment". */
|
||
action?: string;
|
||
}
|
||
|
||
/** HTTP statuses that mean "the server or a proxy will likely recover". */
|
||
const TRANSIENT_STATUSES = new Set([408, 425, 429, 502, 503, 504]);
|
||
|
||
/**
|
||
* Error codes that mean "temporarily unavailable" regardless of the HTTP status
|
||
* they arrive with. The Cloud gateway has sent `tenant_app_unavailable` with
|
||
* non-5xx statuses, so these are matched by code, never by status alone.
|
||
*/
|
||
const TRANSIENT_CODES = new Set([
|
||
"tenant_app_unavailable",
|
||
"tenant_app_starting",
|
||
// Plugin bridge (`PluginBridgeErrorCode`).
|
||
"WORKER_UNAVAILABLE",
|
||
"TIMEOUT",
|
||
]);
|
||
|
||
/** Transient errors that say "the whole server is unreachable", not one route. */
|
||
const CONNECTIVITY_STATUSES = new Set([502, 503, 504]);
|
||
const CONNECTIVITY_CODES = new Set(["tenant_app_unavailable", "tenant_app_starting"]);
|
||
|
||
// --- Copy table ---------------------------------------------------------------
|
||
//
|
||
// The only place user-facing error copy lives. Keys are server error codes (the
|
||
// raw `body.error`/`body.code`) or an `ErrorKind` fallback. Never return a raw
|
||
// code to the user: anything not in this table falls back to its kind's copy.
|
||
|
||
const UNAVAILABLE_BODY = "Paperclip is temporarily unavailable. Please try again in a moment.";
|
||
|
||
const CODE_COPY: Record<string, { title: string; body: string }> = {
|
||
tenant_app_unavailable: {
|
||
title: "Reconnecting to Paperclip",
|
||
body: "Paperclip is restarting or updating. Please try again in a moment.",
|
||
},
|
||
tenant_app_starting: {
|
||
title: "Paperclip is starting",
|
||
body: "Paperclip is starting up. Please try again in a few seconds.",
|
||
},
|
||
WORKER_UNAVAILABLE: {
|
||
title: "Plugin unavailable",
|
||
body: "This plugin is restarting. Please try again in a moment.",
|
||
},
|
||
TIMEOUT: {
|
||
title: "Request timed out",
|
||
body: "This took too long to respond. Please try again in a moment.",
|
||
},
|
||
// Issue-thread interaction resolution (moved from lib/interaction-resolution-error.ts).
|
||
interaction_audience_denied: {
|
||
title: "You can’t respond to this card",
|
||
body: "You are not in this card's resolver audience.",
|
||
},
|
||
interaction_forbidden: {
|
||
title: "You can’t respond to this card",
|
||
body: "You do not have permission to respond to this card.",
|
||
},
|
||
interaction_settled: {
|
||
title: "Already handled",
|
||
body: "This request is no longer waiting for a decision.",
|
||
},
|
||
interaction_submit_failed: {
|
||
title: "Couldn't submit",
|
||
body: "Couldn't submit. Try again.",
|
||
},
|
||
};
|
||
|
||
const KIND_COPY: Record<ErrorKind, { title: string; body: string }> = {
|
||
// Moved from `ApiUnavailableError` (api/response.ts).
|
||
transient: { title: "Connection interrupted", body: UNAVAILABLE_BODY },
|
||
auth: { title: "Signed out", body: "Your session has ended. Sign in again to continue." },
|
||
forbidden: { title: "Not allowed", body: "You don’t have permission to do that." },
|
||
not_found: { title: "Not found", body: "This item doesn’t exist or was moved." },
|
||
conflict: { title: "Out of date", body: "This changed since you loaded it. Refresh and try again." },
|
||
invalid: { title: "Check your input", body: "Some details aren’t valid. Review them and try again." },
|
||
aborted: { title: "Cancelled", body: "The request was cancelled." },
|
||
unknown: { title: "Something went wrong", body: "An unexpected error occurred. Please try again." },
|
||
};
|
||
|
||
/** Copy for a specific code or kind, for callers that build their own messages. */
|
||
export function errorCopy(key: string): { title: string; body: string } {
|
||
return CODE_COPY[key] ?? KIND_COPY[key as ErrorKind] ?? KIND_COPY.unknown;
|
||
}
|
||
|
||
// --- Field extraction -------------------------------------------------------------
|
||
|
||
function record(value: unknown): Record<string, unknown> | null {
|
||
return value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Record<string, unknown>)
|
||
: null;
|
||
}
|
||
|
||
function stringField(value: unknown): string | null {
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
/** The HTTP status an error carries, if any. */
|
||
export function errorStatus(err: unknown): number | null {
|
||
const status = record(err)?.status;
|
||
return typeof status === "number" && Number.isFinite(status) ? status : null;
|
||
}
|
||
|
||
/**
|
||
* Every server-provided code on an error: `ApiError.code` (the raw
|
||
* `body.error`), `body.code`, and `body.details.code`. Plugin bridge errors put
|
||
* their code on `body.code`; HttpErrors put a machine code on `details.code`.
|
||
*/
|
||
export function errorCodes(err: unknown): string[] {
|
||
const error = record(err);
|
||
if (!error) return [];
|
||
const body = record(error.body);
|
||
const codes = [
|
||
stringField(error.code),
|
||
stringField(body?.error),
|
||
stringField(body?.code),
|
||
stringField(record(body?.details)?.code),
|
||
];
|
||
return [...new Set(codes.filter((code): code is string => code !== null))];
|
||
}
|
||
|
||
/**
|
||
* True for a machine code (`tenant_app_unavailable`, `WORKER_UNAVAILABLE`,
|
||
* `TIMEOUT`) rather than a sentence a person wrote. Single readable words such
|
||
* as "Forbidden" are not codes.
|
||
*/
|
||
export function isRawErrorCode(value: string): boolean {
|
||
const text = value.trim();
|
||
if (!text || /\s/.test(text)) return false;
|
||
return /^[A-Za-z0-9]+(?:[_.][A-Za-z0-9]+)+$/.test(text) || /^[A-Z][A-Z0-9_]{2,}$/.test(text);
|
||
}
|
||
|
||
/** A server message that is safe to show: not a raw code, not a generic status line. */
|
||
function readableServerMessage(err: unknown): string | null {
|
||
const error = record(err);
|
||
const candidates = [stringField(record(error?.body)?.message), stringField(record(error?.body)?.error)];
|
||
if (err instanceof Error) candidates.push(stringField(err.message));
|
||
for (const candidate of candidates) {
|
||
if (!candidate) continue;
|
||
if (isRawErrorCode(candidate)) continue;
|
||
if (/^Request failed: \d+$/.test(candidate)) continue;
|
||
if (candidate === UNAVAILABLE_BODY) continue;
|
||
return candidate;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// --- Classification ---------------------------------------------------------------
|
||
|
||
function isAbortError(err: unknown): boolean {
|
||
const name = record(err)?.name;
|
||
return name === "AbortError" || name === "CancelledError";
|
||
}
|
||
|
||
function isNetworkTypeError(err: unknown): boolean {
|
||
// Fetch uses different network-failure messages across browsers. Do not
|
||
// classify arbitrary TypeErrors (programming bugs) as outages.
|
||
return err instanceof TypeError && /fetch|network|load failed/i.test(err.message);
|
||
}
|
||
|
||
function isApiUnavailableError(err: unknown): boolean {
|
||
return record(err)?.name === "ApiUnavailableError";
|
||
}
|
||
|
||
export function classifyError(err: unknown): ErrorKind {
|
||
if (err === null || err === undefined) return "unknown";
|
||
if (isAbortError(err)) return "aborted";
|
||
if (isApiUnavailableError(err) || isNetworkTypeError(err)) return "transient";
|
||
if (errorCodes(err).some((code) => TRANSIENT_CODES.has(code))) return "transient";
|
||
|
||
const status = errorStatus(err);
|
||
if (status === null) return "unknown";
|
||
if (TRANSIENT_STATUSES.has(status)) return "transient";
|
||
if (status === 401) return "auth";
|
||
if (status === 403) return "forbidden";
|
||
if (status === 404 || status === 410) return "not_found";
|
||
if (status === 409 || status === 412) return "conflict";
|
||
if (status >= 400 && status < 500) return "invalid";
|
||
return "unknown";
|
||
}
|
||
|
||
export function isTransientError(err: unknown): boolean {
|
||
return classifyError(err) === "transient";
|
||
}
|
||
|
||
/**
|
||
* A transient error that means the server as a whole is unreachable (network
|
||
* drop, proxy 5xx, gateway "app unavailable"), as opposed to one route being
|
||
* busy (429, a plugin worker restarting). Only these move the app-wide
|
||
* connectivity state; per-route blips are left to query retry.
|
||
*/
|
||
export function isConnectivityError(err: unknown): boolean {
|
||
if (isApiUnavailableError(err) || isNetworkTypeError(err)) return true;
|
||
if (errorCodes(err).some((code) => CONNECTIVITY_CODES.has(code))) return true;
|
||
const status = errorStatus(err);
|
||
if (status === null || !CONNECTIVITY_STATUSES.has(status)) return false;
|
||
// A plugin worker 503 is one route, not the server.
|
||
return !errorCodes(err).some((code) => TRANSIENT_CODES.has(code) && !CONNECTIVITY_CODES.has(code));
|
||
}
|
||
|
||
/** HTTP 4xx other than the transient ones; retrying the same request cannot help. */
|
||
export function isClientError(err: unknown): boolean {
|
||
const kind = classifyError(err);
|
||
return kind === "auth" || kind === "forbidden" || kind === "not_found" || kind === "conflict" || kind === "invalid";
|
||
}
|
||
|
||
// --- Retry-After --------------------------------------------------------------------
|
||
|
||
const MAX_RETRY_AFTER_MS = 5 * 60_000;
|
||
|
||
/** Parse an HTTP `Retry-After` value (delta-seconds or HTTP-date) into milliseconds. */
|
||
export function parseRetryAfter(value: string | null | undefined, now = Date.now()): number | null {
|
||
if (!value) return null;
|
||
const text = value.trim();
|
||
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
||
return Math.min(Math.round(Number(text) * 1000), MAX_RETRY_AFTER_MS);
|
||
}
|
||
const date = Date.parse(text);
|
||
if (Number.isNaN(date)) return null;
|
||
return Math.min(Math.max(0, date - now), MAX_RETRY_AFTER_MS);
|
||
}
|
||
|
||
/** The server-requested delay carried by an error, if any. */
|
||
export function errorRetryAfterMs(err: unknown): number | null {
|
||
const value = record(err)?.retryAfterMs;
|
||
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
||
}
|
||
|
||
// --- Copy -----------------------------------------------------------------------------
|
||
|
||
/**
|
||
* User-facing copy for any error. Never returns a raw `snake_case` code:
|
||
* known codes use the table above, readable server messages pass through for
|
||
* client errors, and everything else uses the copy for its kind.
|
||
*/
|
||
export function describeError(err: unknown, options: DescribeErrorOptions = {}): ErrorDescription {
|
||
const kind = classifyError(err);
|
||
const retryable = kind === "transient" || kind === "unknown";
|
||
const coded = errorCodes(err).map((code) => CODE_COPY[code]).find(Boolean);
|
||
const fallback = KIND_COPY[kind];
|
||
|
||
let body: string;
|
||
if (coded) body = coded.body;
|
||
else if (kind === "transient" || kind === "aborted") body = fallback.body;
|
||
else body = readableServerMessage(err) ?? fallback.body;
|
||
|
||
const title = options.action && kind !== "transient"
|
||
? `Couldn't ${options.action}`
|
||
: (coded?.title ?? fallback.title);
|
||
return { title, body, retryable };
|
||
}
|
||
|
||
/**
|
||
* The `message` for an `ApiError` built from an HTTP response: the server's own
|
||
* text when it is readable, otherwise copy from the table. Keeps raw codes out
|
||
* of every `{error.message}` render and `toast(err.message)` call.
|
||
*/
|
||
export function apiErrorMessage(status: number, body: unknown): string {
|
||
const raw = stringField(record(body)?.error);
|
||
if (raw && !isRawErrorCode(raw)) return raw;
|
||
return describeError({ status, body, code: raw }).body;
|
||
}
|