mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Follow-ups to the v2.1.0 Sentry telemetry overhaul, found by reviewing live release:2.1.0 events: - error_code tag was empty because reportError read only the top-level err.code; add extractErrorCode() to walk the cause chain (pg SQLSTATE, node E-code, else first short code). - InputValidationError from a tool's processV2 in the worker was logged as error_class=bug; classify it as expected for any source. Worker-side ZodError stays a bug (schema drift). - AI dispatcher timeouts rejected with a bare Error, which the sanitizer scrubbed to a message-less "Error: Error"; reject with an operational SafeError (code "timeout") at both timeout sites. Each fix written failing-test-first; affected and adjacent unit suites green plus full CI (integration + e2e).
197 lines
7.9 KiB
TypeScript
197 lines
7.9 KiB
TypeScript
import {
|
|
connectivityClass,
|
|
extractErrorCode,
|
|
isClientAbort,
|
|
rebuildErrorValue,
|
|
SafeError,
|
|
} from "@snapotter/shared";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const sysErr = (code: string, syscall?: string) =>
|
|
Object.assign(new Error(`${code}: boom /Users/secret/file.png`), { code, syscall });
|
|
|
|
describe("rebuildErrorValue", () => {
|
|
it("passes SafeError messages through", () => {
|
|
expect(rebuildErrorValue(new SafeError("Storage directory is not writable"))).toBe(
|
|
"Storage directory is not writable",
|
|
);
|
|
});
|
|
it("rebuilds node system errors as code + syscall, no path", () => {
|
|
expect(rebuildErrorValue(sysErr("EACCES", "mkdir"))).toBe("EACCES mkdir");
|
|
expect(rebuildErrorValue(sysErr("ENOSPC"))).toBe("ENOSPC");
|
|
});
|
|
it("resolves a 5-char EPIPE as a node code, not a SQLSTATE", () => {
|
|
expect(rebuildErrorValue(sysErr("EPIPE", "write"))).toBe("EPIPE write");
|
|
});
|
|
it("finds a pg SQLSTATE through a drizzle-style cause chain, never the SQL", () => {
|
|
const pg = Object.assign(new Error('relation "settings" does not exist'), {
|
|
code: "42P01",
|
|
severity: "ERROR",
|
|
});
|
|
const wrapped = Object.assign(new Error('Failed query: select "value" from "settings"'), {
|
|
cause: pg,
|
|
});
|
|
expect(rebuildErrorValue(wrapped)).toBe("pg 42P01");
|
|
});
|
|
it("appends a vetted pg routine when present", () => {
|
|
const pg = Object.assign(new Error("terminating connection due to administrator command"), {
|
|
code: "57P01",
|
|
routine: "ProcessInterrupts",
|
|
});
|
|
expect(rebuildErrorValue(pg)).toBe("pg 57P01 ProcessInterrupts");
|
|
});
|
|
it("keeps only the first token of a redis ReplyError", () => {
|
|
const err = Object.assign(new Error("NOAUTH Authentication required secret-arg"), {
|
|
name: "ReplyError",
|
|
});
|
|
expect(rebuildErrorValue(err)).toBe("reply NOAUTH");
|
|
});
|
|
it("returns bare reply when the ReplyError first token is not code-shaped", () => {
|
|
const err = Object.assign(new Error("user_script:1:attempted-to-index-secret"), {
|
|
name: "ReplyError",
|
|
});
|
|
expect(rebuildErrorValue(err)).toBe("reply");
|
|
});
|
|
it("rebuilds zod errors as issue code + path", () => {
|
|
const err = Object.assign(new Error("big zod dump with received values"), {
|
|
name: "ZodError",
|
|
issues: [{ code: "invalid_type", path: ["settings", "width"] }],
|
|
});
|
|
expect(rebuildErrorValue(err)).toBe("zod invalid_type at settings.width");
|
|
});
|
|
it("replaces unvettable zod path segments with a tilde", () => {
|
|
const err = Object.assign(new Error("dump"), {
|
|
name: "ZodError",
|
|
issues: [{ code: "invalid_type", path: ["files", "user secret.pdf", 0] }],
|
|
});
|
|
expect(rebuildErrorValue(err)).toBe("zod invalid_type at files.~.0");
|
|
});
|
|
it("returns null for unknown errors (caller falls back to type-only)", () => {
|
|
expect(rebuildErrorValue(new Error("user file /tmp/x.pdf broke"))).toBeNull();
|
|
expect(rebuildErrorValue("string")).toBeNull();
|
|
expect(rebuildErrorValue(null)).toBeNull();
|
|
});
|
|
it("discards hostile names on the status branch", () => {
|
|
const err = Object.assign(new Error("upstream said no"), {
|
|
name: "Bad Gateway https://internal.host/path",
|
|
status: 502,
|
|
});
|
|
expect(rebuildErrorValue(err)).toBe("HttpError 502");
|
|
});
|
|
it("returns null for a circular cause chain without hanging", () => {
|
|
const err = new Error("loop") as Error & { cause?: unknown };
|
|
err.cause = err;
|
|
expect(rebuildErrorValue(err)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("extractErrorCode", () => {
|
|
it("finds a pg SQLSTATE through a drizzle-style cause chain", () => {
|
|
const pg = Object.assign(new Error("password authentication failed"), {
|
|
code: "28P01",
|
|
severity: "FATAL",
|
|
});
|
|
const wrapped = Object.assign(new Error("Failed query: select 1"), { cause: pg });
|
|
expect(extractErrorCode(wrapped)).toBe("28P01");
|
|
});
|
|
it("finds a node syscall code nested in the chain", () => {
|
|
const net = Object.assign(new Error("getaddrinfo ENOTFOUND db"), { code: "ENOTFOUND" });
|
|
const wrapped = Object.assign(new Error("connect failed"), { cause: net });
|
|
expect(extractErrorCode(wrapped)).toBe("ENOTFOUND");
|
|
});
|
|
it("prefers a pg SQLSTATE over an earlier non-standard code", () => {
|
|
const pg = Object.assign(new Error("deadlock detected"), { code: "40P01" });
|
|
const wrapped = Object.assign(new Error("wrap"), { code: "generic", cause: pg });
|
|
expect(extractErrorCode(wrapped)).toBe("40P01");
|
|
});
|
|
it("falls back to a SafeError's authored code when no pg/node code is present", () => {
|
|
const err = new SafeError("Python script timed out", { kind: "operational", code: "timeout" });
|
|
expect(extractErrorCode(err)).toBe("timeout");
|
|
});
|
|
it("returns null when no code exists anywhere in the chain", () => {
|
|
expect(extractErrorCode(new Error("plain boom"))).toBeNull();
|
|
expect(extractErrorCode("string")).toBeNull();
|
|
expect(extractErrorCode(null)).toBeNull();
|
|
});
|
|
it("returns null when a cause getter throws (hostile)", () => {
|
|
const hostile = new Error("boom");
|
|
Object.defineProperty(hostile, "cause", {
|
|
get() {
|
|
throw new Error("gotcha");
|
|
},
|
|
});
|
|
expect(extractErrorCode(hostile)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("connectivityClass", () => {
|
|
it("classifies pg-unavailable via SQLSTATE 08/57P and drizzle wrapping", () => {
|
|
const pg = Object.assign(new Error("terminating connection"), { code: "57P01" });
|
|
expect(connectivityClass(Object.assign(new Error("Failed query: x"), { cause: pg }))).toBe(
|
|
"pg-unavailable",
|
|
);
|
|
});
|
|
it("classifies ECONN* under pg when the chain looks like pg, else net", () => {
|
|
const conn = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:5432"), {
|
|
code: "ECONNREFUSED",
|
|
});
|
|
expect(connectivityClass(Object.assign(new Error("Failed query: y"), { cause: conn }))).toBe(
|
|
"pg-unavailable",
|
|
);
|
|
expect(connectivityClass(conn)).toBe("net-unavailable");
|
|
});
|
|
it("classifies ioredis connection loss as redis-unavailable", () => {
|
|
const err = Object.assign(new Error("Connection is closed."), {
|
|
name: "MaxRetriesPerRequestError",
|
|
});
|
|
expect(connectivityClass(err)).toBe("redis-unavailable");
|
|
});
|
|
it("returns null for ordinary errors and ReplyError", () => {
|
|
expect(connectivityClass(new Error("nope"))).toBeNull();
|
|
expect(
|
|
connectivityClass(Object.assign(new Error("ERR unknown command"), { name: "ReplyError" })),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("isClientAbort", () => {
|
|
it("matches abort/premature-close/reset shapes", () => {
|
|
expect(isClientAbort(Object.assign(new Error("aborted"), { code: "ECONNRESET" }))).toBe(true);
|
|
expect(
|
|
isClientAbort(
|
|
Object.assign(new Error("premature close"), { code: "ERR_STREAM_PREMATURE_CLOSE" }),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
isClientAbort(Object.assign(new Error("request aborted"), { name: "RequestAbortedError" })),
|
|
).toBe(true);
|
|
expect(isClientAbort(new Error("boom"))).toBe(false);
|
|
});
|
|
it("treats a top-level bare ECONNRESET as a client abort", () => {
|
|
const err = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
|
|
expect(isClientAbort(err)).toBe(true);
|
|
});
|
|
it("does not treat a drizzle-wrapped pg ECONNRESET as a client abort", () => {
|
|
const conn = Object.assign(new Error("connect ECONNRESET 127.0.0.1:5432"), {
|
|
code: "ECONNRESET",
|
|
});
|
|
const wrapped = Object.assign(new Error("Failed query: z"), { cause: conn });
|
|
expect(isClientAbort(wrapped)).toBe(false);
|
|
expect(connectivityClass(wrapped)).toBe("pg-unavailable");
|
|
});
|
|
});
|
|
|
|
describe("hostile error shapes", () => {
|
|
it("returns the fallback from all three exports when a cause getter throws", () => {
|
|
const hostile = new Error("boom");
|
|
Object.defineProperty(hostile, "cause", {
|
|
get() {
|
|
throw new Error("gotcha");
|
|
},
|
|
});
|
|
expect(rebuildErrorValue(hostile)).toBeNull();
|
|
expect(connectivityClass(hostile)).toBeNull();
|
|
expect(isClientAbort(hostile)).toBe(false);
|
|
});
|
|
});
|