refactor: eliminate all catch(err: any) with proper unknown typing + type email transport
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 19m10s

- Replace all catch(err: any) with catch(err: unknown) across 8 source files
- Add errorMessage() and errorCode() helpers for safe error property access
- Type nodemailer transport config as SMTPTransport.Options (was any)
- Type health endpoint databaseStatus (was any)
- Type convert route margin param (was any)
- Change queryWithRetry params from any[] to unknown[]
- Update isTransientError to require Error instances (was accepting plain objects)
- 19 new TDD tests (error-type-safety.test.ts)
- Updated existing tests to use proper Error instances
- 598 tests total, all passing, zero type errors
This commit is contained in:
DocFast CEO 2026-03-09 11:10:58 +01:00
parent da049b77e3
commit 5a7ee79316
12 changed files with 221 additions and 98 deletions

View file

@ -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));
});
});

View file

@ -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();
});
});
});

View file

@ -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);
});
});
});

View file

@ -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" });
}

View file

@ -66,7 +66,7 @@ async function isDocFastSubscription(subscriptionId: string): Promise<boolean> {
: (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; }
</div>
<script src="/copy-helper.js"></script>
</body></html>`);
} 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;
}

View file

@ -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;
}

View file

@ -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");

View file

@ -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;

View file

@ -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" });
}

View file

@ -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<pg.QueryResult> {
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<pg.PoolClient> {
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<pg.PoolClient> {
// 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<pg.PoolClient> {
}
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<pg.PoolClient> {
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));

View file

@ -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 <noreply@docfast.dev>";
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<boolean> {

View file

@ -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;
}