From 5999c888f73bdbffe4652bb7fd3b26d36549162a Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 5 Aug 2026 15:37:00 +0100 Subject: [PATCH] fix(webapp): honor the configured database connect timeout The Prisma connection URLs were built with a connection_timeout query param, which the Postgres connector ignores (the parameter is connect_timeout), so every client silently fell back to the 5s default instead of the configured value. Brief connection spikes could then exceed 5s while establishing a connection and surface as "Can't reach database server". All four clients now build their connection URL through one shared helper that sets connect_timeout, so the configured timeout actually applies. --- .../fix-database-connect-timeout.md | 6 ++ apps/webapp/app/db.server.ts | 55 +++++++------------ .../app/utils/prismaConnectionUrl.test.ts | 30 ++++++++++ apps/webapp/app/utils/prismaConnectionUrl.ts | 18 ++++++ 4 files changed, 75 insertions(+), 34 deletions(-) create mode 100644 .server-changes/fix-database-connect-timeout.md create mode 100644 apps/webapp/app/utils/prismaConnectionUrl.test.ts create mode 100644 apps/webapp/app/utils/prismaConnectionUrl.ts diff --git a/.server-changes/fix-database-connect-timeout.md b/.server-changes/fix-database-connect-timeout.md new file mode 100644 index 00000000000..9f46e012d40 --- /dev/null +++ b/.server-changes/fix-database-connect-timeout.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Honor the configured database connection timeout so brief connection spikes no longer surface as spurious "can't reach database server" errors. diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 97a3ae7bb53..002a84d6bce 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { env } from "./env.server"; import { logger } from "./services/logger.server"; import { isValidDatabaseUrl } from "./utils/db"; +import { buildPrismaConnectionUrl } from "./utils/prismaConnectionUrl"; import { captureInfrastructureErrors, infraErrorAlreadyLogged, @@ -395,11 +396,11 @@ export function buildWriterClient({ url: string; clientType: string; }): PrismaClient { - const databaseUrl = extendQueryParams(url, { - connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), - pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), - connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), - application_name: env.SERVICE_NAME, + const databaseUrl = buildPrismaConnectionUrl(url, { + connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(), + poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(), + connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + applicationName: env.SERVICE_NAME, }); console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`); @@ -542,11 +543,11 @@ export function buildReplicaClient({ url: string; clientType: string; }): PrismaClient { - const replicaUrl = extendQueryParams(url, { - connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), - pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), - connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), - application_name: env.SERVICE_NAME, + const replicaUrl = buildPrismaConnectionUrl(url, { + connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(), + poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(), + connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + applicationName: env.SERVICE_NAME, }); console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`); @@ -672,11 +673,11 @@ function buildRunOpsWriterClient({ url: string; clientType: string; }): RunOpsPrismaClient { - const databaseUrl = extendQueryParams(url, { - connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), - pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), - connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), - application_name: env.SERVICE_NAME, + const databaseUrl = buildPrismaConnectionUrl(url, { + connectionLimit: env.DATABASE_CONNECTION_LIMIT.toString(), + poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(), + connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + applicationName: env.SERVICE_NAME, }); console.log(`🔌 setting up run-ops prisma client to ${redactUrlSecrets(databaseUrl)}`); @@ -723,14 +724,13 @@ function buildRunOpsReplicaClient({ url: string; clientType: string; }): RunOpsPrismaClient { - const replicaUrl = extendQueryParams(url, { - // The new run-ops replica connects unpooled, so allow capping it independently of the writer. - connection_limit: ( + const replicaUrl = buildPrismaConnectionUrl(url, { + connectionLimit: ( env.RUN_OPS_DATABASE_READ_REPLICA_CONNECTION_LIMIT ?? env.DATABASE_CONNECTION_LIMIT ).toString(), - pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), - connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), - application_name: env.SERVICE_NAME, + poolTimeout: env.DATABASE_POOL_TIMEOUT.toString(), + connectTimeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + applicationName: env.SERVICE_NAME, }); console.log(`🔌 setting up run-ops read replica connection to ${redactUrlSecrets(replicaUrl)}`); @@ -789,19 +789,6 @@ export function sameDatabaseTarget(a: string | undefined, b: string | undefined) } } -function extendQueryParams(hrefOrUrl: string | URL, queryParams: Record) { - const url = new URL(hrefOrUrl); - const query = url.searchParams; - - for (const [key, val] of Object.entries(queryParams)) { - query.set(key, val); - } - - url.search = query.toString(); - - return url; -} - function redactUrlSecrets(hrefOrUrl: string | URL) { const url = new URL(hrefOrUrl); url.password = ""; diff --git a/apps/webapp/app/utils/prismaConnectionUrl.test.ts b/apps/webapp/app/utils/prismaConnectionUrl.test.ts new file mode 100644 index 00000000000..bdebf7b973d --- /dev/null +++ b/apps/webapp/app/utils/prismaConnectionUrl.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { buildPrismaConnectionUrl } from "./prismaConnectionUrl"; + +describe("buildPrismaConnectionUrl", () => { + it("sets connect_timeout (the Postgres connector parameter), not the ignored connection_timeout", () => { + const url = buildPrismaConnectionUrl("postgresql://u:p@host:5432/db?schema=public", { + connectionLimit: "10", + poolTimeout: "0", + connectTimeout: "20", + applicationName: "svc", + }); + + expect(url.searchParams.get("connect_timeout")).toBe("20"); + expect(url.searchParams.has("connection_timeout")).toBe(false); + expect(url.searchParams.get("connection_limit")).toBe("10"); + expect(url.searchParams.get("pool_timeout")).toBe("0"); + expect(url.searchParams.get("application_name")).toBe("svc"); + }); + + it("preserves existing base query params", () => { + const url = buildPrismaConnectionUrl( + "postgresql://u:p@host:5432/db?schema=public&sslmode=require", + { connectionLimit: "5", poolTimeout: "10", connectTimeout: "20", applicationName: "svc" } + ); + + expect(url.searchParams.get("schema")).toBe("public"); + expect(url.searchParams.get("sslmode")).toBe("require"); + expect(url.searchParams.get("connect_timeout")).toBe("20"); + }); +}); diff --git a/apps/webapp/app/utils/prismaConnectionUrl.ts b/apps/webapp/app/utils/prismaConnectionUrl.ts new file mode 100644 index 00000000000..deab62c8eca --- /dev/null +++ b/apps/webapp/app/utils/prismaConnectionUrl.ts @@ -0,0 +1,18 @@ +export type PrismaConnectionParams = { + connectionLimit: string; + poolTimeout: string; + connectTimeout: string; + applicationName: string; +}; + +export function buildPrismaConnectionUrl( + baseUrl: string | URL, + params: PrismaConnectionParams +): URL { + const url = new URL(baseUrl); + url.searchParams.set("connection_limit", params.connectionLimit); + url.searchParams.set("pool_timeout", params.poolTimeout); + url.searchParams.set("connect_timeout", params.connectTimeout); + url.searchParams.set("application_name", params.applicationName); + return url; +}