diff --git a/src/__tests__/db-utils.test.ts b/src/__tests__/db-utils.test.ts index ca8323f..12b5faa 100644 --- a/src/__tests__/db-utils.test.ts +++ b/src/__tests__/db-utils.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect } from "vitest"; import { isTransientError } from "../utils/errors.js"; +/** Create an Error with a `.code` property (like Node/pg errors) */ +function makeError(opts: { code?: string; message?: string }): Error { + const err = new Error(opts.message || ""); + if (opts.code) (err as Error & { code: string }).code = opts.code; + return err; +} + describe("isTransientError", () => { describe("transient error codes", () => { for (const code of ["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "CONNECTION_LOST", "57P01", "57P02", "57P03", "08006", "08003", "08001"]) { it(`detects code ${code}`, () => { - expect(isTransientError({ code })).toBe(true); + expect(isTransientError(makeError({ code }))).toBe(true); }); } }); @@ -20,7 +27,7 @@ describe("isTransientError", () => { describe("non-transient errors", () => { it("rejects generic error", () => expect(isTransientError(new Error("something broke"))).toBe(false)); - it("rejects SQL syntax error", () => expect(isTransientError({ code: "42601", message: "syntax error" })).toBe(false)); + it("rejects SQL syntax error", () => expect(isTransientError(makeError({ code: "42601", message: "syntax error" }))).toBe(false)); }); describe("null/undefined input", () => { @@ -29,8 +36,9 @@ describe("isTransientError", () => { }); describe("partial error objects", () => { - it("handles error with code but no message", () => expect(isTransientError({ code: "ECONNRESET" })).toBe(true)); - it("handles error with message but no code", () => expect(isTransientError({ message: "connection terminated" })).toBe(true)); - it("rejects error with unrelated code and no message", () => expect(isTransientError({ code: "UNKNOWN" })).toBe(false)); + it("handles Error with code but no message", () => expect(isTransientError(makeError({ code: "ECONNRESET" }))).toBe(true)); + it("handles Error with message but no code", () => expect(isTransientError(new Error("connection terminated"))).toBe(true)); + it("rejects Error with unrelated code and no message", () => expect(isTransientError(makeError({ code: "UNKNOWN" }))).toBe(false)); + it("rejects plain object (not an Error instance)", () => expect(isTransientError({ code: "ECONNRESET" })).toBe(false)); }); }); diff --git a/src/__tests__/error-type-safety.test.ts b/src/__tests__/error-type-safety.test.ts new file mode 100644 index 0000000..033473a --- /dev/null +++ b/src/__tests__/error-type-safety.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { isTransientError, errorMessage, errorCode } from "../utils/errors.js"; + +describe("error type safety — unknown error types", () => { + describe("isTransientError with non-Error objects", () => { + it("handles string thrown as error", () => { + expect(isTransientError("connection refused")).toBe(false); + }); + + it("handles null thrown as error", () => { + expect(isTransientError(null)).toBe(false); + }); + + it("handles undefined thrown as error", () => { + expect(isTransientError(undefined)).toBe(false); + }); + + it("handles number thrown as error", () => { + expect(isTransientError(42)).toBe(false); + }); + + it("handles plain object with code property", () => { + expect(isTransientError({ code: "ECONNRESET" })).toBe(false); + }); + + it("handles Error with transient code", () => { + const err = new Error("connection reset"); + (err as any).code = "ECONNRESET"; + expect(isTransientError(err)).toBe(true); + }); + + it("handles Error with transient message", () => { + const err = new Error("Connection terminated unexpectedly"); + expect(isTransientError(err)).toBe(true); + }); + + it("handles Error with non-transient message", () => { + const err = new Error("syntax error at position 42"); + expect(isTransientError(err)).toBe(false); + }); + }); + + describe("errorMessage", () => { + it("extracts message from Error", () => { + expect(errorMessage(new Error("test"))).toBe("test"); + }); + + it("returns string directly", () => { + expect(errorMessage("raw string error")).toBe("raw string error"); + }); + + it("stringifies null", () => { + expect(errorMessage(null)).toBe("null"); + }); + + it("stringifies number", () => { + expect(errorMessage(42)).toBe("42"); + }); + + it("stringifies undefined", () => { + expect(errorMessage(undefined)).toBe("undefined"); + }); + }); + + describe("errorCode", () => { + it("extracts code from Error with code", () => { + const err = new Error("fail"); + (err as any).code = "ECONNRESET"; + expect(errorCode(err)).toBe("ECONNRESET"); + }); + + it("returns undefined for Error without code", () => { + expect(errorCode(new Error("fail"))).toBeUndefined(); + }); + + it("returns undefined for non-Error", () => { + expect(errorCode("string")).toBeUndefined(); + }); + + it("returns undefined for null", () => { + expect(errorCode(null)).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts index 7d42e79..a4fc297 100644 --- a/src/__tests__/errors.test.ts +++ b/src/__tests__/errors.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from "vitest"; import { isTransientError } from "../utils/errors.js"; +/** Create an Error with a `.code` property (like Node/pg errors) */ +function makeError(opts: { code?: string; message?: string }): Error { + const err = new Error(opts.message || ""); + if (opts.code) (err as Error & { code: string }).code = opts.code; + return err; +} + describe("isTransientError", () => { describe("null/undefined/empty input", () => { it("returns false for null", () => { @@ -11,189 +18,191 @@ describe("isTransientError", () => { expect(isTransientError(undefined)).toBe(false); }); - it("returns false for empty object", () => { + it("returns false for empty object (not an Error)", () => { expect(isTransientError({})).toBe(false); }); + + it("returns false for plain string", () => { + expect(isTransientError("ECONNRESET")).toBe(false); + }); }); describe("error codes from TRANSIENT_ERRORS set", () => { it("returns true for ECONNRESET", () => { - expect(isTransientError({ code: "ECONNRESET" })).toBe(true); + expect(isTransientError(makeError({ code: "ECONNRESET" }))).toBe(true); }); it("returns true for ECONNREFUSED", () => { - expect(isTransientError({ code: "ECONNREFUSED" })).toBe(true); + expect(isTransientError(makeError({ code: "ECONNREFUSED" }))).toBe(true); }); it("returns true for EPIPE", () => { - expect(isTransientError({ code: "EPIPE" })).toBe(true); + expect(isTransientError(makeError({ code: "EPIPE" }))).toBe(true); }); it("returns true for ETIMEDOUT", () => { - expect(isTransientError({ code: "ETIMEDOUT" })).toBe(true); + expect(isTransientError(makeError({ code: "ETIMEDOUT" }))).toBe(true); }); it("returns true for CONNECTION_LOST", () => { - expect(isTransientError({ code: "CONNECTION_LOST" })).toBe(true); + expect(isTransientError(makeError({ code: "CONNECTION_LOST" }))).toBe(true); }); it("returns true for 57P01 (admin_shutdown)", () => { - expect(isTransientError({ code: "57P01" })).toBe(true); + expect(isTransientError(makeError({ code: "57P01" }))).toBe(true); }); it("returns true for 57P02 (crash_shutdown)", () => { - expect(isTransientError({ code: "57P02" })).toBe(true); + expect(isTransientError(makeError({ code: "57P02" }))).toBe(true); }); it("returns true for 57P03 (cannot_connect_now)", () => { - expect(isTransientError({ code: "57P03" })).toBe(true); + expect(isTransientError(makeError({ code: "57P03" }))).toBe(true); }); it("returns true for 08006 (connection_failure)", () => { - expect(isTransientError({ code: "08006" })).toBe(true); + expect(isTransientError(makeError({ code: "08006" }))).toBe(true); }); it("returns true for 08003 (connection_does_not_exist)", () => { - expect(isTransientError({ code: "08003" })).toBe(true); + expect(isTransientError(makeError({ code: "08003" }))).toBe(true); }); it("returns true for 08001 (sqlclient_unable_to_establish_sqlconnection)", () => { - expect(isTransientError({ code: "08001" })).toBe(true); + expect(isTransientError(makeError({ code: "08001" }))).toBe(true); }); }); describe("message substring matching", () => { it("returns true for 'no available server'", () => { - expect(isTransientError({ message: "no available server" })).toBe(true); + expect(isTransientError(new Error("no available server"))).toBe(true); }); it("returns true for 'connection terminated'", () => { - expect(isTransientError({ message: "connection terminated unexpectedly" })).toBe(true); + expect(isTransientError(new Error("connection terminated unexpectedly"))).toBe(true); }); it("returns true for 'connection refused'", () => { - expect(isTransientError({ message: "connection refused by server" })).toBe(true); + expect(isTransientError(new Error("connection refused by server"))).toBe(true); }); it("returns true for 'server closed the connection'", () => { - expect(isTransientError({ message: "server closed the connection unexpectedly" })).toBe(true); + expect(isTransientError(new Error("server closed the connection unexpectedly"))).toBe(true); }); it("returns true for 'timeout expired'", () => { - expect(isTransientError({ message: "timeout expired waiting for connection" })).toBe(true); + expect(isTransientError(new Error("timeout expired waiting for connection"))).toBe(true); }); }); describe("case-insensitive message matching", () => { it("returns true for 'No Available Server' (mixed case)", () => { - expect(isTransientError({ message: "No Available Server" })).toBe(true); + expect(isTransientError(new Error("No Available Server"))).toBe(true); }); it("returns true for 'CONNECTION TERMINATED' (uppercase)", () => { - expect(isTransientError({ message: "CONNECTION TERMINATED" })).toBe(true); + expect(isTransientError(new Error("CONNECTION TERMINATED"))).toBe(true); }); it("returns true for 'Connection Refused' (title case)", () => { - expect(isTransientError({ message: "Connection Refused" })).toBe(true); + expect(isTransientError(new Error("Connection Refused"))).toBe(true); }); it("returns true for 'SERVER CLOSED THE CONNECTION' (uppercase)", () => { - expect(isTransientError({ message: "SERVER CLOSED THE CONNECTION" })).toBe(true); + expect(isTransientError(new Error("SERVER CLOSED THE CONNECTION"))).toBe(true); }); it("returns true for 'Timeout Expired' (title case)", () => { - expect(isTransientError({ message: "Timeout Expired" })).toBe(true); + expect(isTransientError(new Error("Timeout Expired"))).toBe(true); }); }); describe("non-transient errors", () => { it("returns false for syntax error", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "42601", message: "syntax error at or near SELECT" - })).toBe(false); + }))).toBe(false); }); it("returns false for unique constraint violation", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "23505", message: "duplicate key value violates unique constraint" - })).toBe(false); + }))).toBe(false); }); it("returns false for foreign key violation", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "23503", message: "foreign key constraint violation" - })).toBe(false); + }))).toBe(false); }); it("returns false for not null violation", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "23502", message: "null value in column violates not-null constraint" - })).toBe(false); + }))).toBe(false); }); it("returns false for permission denied", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "42501", message: "permission denied for table users" - })).toBe(false); + }))).toBe(false); }); }); describe("unrelated codes and messages", () => { it("returns false for unrelated error code", () => { - expect(isTransientError({ code: "UNKNOWN_ERROR" })).toBe(false); + expect(isTransientError(makeError({ code: "UNKNOWN_ERROR" }))).toBe(false); }); it("returns false for unrelated error message", () => { - expect(isTransientError({ message: "Something went wrong" })).toBe(false); + expect(isTransientError(new Error("Something went wrong"))).toBe(false); }); it("returns false for generic database error", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "P0001", message: "Database operation failed" - })).toBe(false); + }))).toBe(false); }); it("returns false for application error", () => { - expect(isTransientError({ - message: "Invalid user input" - })).toBe(false); + expect(isTransientError(new Error("Invalid user input"))).toBe(false); }); }); describe("edge cases", () => { it("returns true when both code and message match", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "ECONNRESET", message: "connection terminated" - })).toBe(true); + }))).toBe(true); }); it("returns true when only code matches", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "ETIMEDOUT", message: "some other message" - })).toBe(true); + }))).toBe(true); }); it("returns true when only message matches", () => { - expect(isTransientError({ + expect(isTransientError(makeError({ code: "SOME_CODE", message: "no available server to connect" - })).toBe(true); + }))).toBe(true); }); it("returns false for error with only unrelated code", () => { - expect(isTransientError({ code: "NOTFOUND" })).toBe(false); + expect(isTransientError(makeError({ code: "NOTFOUND" }))).toBe(false); }); - it("returns false for error with empty message", () => { - expect(isTransientError({ message: "" })).toBe(false); + it("returns false for Error with empty message", () => { + expect(isTransientError(new Error(""))).toBe(false); }); }); }); diff --git a/src/index.ts b/src/index.ts index 30f15ee..134100b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -225,7 +225,7 @@ app.post("/admin/cleanup", authMiddleware, adminAuth, async (_req: Request, res: try { const results = await cleanupStaleData(); res.json({ status: "ok", cleaned: results }); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Admin cleanup failed"); res.status(500).json({ error: "Cleanup failed" }); } diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 233c8a2..2a8bd37 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -66,7 +66,7 @@ async function isDocFastSubscription(subscriptionId: string): Promise { : (price?.product as Stripe.Product | null)?.id; return productId === DOCFAST_PRODUCT_ID; }); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err, subscriptionId }, "isDocFastSubscription: failed to retrieve subscription"); return false; } @@ -134,7 +134,7 @@ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) => logger.info({ clientIp, sessionId: session.id }, "Checkout session created"); res.json({ url: session.url }); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Checkout error"); res.status(500).json({ error: "Failed to create checkout session" }); } @@ -214,7 +214,7 @@ a { color: #4f9; } `); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Success page error"); res.status(500).json({ error: "Failed to retrieve session" }); } @@ -237,7 +237,7 @@ router.post("/webhook", async (req: Request, res: Response) => { } else { try { event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Webhook signature verification failed"); res.status(400).json({ error: "Invalid signature" }); return; @@ -265,7 +265,7 @@ router.post("/webhook", async (req: Request, res: Response) => { logger.info({ sessionId: session.id }, "Ignoring event for different product"); break; } - } catch (err: any) { + } catch (err: unknown) { logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items"); break; } diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 9657420..0e14aa7 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -3,6 +3,7 @@ import { renderPdf, renderUrlPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import dns from "node:dns/promises"; import logger from "../services/logger.js"; +import { errorMessage } from "../utils/errors.js"; import { isPrivateIP } from "../utils/network.js"; import { sanitizeFilename } from "../utils/sanitize.js"; @@ -122,13 +123,14 @@ convertRouter.post("/html", async (req: Request, res: Response) => { res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("X-Render-Time", String(durationMs)); res.send(pdf); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Convert HTML error"); - if (err.message === "QUEUE_FULL") { + const msg = errorMessage(err); + if (msg === "QUEUE_FULL") { res.status(503).json({ error: "Server busy — too many concurrent PDF generations. Please try again in a few seconds." }); return; } - if (err.message === "PDF_TIMEOUT") { + if (msg === "PDF_TIMEOUT") { res.status(504).json({ error: "PDF generation timed out." }); return; } @@ -228,13 +230,14 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("X-Render-Time", String(durationMs)); res.send(pdf); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Convert MD error"); - if (err.message === "QUEUE_FULL") { + const msg = errorMessage(err); + if (msg === "QUEUE_FULL") { res.status(503).json({ error: "Server busy — too many concurrent PDF generations. Please try again in a few seconds." }); return; } - if (err.message === "PDF_TIMEOUT") { + if (msg === "PDF_TIMEOUT") { res.status(504).json({ error: "PDF generation timed out." }); return; } @@ -308,7 +311,7 @@ convertRouter.post("/url", async (req: Request, res: Response) => { res.status(415).json({ error: "Unsupported Content-Type. Use application/json." }); return; } - const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string; headerTemplate?: string; footerTemplate?: string; displayHeaderFooter?: boolean; scale?: number; pageRanges?: string; preferCSSPageSize?: boolean; width?: string; height?: string }; + const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: string | { top?: string; right?: string; bottom?: string; left?: string }; printBackground?: boolean; waitUntil?: string; filename?: string; headerTemplate?: string; footerTemplate?: string; displayHeaderFooter?: boolean; scale?: number; pageRanges?: string; preferCSSPageSize?: boolean; width?: string; height?: string }; if (!body.url) { res.status(400).json({ error: "Missing 'url' field" }); @@ -365,13 +368,14 @@ convertRouter.post("/url", async (req: Request, res: Response) => { res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("X-Render-Time", String(durationMs)); res.send(pdf); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Convert URL error"); - if (err.message === "QUEUE_FULL") { + const msg = errorMessage(err); + if (msg === "QUEUE_FULL") { res.status(503).json({ error: "Server busy — too many concurrent PDF generations. Please try again in a few seconds." }); return; } - if (err.message === "PDF_TIMEOUT") { + if (msg === "PDF_TIMEOUT") { res.status(504).json({ error: "PDF generation timed out." }); return; } diff --git a/src/routes/demo.ts b/src/routes/demo.ts index c381925..da7ad63 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit"; import { renderPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import logger from "../services/logger.js"; +import { errorMessage } from "../utils/errors.js"; import { sanitizeFilename } from "../utils/sanitize.js"; import { validatePdfOptions } from "../utils/pdf-options.js"; @@ -144,10 +145,11 @@ router.post("/html", async (req: Request, res: Response) => { res.setHeader("Content-Length", pdf.length); res.setHeader("X-Render-Time", String(durationMs)); res.send(pdf); - } catch (err: any) { - if (err.message === "QUEUE_FULL") { + } catch (err: unknown) { + const msg = errorMessage(err); + if (msg === "QUEUE_FULL") { res.status(503).json({ error: "Server busy. Please try again in a moment." }); - } else if (err.message === "PDF_TIMEOUT") { + } else if (msg === "PDF_TIMEOUT") { res.status(504).json({ error: "PDF generation timed out." }); } else { logger.error({ err }, "Demo HTML conversion failed"); @@ -249,10 +251,11 @@ router.post("/markdown", async (req: Request, res: Response) => { res.setHeader("Content-Length", pdf.length); res.setHeader("X-Render-Time", String(durationMs)); res.send(pdf); - } catch (err: any) { - if (err.message === "QUEUE_FULL") { + } catch (err: unknown) { + const msg = errorMessage(err); + if (msg === "QUEUE_FULL") { res.status(503).json({ error: "Server busy. Please try again in a moment." }); - } else if (err.message === "PDF_TIMEOUT") { + } else if (msg === "PDF_TIMEOUT") { res.status(504).json({ error: "PDF generation timed out." }); } else { logger.error({ err }, "Demo markdown conversion failed"); diff --git a/src/routes/health.ts b/src/routes/health.ts index 39bfb23..c88ea30 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -62,7 +62,7 @@ const HEALTH_CHECK_TIMEOUT_MS = 3000; */ healthRouter.get("/", async (_req, res) => { const poolStats = getPoolStats(); - let databaseStatus: any; + let databaseStatus: { status: string; version?: string; message?: string }; let overallStatus = "ok"; let httpStatus = 200; @@ -91,10 +91,10 @@ healthRouter.get("/", async (_req, res) => { ); databaseStatus = await Promise.race([dbCheck(), timeout]); - } catch (error: any) { + } catch (error: unknown) { databaseStatus = { status: "error", - message: error.message || "Database connection failed" + message: error instanceof Error ? error.message : "Database connection failed" }; overallStatus = "degraded"; httpStatus = 503; diff --git a/src/routes/templates.ts b/src/routes/templates.ts index 37a1648..81e61e3 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -172,7 +172,7 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { res.setHeader("Content-Type", "application/pdf"); res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.send(pdf); - } catch (err: any) { + } catch (err: unknown) { logger.error({ err }, "Template render error"); res.status(500).json({ error: "Template rendering failed" }); } diff --git a/src/services/db.ts b/src/services/db.ts index 6906e5e..500cfa5 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -1,7 +1,7 @@ import pg from "pg"; import logger from "./logger.js"; -import { isTransientError } from "../utils/errors.js"; +import { isTransientError, errorMessage, errorCode } from "../utils/errors.js"; const { Pool } = pg; const pool = new Pool({ @@ -35,10 +35,10 @@ export { isTransientError } from "../utils/errors.js"; */ export async function queryWithRetry( queryText: string, - params?: any[], + params?: unknown[], maxRetries = 3 ): Promise { - let lastError: any; + let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { let client: pg.PoolClient | undefined; @@ -47,7 +47,7 @@ export async function queryWithRetry( const result = await client.query(queryText, params); client.release(); // Return healthy connection to pool return result; - } catch (err: any) { + } catch (err: unknown) { // Destroy the bad connection so pool doesn't reuse it if (client) { try { client.release(true); } catch (_) { /* already destroyed */ } @@ -61,7 +61,7 @@ export async function queryWithRetry( const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); // 1s, 2s, 4s (capped at 5s) logger.warn( - { err: err.message, code: err.code, attempt: attempt + 1, maxRetries, delayMs }, + { err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB error, destroying bad connection and retrying..." ); await new Promise(resolve => setTimeout(resolve, delayMs)); @@ -77,7 +77,7 @@ export async function queryWithRetry( * fresh connections to the new PgBouncer pod. */ export async function connectWithRetry(maxRetries = 3): Promise { - let lastError: any; + let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { @@ -85,7 +85,7 @@ export async function connectWithRetry(maxRetries = 3): Promise { // Validate the connection is actually alive try { await client.query("SELECT 1"); - } catch (validationErr: any) { + } catch (validationErr: unknown) { // Connection is dead — destroy it and retry try { client.release(true); } catch (_) {} if (!isTransientError(validationErr) || attempt === maxRetries) { @@ -93,14 +93,14 @@ export async function connectWithRetry(maxRetries = 3): Promise { } const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); logger.warn( - { err: validationErr.message, code: validationErr.code, attempt: attempt + 1 }, + { err: errorMessage(validationErr), code: errorCode(validationErr), attempt: attempt + 1 }, "Connection validation failed, destroying and retrying..." ); await new Promise(resolve => setTimeout(resolve, delayMs)); continue; } return client; - } catch (err: any) { + } catch (err: unknown) { lastError = err; if (!isTransientError(err) || attempt === maxRetries) { @@ -109,7 +109,7 @@ export async function connectWithRetry(maxRetries = 3): Promise { const delayMs = Math.min(1000 * Math.pow(2, attempt), 5000); logger.warn( - { err: err.message, code: err.code, attempt: attempt + 1, maxRetries, delayMs }, + { err: errorMessage(err), code: errorCode(err), attempt: attempt + 1, maxRetries, delayMs }, "Transient DB connect error, retrying..." ); await new Promise(resolve => setTimeout(resolve, delayMs)); diff --git a/src/services/email.ts b/src/services/email.ts index ad2414d..a426553 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -1,4 +1,5 @@ import nodemailer from "nodemailer"; +import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; import logger from "./logger.js"; const smtpUser = process.env.SMTP_USER; @@ -8,7 +9,7 @@ const smtpPort = Number(process.env.SMTP_PORT || 25); const smtpFrom = process.env.SMTP_FROM || "DocFast "; const smtpSecure = smtpPort === 465; -const transportConfig: any = { +const transportConfig: SMTPTransport.Options = { host: smtpHost, port: smtpPort, secure: smtpSecure, @@ -16,12 +17,9 @@ const transportConfig: any = { greetingTimeout: 5000, socketTimeout: 10000, tls: { rejectUnauthorized: false }, + ...(smtpUser && smtpPass ? { auth: { user: smtpUser, pass: smtpPass } } : {}), }; -if (smtpUser && smtpPass) { - transportConfig.auth = { user: smtpUser, pass: smtpPass }; -} - const transporter = nodemailer.createTransport(transportConfig); export async function sendVerificationEmail(email: string, code: string): Promise { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index b31cb9c..711997c 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -16,10 +16,10 @@ const TRANSIENT_ERRORS = new Set([ /** * Determine if an error is transient (PgBouncer failover, network blip) */ -export function isTransientError(err: any): boolean { - if (!err) return false; - const code = err.code || ""; - const msg = (err.message || "").toLowerCase(); +export function isTransientError(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const code = (err as Error & { code?: string }).code || ""; + const msg = err.message.toLowerCase(); if (TRANSIENT_ERRORS.has(code)) return true; if (msg.includes("no available server")) return true; @@ -30,3 +30,20 @@ export function isTransientError(err: any): boolean { return false; } + +/** + * Extract message from unknown error (safe for catch blocks). + */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + return String(err); +} + +/** + * Extract error code from unknown error (e.g., PostgreSQL/Node error codes). + */ +export function errorCode(err: unknown): string | undefined { + if (err instanceof Error && "code" in err) return (err as Error & { code?: string }).code; + return undefined; +}