Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion web_src/src/lib/errors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getApiErrorMessage, getResponseErrorMessage } from "@/lib/errors";
import { getApiErrorMessage, getResponseErrorMessage, isTransientHttpError, summarizeError } from "@/lib/errors";

describe("errors", () => {
it("extracts nested api error messages", () => {
Expand Down Expand Up @@ -84,3 +84,72 @@ describe("errors", () => {
await expect(getResponseErrorMessage(response, "fallback")).resolves.toBe("fallback");
});
});

describe("isTransientHttpError", () => {
it("treats HTML response bodies as transient", () => {
const htmlError = `<!DOCTYPE html>
<html lang="en-US">
<head><title>superplane.com | 502: Bad gateway</title></head>
<body>Bad gateway</body>
</html>`;

expect(isTransientHttpError(htmlError)).toBe(true);
expect(isTransientHttpError("<html><head></head><body>hi</body></html>")).toBe(true);
});

it("treats browser network failures as transient", () => {
expect(isTransientHttpError(new TypeError("Failed to fetch"))).toBe(true);
expect(isTransientHttpError(new TypeError("Load failed"))).toBe(true);
expect(isTransientHttpError({ name: "AbortError" })).toBe(true);
});

it("treats transient HTTP statuses as transient", () => {
for (const status of [0, 401, 403, 408, 429, 502, 503, 504]) {
expect(isTransientHttpError({ status })).toBe(true);
expect(isTransientHttpError({ response: { status } })).toBe(true);
}
});

it("does not treat real application errors as transient", () => {
expect(isTransientHttpError({ status: 400 })).toBe(false);
expect(isTransientHttpError({ response: { status: 422 } })).toBe(false);
expect(isTransientHttpError(new Error("validation failed"))).toBe(false);
expect(isTransientHttpError("conflict resolving canvas changes")).toBe(false);
expect(isTransientHttpError(undefined)).toBe(false);
expect(isTransientHttpError(null)).toBe(false);
});
});

describe("summarizeError", () => {
it("collapses HTML response bodies to a short label", () => {
const htmlError = `<!DOCTYPE html>
<html lang="en-US">
<head><title>superplane.com | 502: Bad gateway</title></head>
<body>Bad gateway</body>
</html>`;

expect(summarizeError(htmlError)).toBe("HTML response body");
});

it("includes the HTTP status when available", () => {
expect(summarizeError({ status: 503 })).toBe("HTTP 503");
expect(summarizeError({ response: { status: 504 } })).toBe("HTTP 504");
});

it("returns the error message for Error instances", () => {
expect(summarizeError(new Error("validation failed"))).toBe("validation failed");
});

it("truncates very long messages", () => {
const longString = "x".repeat(500);
const summary = summarizeError(longString, 50);
expect(summary.length).toBeLessThanOrEqual(50);
expect(summary.endsWith("…")).toBe(true);
});

it("handles non-string, non-error values", () => {
expect(summarizeError(undefined)).toBe("Unknown error");
expect(summarizeError(null)).toBe("Unknown error");
expect(summarizeError({ foo: "bar" })).toBe('{"foo":"bar"}');
});
});
116 changes: 116 additions & 0 deletions web_src/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { GooglerpcStatus } from "@/api-client/types.gen";

const TRANSIENT_HTTP_STATUSES = new Set([0, 401, 403, 408, 429, 502, 503, 504]);

/**
* Extract error message from API error response
* Handles the structure returned by @hey-api/client-fetch
Expand Down Expand Up @@ -93,3 +95,117 @@ function looksLikeBrowserNetworkError(value: string): boolean {
normalized.includes("networkerror when attempting to fetch resource")
);
}

function extractHttpStatus(error: unknown): number | undefined {
if (!error || typeof error !== "object") {
return undefined;
}

const candidate = error as { status?: unknown; response?: { status?: unknown } };
if (typeof candidate.status === "number") {
return candidate.status;
}

if (candidate.response && typeof candidate.response === "object" && typeof candidate.response.status === "number") {
return candidate.response.status;
}

return undefined;
}

function looksLikeNetworkFailureError(error: unknown): boolean {
if (error instanceof TypeError) {
const message = error.message ?? "";
if (!message) {
return true;
}
return looksLikeBrowserNetworkError(message);
}

if (error && typeof error === "object") {
const name = (error as { name?: unknown }).name;
if (name === "AbortError" || name === "NetworkError") {
return true;
}
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && looksLikeBrowserNetworkError(message)) {
return true;
}
}

return false;
}

/**
* Returns true when the error looks like a transient infrastructure or
* connectivity failure rather than a real application bug. These should not
* be reported as actionable errors for background/silent operations.
*
* Treated as transient:
* - HTML payloads thrown by `@hey-api/client-fetch` when the response body is
* an HTML error page from a proxy / load balancer / auth redirect.
* - `TypeError("Failed to fetch")` and other generic browser network errors.
* - HTTP statuses {0, 401, 403, 408, 429, 502, 503, 504}.
*/
export function isTransientHttpError(error: unknown): boolean {
if (typeof error === "string" && looksLikeHtmlDocument(error)) {
return true;
}

if (looksLikeNetworkFailureError(error)) {
return true;
}

const status = extractHttpStatus(error);
if (status !== undefined && TRANSIENT_HTTP_STATUSES.has(status)) {
return true;
}

return false;
}

/**
* Produce a short, telemetry-safe summary of an arbitrary error value. The
* goal is to avoid emitting full HTML response bodies (or other huge payloads)
* as log/error titles, which causes noisy and ungrouped Sentry issues.
*/
export function summarizeError(error: unknown, maxLength = 200): string {
if (error instanceof Error) {
const status = extractHttpStatus(error);
const base = error.message?.trim() || error.name || "Error";
if (looksLikeHtmlDocument(base)) {
return status !== undefined ? `HTTP ${status} (HTML response)` : "HTML response body";
}
const prefix = status !== undefined ? `HTTP ${status}: ` : "";
return truncate(`${prefix}${base}`, maxLength);
}

if (typeof error === "string") {
if (looksLikeHtmlDocument(error)) {
return "HTML response body";
}
return truncate(error.trim(), maxLength);
}

const status = extractHttpStatus(error);
if (status !== undefined) {
return `HTTP ${status}`;
}

if (error && typeof error === "object") {
try {
return truncate(JSON.stringify(error), maxLength);
} catch {
return "Unknown error";
}
}

return "Unknown error";
}

function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
}
35 changes: 33 additions & 2 deletions web_src/src/pages/workflowv2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ import { useQueueHistory } from "@/hooks/useQueueHistory";
import { analytics } from "@/lib/analytics";
// getColorClass moved to workflowPageHelpers
import { filterVisibleConfiguration } from "@/lib/components";
import { getApiErrorMessage } from "@/lib/errors";
import { getApiErrorMessage, isTransientHttpError, summarizeError } from "@/lib/errors";
import { Sentry } from "@/sentry";
import { getIntegrationWebhookUrl } from "@/lib/integrationUtils";
import { DefaultLayoutEngine } from "@/lib/layout";
import { withOrganizationHeader } from "@/lib/withOrganizationHeader";
Expand Down Expand Up @@ -169,6 +170,36 @@ type QueuedCanvasSaveRequest = {
reject: (error: unknown) => void;
};
type CanvasEchoRelease = () => void;

/**
* Report a canvas auto-save failure without polluting telemetry.
*
* Auto-save runs silently in the background after position drags. Transient
* failures (HTML error pages from a proxy, auth redirects, offline blips,
* 5xx gateway errors) are expected and will be retried on the next tick, so
* they are dropped here. Unexpected failures are forwarded to Sentry with a
* stable fingerprint so they group together instead of producing one issue
* per unique response body.
*/
function reportAutoSaveFailure(error: unknown): void {
// Transient failures (HTML error pages from a proxy / auth redirect, offline
// blips, 5xx gateways) are expected for a silent background save and will
// retry on the next tick — drop them so they do not flood Sentry.
if (isTransientHttpError(error)) {
return;
}

const summary = summarizeError(error);

Sentry.withScope((scope) => {
scope.setTag("auto_save.scope", "canvas-auto-save");
scope.setExtra("errorSummary", summary);
scope.setFingerprint(["canvas-auto-save-failure"]);
const reportable = error instanceof Error ? error : new Error(`Canvas auto-save failed: ${summary}`);
Sentry.captureException(reportable);
});
}

export function WorkflowPageV2() {
const { organizationId, canvasId } = useParams<{
organizationId: string;
Expand Down Expand Up @@ -1636,7 +1667,7 @@ export function WorkflowPageV2() {

// Auto-save completed silently (no toast or state changes)
} catch (error) {
console.error("Failed to auto-save", error);
reportAutoSaveFailure(error);
} finally {
if (focusedNoteId) {
requestAnimationFrame(() => {
Expand Down
Loading