mirror of https://github.com/openclaw/openclaw.git
Compare commits
2 Commits
53de70fc74
...
5a1eb3aea7
| Author | SHA1 | Date |
|---|---|---|
|
|
5a1eb3aea7 | |
|
|
90dec93c93 |
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isAbortError, isTransientNetworkError } from "./unhandled-rejections.js";
|
||||
import {
|
||||
isAbortError,
|
||||
isTransientNetworkError,
|
||||
isTransientSqliteError,
|
||||
} from "./unhandled-rejections.js";
|
||||
|
||||
describe("isAbortError", () => {
|
||||
it("returns true for error with name AbortError", () => {
|
||||
|
|
@ -187,3 +191,59 @@ describe("isTransientNetworkError", () => {
|
|||
expect(isTransientNetworkError(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransientSqliteError", () => {
|
||||
it("returns true for errors with transient SQLite codes", () => {
|
||||
const codes = [
|
||||
"SQLITE_CANTOPEN",
|
||||
"SQLITE_BUSY",
|
||||
"SQLITE_LOCKED",
|
||||
"SQLITE_IOERR_LOCK",
|
||||
"SQLITE_IOERR_SHORT_READ",
|
||||
"SQLITE_IOERR_BLOCKED",
|
||||
];
|
||||
|
||||
for (const code of codes) {
|
||||
const error = Object.assign(new Error("test"), { code });
|
||||
expect(isTransientSqliteError(error), `code: ${code}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for broad SQLITE_IOERR base code", () => {
|
||||
const error = Object.assign(new Error("test"), { code: "SQLITE_IOERR" });
|
||||
expect(isTransientSqliteError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for permanent SQLITE_IOERR subtypes", () => {
|
||||
const permanentCodes = ["SQLITE_IOERR_NOMEM", "SQLITE_IOERR_ACCESS", "SQLITE_IOERR_WRITE"];
|
||||
for (const code of permanentCodes) {
|
||||
const error = Object.assign(new Error("test"), { code });
|
||||
expect(isTransientSqliteError(error), `code: ${code}`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true for SQLite error nested in cause chain", () => {
|
||||
const innerCause = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" });
|
||||
const error = Object.assign(new Error("wrapper"), { cause: innerCause });
|
||||
expect(isTransientSqliteError(error)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for non-SQLite errors", () => {
|
||||
expect(isTransientSqliteError(new Error("Something went wrong"))).toBe(false);
|
||||
expect(isTransientSqliteError(Object.assign(new Error("test"), { code: "ECONNRESET" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for non-transient SQLite errors", () => {
|
||||
const error = Object.assign(new Error("test"), { code: "SQLITE_CORRUPT" });
|
||||
expect(isTransientSqliteError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([null, undefined, "string error", 42, { message: "plain object" }])(
|
||||
"returns false for non-SQLite input %#",
|
||||
(value) => {
|
||||
expect(isTransientSqliteError(value)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,6 +20,19 @@ const FATAL_ERROR_CODES = new Set([
|
|||
|
||||
const CONFIG_ERROR_CODES = new Set(["INVALID_CONFIG", "MISSING_API_KEY", "MISSING_CREDENTIALS"]);
|
||||
|
||||
// SQLite error codes that indicate transient failures (shouldn't crash the gateway).
|
||||
// Note: we intentionally do NOT include the broad SQLITE_IOERR base code here because
|
||||
// many IO-error subtypes (e.g. SQLITE_IOERR_NOMEM, SQLITE_IOERR_ACCESS) are permanent.
|
||||
// Only specific transient IO-error subtypes are listed.
|
||||
const TRANSIENT_SQLITE_CODES = new Set([
|
||||
"SQLITE_CANTOPEN",
|
||||
"SQLITE_BUSY",
|
||||
"SQLITE_LOCKED",
|
||||
"SQLITE_IOERR_LOCK",
|
||||
"SQLITE_IOERR_SHORT_READ",
|
||||
"SQLITE_IOERR_BLOCKED",
|
||||
]);
|
||||
|
||||
// Network error codes that indicate transient failures (shouldn't crash the gateway)
|
||||
const TRANSIENT_NETWORK_CODES = new Set([
|
||||
"ECONNRESET",
|
||||
|
|
@ -112,6 +125,21 @@ function extractErrorCodeWithCause(err: unknown): string | undefined {
|
|||
return extractErrorCode(getErrorCause(err));
|
||||
}
|
||||
|
||||
/** Shared callback for {@link collectErrorGraphCandidates} used by both SQLite and network checks. */
|
||||
function collectNestedErrorSources(current: Record<string, unknown>): Array<unknown> {
|
||||
const nested: Array<unknown> = [
|
||||
current.cause,
|
||||
current.reason,
|
||||
current.original,
|
||||
current.error,
|
||||
current.data,
|
||||
];
|
||||
if (Array.isArray(current.errors)) {
|
||||
nested.push(...current.errors);
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is an AbortError.
|
||||
* These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
|
||||
|
|
@ -142,6 +170,35 @@ function isConfigError(err: unknown): boolean {
|
|||
return code !== undefined && CONFIG_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is a transient SQLite error that shouldn't crash the gateway.
|
||||
* These are typically temporary I/O or locking issues (e.g., running as a LaunchAgent on macOS).
|
||||
*/
|
||||
export function isTransientSqliteError(err: unknown): boolean {
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
for (const candidate of collectErrorGraphCandidates(err, collectNestedErrorSources)) {
|
||||
const code = extractErrorCodeOrErrno(candidate);
|
||||
if (code && TRANSIENT_SQLITE_CODES.has(code)) {
|
||||
return true;
|
||||
}
|
||||
// node:sqlite surfaces errors as plain Error with message text rather than
|
||||
// structured error codes. Match transient patterns in the message.
|
||||
if (candidate && typeof candidate === "object" && "message" in candidate) {
|
||||
const msg = String((candidate as { message: unknown }).message).toLowerCase();
|
||||
if (
|
||||
msg.includes("database is locked") ||
|
||||
msg.includes("database is busy") ||
|
||||
msg.includes("unable to open database")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an error is a transient network error that shouldn't crash the gateway.
|
||||
* These are typically temporary connectivity issues that will resolve on their own.
|
||||
|
|
@ -150,19 +207,7 @@ export function isTransientNetworkError(err: unknown): boolean {
|
|||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
for (const candidate of collectErrorGraphCandidates(err, (current) => {
|
||||
const nested: Array<unknown> = [
|
||||
current.cause,
|
||||
current.reason,
|
||||
current.original,
|
||||
current.error,
|
||||
current.data,
|
||||
];
|
||||
if (Array.isArray(current.errors)) {
|
||||
nested.push(...current.errors);
|
||||
}
|
||||
return nested;
|
||||
})) {
|
||||
for (const candidate of collectErrorGraphCandidates(err, collectNestedErrorSources)) {
|
||||
const code = extractErrorCodeOrErrno(candidate);
|
||||
if (code && TRANSIENT_NETWORK_CODES.has(code)) {
|
||||
return true;
|
||||
|
|
@ -251,6 +296,11 @@ export function installUnhandledRejectionHandler(): void {
|
|||
return;
|
||||
}
|
||||
|
||||
if (isTransientSqliteError(reason)) {
|
||||
console.warn("[openclaw] Non-fatal SQLite error (continuing):", formatUncaughtError(reason));
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("[openclaw] Unhandled promise rejection:", formatUncaughtError(reason));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue