const BACKEND_CALL_PREFIX = /\[CONVEX [^\]]*\]\s*/g; const REQUEST_ID_ONLY = /^\[Request ID:\s*([A-Za-z0-9]+)\]\s*Server Error$/i; /** Preserve actionable public fields without dumping arbitrary backend data. */ export function publicErrorDetails(error: unknown): Record { if (!error || typeof error !== "object") return {}; let outer = error as Record; // Domain wrappers can preserve the original RPC error as a cause. Keep the // walk bounded so an accidental cyclic cause never hides the actual failure. for (let depth = 0; depth < 3; depth++) { if ( outer.data !== undefined || outer.payload !== undefined || typeof outer.code === "string" ) break; if ( !outer.cause || typeof outer.cause !== "object" || outer.cause === outer ) break; outer = outer.cause as Record; } const raw = outer.data ?? outer.payload ?? outer; let data = raw; if (typeof raw === "string") { try { data = JSON.parse(raw); } catch { return {}; } } if (!data || typeof data !== "object" || Array.isArray(data)) return {}; const value = data as Record; const result: Record = {}; for (const key of [ "code", "requestId", "operationId", "executionStatus", "draftId", "receiptId", "nextAction", ] as const) { if (typeof value[key] === "string") result[key] = value[key]; } if (typeof value.retryable === "boolean") result.retryable = value.retryable; const neverRetry = new Set(["uncertain_outcome", "unconfirmed"]); const noAutomaticRetry = new Set([ "invalid_arguments", "validation_failed", "missing_connection", "needs_connection", "needs_sender", "permission_denied", "forbidden", "login_required", "canceled", "cancelled", "stale_owner", "provider_failure", "provider_failed", "upstream_failed", ]); if ( typeof result.code === "string" && (neverRetry.has(result.code) || (result.retryable === undefined && noAutomaticRetry.has(result.code))) ) result.retryable = false; if ( typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) ) { result.retryAfterMs = value.retryAfterMs; } if (Array.isArray(value.nextActions)) { result.nextActions = value.nextActions.filter( (item) => typeof item === "string", ); } return result; } export function publicErrorMessage(message: string): string { const cleaned = message .replace(BACKEND_CALL_PREFIX, "") .replace(/\s*Called by client\s*$/i, "") .trim(); const requestId = cleaned.match(REQUEST_ID_ONLY)?.[1]; return requestId ? `Something went wrong (request ${requestId})` : cleaned; } /** * Production deployments mask `error.message` ("[Request ID: …] Server Error") * but forward `ConvexError.data` verbatim, so the data payload is the message * an agent can act on. */ export function convexErrorData(error: unknown): string | undefined { if (!error || typeof error !== "object" || !("data" in error)) { return undefined; } const data = (error as { data?: unknown }).data; if (typeof data === "string" && data.trim()) return data; if (data && typeof data === "object") { const message = (data as { message?: unknown }).message; if (typeof message === "string" && message.trim()) return message; try { return JSON.stringify(data); } catch { return undefined; } } return undefined; } export function backendErrorText(error: unknown): string { return publicErrorMessage( convexErrorData(error) ?? (error instanceof Error ? error.message : String(error)), ); }