diff --git a/docs/runtime/sql.mdx b/docs/runtime/sql.mdx index ac8692a4d6d7..9e77f2d860e5 100644 --- a/docs/runtime/sql.mdx +++ b/docs/runtime/sql.mdx @@ -1041,15 +1041,16 @@ try { ### PostgreSQL Connection Errors -| Connection Errors | Description | -| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ERR_POSTGRES_CONNECTION_CLOSED` | An established connection was terminated | -| `ERR_POSTGRES_CONNECTION_FAILED` | Connection could not be established (refused, or socket closed before the handshake completed, e.g. an intermediary accepted then closed); errors the server sends during startup, like `57P03`, surface as `ERR_POSTGRES_SERVER_ERROR` | -| `ERR_POSTGRES_CONNECTION_TIMEOUT` | Failed to establish connection within timeout period | -| `ERR_POSTGRES_IDLE_TIMEOUT` | Connection closed due to inactivity | -| `ERR_POSTGRES_LIFETIME_TIMEOUT` | Connection exceeded maximum lifetime | -| `ERR_POSTGRES_TLS_NOT_AVAILABLE` | SSL/TLS connection not available | -| `ERR_POSTGRES_TLS_UPGRADE_FAILED` | Failed to upgrade connection to SSL/TLS | +| Connection Errors | Description | +| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ERR_POSTGRES_CONNECTION_CLOSED` | An established connection was terminated | +| `ERR_POSTGRES_CONNECTION_FAILED` | Connection was accepted but closed before the handshake completed (e.g. the server is still starting up). Retried with backoff until `connectionTimeout` while queries are waiting. Note: errors the server sends during startup, like `57P03`, surface as `ERR_POSTGRES_SERVER_ERROR` | +| `ERR_POSTGRES_CONNECTION_REFUSED` | Connection was refused because nothing is listening at the address. Fails immediately and is not retried | +| `ERR_POSTGRES_CONNECTION_TIMEOUT` | Failed to establish connection within timeout period | +| `ERR_POSTGRES_IDLE_TIMEOUT` | Connection closed due to inactivity | +| `ERR_POSTGRES_LIFETIME_TIMEOUT` | Connection exceeded maximum lifetime | +| `ERR_POSTGRES_TLS_NOT_AVAILABLE` | SSL/TLS connection not available | +| `ERR_POSTGRES_TLS_UPGRADE_FAILED` | Failed to upgrade connection to SSL/TLS | ### Authentication Errors diff --git a/src/js/internal/sql/mysql.ts b/src/js/internal/sql/mysql.ts index f94578e56edb..4786bfc84570 100644 --- a/src/js/internal/sql/mysql.ts +++ b/src/js/internal/sql/mysql.ts @@ -304,6 +304,12 @@ class PooledMySQLConnection { flags: number = 0; /// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries queryCount: number = 0; + /// when the current connect cycle started; 0 when not connecting. Connect + /// failures (server not yet accepting connections) are retried until + /// connectionTimeout elapses from this point. + connectStartedAt: number = 0; + connectAttempts: number = 0; + retryTimer: ReturnType | null = null; #onConnected(err, connection) { if (err) { @@ -318,6 +324,7 @@ class PooledMySQLConnection { } this.storedError = err; if (!err) { + this.connectStartedAt = 0; this.flags |= PooledConnectionFlags.canBeConnected; } this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected; @@ -343,12 +350,40 @@ class PooledMySQLConnection { if (err) { err = wrapError(err); } + this.connection = null; + this.storedError = err; + if (this.#shouldRetryConnecting(err)) { + // The server is not accepting connections yet (e.g. still starting + // up). Keep the slot pending and retry with backoff instead of + // failing the queries that are waiting for a connection. The user's + // onclose callback only fires when the slot actually closes. + this.connectAttempts++; + const delay = Math.min(20 * 2 ** this.connectAttempts, 1000); + this.retryTimer = setTimeout(PooledMySQLConnection.#retryTimerFired, delay, this); + return; + } + // this connect cycle is over; a later retry() starts a fresh one + this.connectStartedAt = 0; + this.#finishClose(err); + } + + static #retryTimerFired(self: PooledMySQLConnection) { + self.retryTimer = null; + // conditions may have changed during the backoff (pool closing, waiters + // gone, retry budget elapsed), so re-check before dialing + if (self.#canKeepRetrying()) { + self.#startConnection(); + } else { + self.#finishClose(self.storedError); + } + } + + #finishClose(err) { const connectionInfo = this.connectionInfo; if (connectionInfo?.onclose) { connectionInfo.onclose(err); } this.state = PooledConnectionState.closed; - this.connection = null; this.storedError = err; // remove from ready connections if its there @@ -378,9 +413,60 @@ class PooledMySQLConnection { } #startConnection() { + if (this.connectStartedAt === 0) { + this.connectStartedAt = Date.now(); + this.connectAttempts = 0; + } PooledMySQLConnection.createConnection(this.connectionInfo, this.#onConnected.bind(this), this.#onClose.bind(this)); } + /// Connect failures (ERR_MYSQL_CONNECTION_FAILED) mean the server + /// accepted the TCP connection but closed it before the handshake + /// completed — typically it is still starting up, or an intermediary + /// (like a container port proxy) is up before the database is. Those are + /// retried until connectionTimeout elapses, as long as queries are + /// waiting on the pool. Refused connections + /// (ERR_MYSQL_CONNECTION_REFUSED) fail fast: nothing is listening, + /// and probes/healthchecks rely on the immediate error. Real server errors (authentication, + /// handshake errors) and closes of established connections are not + /// retried here. + #shouldRetryConnecting(err: Error | null): boolean { + // connect failures come from the native layer as options objects that + // wrapError turned into MySQLError instances with a typed code + if (!(err instanceof MySQLError) || err.code !== "ERR_MYSQL_CONNECTION_FAILED") { + return false; + } + return this.#canKeepRetrying(); + } + + #canKeepRetrying(): boolean { + if (this.adapter.closed || this.onFinish !== null) { + return false; + } + // only retry while queries are actually waiting for a connection + if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) { + return false; + } + // an explicit connectionTimeout of 0 disables the connect timer, and with + // it the retry budget + const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000; + if (connectionTimeout <= 0) { + return false; + } + return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout; + } + + /// Returns true if a scheduled connect retry was cancelled — in that case + /// nothing is in flight and no onClose/onConnected callback will fire. + cancelRetry(): boolean { + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + return true; + } + return false; + } + onClose(onClose: (err: Error) => void) { this.queries.add(onClose); } @@ -396,6 +482,7 @@ class PooledMySQLConnection { } // reset error and state this.storedError = null; + this.connectStartedAt = 0; this.state = PooledConnectionState.pending; // retry connection this.#startConnection(); @@ -672,6 +759,11 @@ class MySQLAdapter for (const pending of reservedQueue) { pending(connection.storedError, connection); } + // draining the queues may have been the last pending work — a + // graceful close() is waiting on this callback + if (this.onAllQueriesFinished && !this.hasPendingQueries()) { + this.onAllQueriesFinished(); + } } return; } @@ -766,6 +858,12 @@ class MySQLAdapter switch (connection.state) { case PooledConnectionState.pending: { + if (connection.cancelRetry()) { + // a connect retry was scheduled; nothing is in flight so + // there is no onClose/onConnected to wait for + connection.state = PooledConnectionState.closed; + break; + } const { promise, resolve } = Promise.withResolvers(); connection.onFinish = resolve; promises.push(promise); diff --git a/src/js/internal/sql/postgres.ts b/src/js/internal/sql/postgres.ts index e37da6c6b8f6..655f2509f189 100644 --- a/src/js/internal/sql/postgres.ts +++ b/src/js/internal/sql/postgres.ts @@ -530,6 +530,12 @@ class PooledPostgresConnection { flags: number = 0; /// queryCount is used to indicate the number of queries using the connection, if a connection is reserved or if its a transaction queryCount will be 1 independently of the number of queries queryCount: number = 0; + /// when the current connect cycle started; 0 when not connecting. Connect + /// failures (server not yet accepting connections) are retried until + /// connectionTimeout elapses from this point. + connectStartedAt: number = 0; + connectAttempts: number = 0; + retryTimer: ReturnType | null = null; #onConnected(err, _) { if (err) { @@ -541,6 +547,7 @@ class PooledPostgresConnection { } this.storedError = err; if (!err) { + this.connectStartedAt = 0; this.flags |= PooledConnectionFlags.canBeConnected; } this.state = err ? PooledConnectionState.closed : PooledConnectionState.connected; @@ -566,12 +573,40 @@ class PooledPostgresConnection { if (err) { err = wrapPostgresError(err); } + this.connection = null; + this.storedError = err; + if (this.#shouldRetryConnecting(err)) { + // The server is not accepting connections yet (e.g. still starting + // up). Keep the slot pending and retry with backoff instead of + // failing the queries that are waiting for a connection. The user's + // onclose callback only fires when the slot actually closes. + this.connectAttempts++; + const delay = Math.min(20 * 2 ** this.connectAttempts, 1000); + this.retryTimer = setTimeout(PooledPostgresConnection.#retryTimerFired, delay, this); + return; + } + // this connect cycle is over; a later retry() starts a fresh one + this.connectStartedAt = 0; + this.#finishClose(err); + } + + static #retryTimerFired(self: PooledPostgresConnection) { + self.retryTimer = null; + // conditions may have changed during the backoff (pool closing, waiters + // gone, retry budget elapsed), so re-check before dialing + if (self.#canKeepRetrying()) { + self.#startConnection(); + } else { + self.#finishClose(self.storedError); + } + } + + #finishClose(err) { const connectionInfo = this.connectionInfo; if (connectionInfo?.onclose) { connectionInfo.onclose(err); } this.state = PooledConnectionState.closed; - this.connection = null; this.storedError = err; // remove from ready connections if its there @@ -601,6 +636,10 @@ class PooledPostgresConnection { } async #startConnection() { + if (this.connectStartedAt === 0) { + this.connectStartedAt = Date.now(); + this.connectAttempts = 0; + } this.connection = await PooledPostgresConnection.createConnection( this.connectionInfo, this.#onConnected.bind(this), @@ -608,6 +647,53 @@ class PooledPostgresConnection { ); } + /// Connect failures (ERR_POSTGRES_CONNECTION_FAILED) mean the server + /// accepted the TCP connection but closed it before the handshake + /// completed — typically it is still starting up, or an intermediary + /// (like a container port proxy) is up before the database is. Those are + /// retried until connectionTimeout elapses, as long as queries are + /// waiting on the pool. Refused connections + /// (ERR_POSTGRES_CONNECTION_REFUSED) fail fast: nothing is listening, + /// and probes/healthchecks rely on the immediate error. Real server errors (authentication, + /// ErrorResponse during startup) and closes of established connections are + /// not retried here. + #shouldRetryConnecting(err: Error | null): boolean { + // connect failures come from the native layer as options objects that + // wrapPostgresError turned into PostgresError instances with a typed code + if (!(err instanceof PostgresError) || err.code !== "ERR_POSTGRES_CONNECTION_FAILED") { + return false; + } + return this.#canKeepRetrying(); + } + + #canKeepRetrying(): boolean { + if (this.adapter.closed || this.onFinish !== null) { + return false; + } + // only retry while queries are actually waiting for a connection + if (this.adapter.waitingQueue.length === 0 && this.adapter.reservedQueue.length === 0) { + return false; + } + // an explicit connectionTimeout of 0 disables the connect timer, and with + // it the retry budget + const connectionTimeout = this.connectionInfo.connectionTimeout ?? 30 * 1000; + if (connectionTimeout <= 0) { + return false; + } + return this.connectStartedAt !== 0 && Date.now() - this.connectStartedAt < connectionTimeout; + } + + /// Returns true if a scheduled connect retry was cancelled — in that case + /// nothing is in flight and no onClose/onConnected callback will fire. + cancelRetry(): boolean { + if (this.retryTimer !== null) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + return true; + } + return false; + } + onClose(onClose: (err: Error) => void) { this.queries.add(onClose); } @@ -623,6 +709,7 @@ class PooledPostgresConnection { } // reset error and state this.storedError = null; + this.connectStartedAt = 0; this.state = PooledConnectionState.pending; // retry connection this.#startConnection(); @@ -909,6 +996,11 @@ class PostgresAdapter for (const pending of reservedQueue) { pending(connection.storedError, connection); } + // draining the queues may have been the last pending work — a + // graceful close() is waiting on this callback + if (this.onAllQueriesFinished && !this.hasPendingQueries()) { + this.onAllQueriesFinished(); + } } return; } @@ -1003,6 +1095,12 @@ class PostgresAdapter switch (connection.state) { case PooledConnectionState.pending: { + if (connection.cancelRetry()) { + // a connect retry was scheduled; nothing is in flight so + // there is no onClose/onConnected to wait for + connection.state = PooledConnectionState.closed; + break; + } const { promise, resolve } = Promise.withResolvers(); connection.onFinish = resolve; promises.push(promise); diff --git a/src/jsc/ErrorCode.rs b/src/jsc/ErrorCode.rs index 871a3b3f94eb..468a3ea5b9fd 100644 --- a/src/jsc/ErrorCode.rs +++ b/src/jsc/ErrorCode.rs @@ -683,9 +683,13 @@ impl ErrorCode { pub const POSTGRES_CONNECTION_FAILED: ErrorCode = ErrorCode(312); /// `ERR_MYSQL_CONNECTION_FAILED` (instanceof Error) pub const MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode(313); + /// `ERR_POSTGRES_CONNECTION_REFUSED` (instanceof Error) + pub const POSTGRES_CONNECTION_REFUSED: ErrorCode = ErrorCode(314); + /// `ERR_MYSQL_CONNECTION_REFUSED` (instanceof Error) + pub const MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode(315); /// == C++ `NODE_ERROR_COUNT`. - pub const COUNT: u16 = 314; + pub const COUNT: u16 = 316; } // ────────────────────────────────────────────────────────────────────────── @@ -873,6 +877,7 @@ impl ErrorCode { ErrorCode::POSTGRES_AUTHENTICATION_FAILED_PBKDF2; pub const ERR_POSTGRES_CONNECTION_CLOSED: ErrorCode = ErrorCode::POSTGRES_CONNECTION_CLOSED; pub const ERR_POSTGRES_CONNECTION_FAILED: ErrorCode = ErrorCode::POSTGRES_CONNECTION_FAILED; + pub const ERR_POSTGRES_CONNECTION_REFUSED: ErrorCode = ErrorCode::POSTGRES_CONNECTION_REFUSED; pub const ERR_POSTGRES_CONNECTION_TIMEOUT: ErrorCode = ErrorCode::POSTGRES_CONNECTION_TIMEOUT; pub const ERR_POSTGRES_EXPECTED_REQUEST: ErrorCode = ErrorCode::POSTGRES_EXPECTED_REQUEST; pub const ERR_POSTGRES_EXPECTED_STATEMENT: ErrorCode = ErrorCode::POSTGRES_EXPECTED_STATEMENT; @@ -929,6 +934,7 @@ impl ErrorCode { pub const ERR_PROXY_INVALID_CONFIG: ErrorCode = ErrorCode::PROXY_INVALID_CONFIG; pub const ERR_MYSQL_CONNECTION_CLOSED: ErrorCode = ErrorCode::MYSQL_CONNECTION_CLOSED; pub const ERR_MYSQL_CONNECTION_FAILED: ErrorCode = ErrorCode::MYSQL_CONNECTION_FAILED; + pub const ERR_MYSQL_CONNECTION_REFUSED: ErrorCode = ErrorCode::MYSQL_CONNECTION_REFUSED; pub const ERR_MYSQL_CONNECTION_TIMEOUT: ErrorCode = ErrorCode::MYSQL_CONNECTION_TIMEOUT; pub const ERR_MYSQL_IDLE_TIMEOUT: ErrorCode = ErrorCode::MYSQL_IDLE_TIMEOUT; pub const ERR_MYSQL_LIFETIME_TIMEOUT: ErrorCode = ErrorCode::MYSQL_LIFETIME_TIMEOUT; @@ -1373,6 +1379,8 @@ static CODE_STR: [&str; ErrorCode::COUNT as usize] = [ "ERR_SECRETS_INTERACTION_REQUIRED", "ERR_POSTGRES_CONNECTION_FAILED", "ERR_MYSQL_CONNECTION_FAILED", + "ERR_POSTGRES_CONNECTION_REFUSED", + "ERR_MYSQL_CONNECTION_REFUSED", ]; // ────────────────────────────────────────────────────────────────────────── diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 2ab97b460c7d..261f06a313d5 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -324,5 +324,7 @@ const errors: ErrorCodeMapping = [ ["ERR_SECRETS_INTERACTION_REQUIRED", Error], ["ERR_POSTGRES_CONNECTION_FAILED", Error, "PostgresError"], ["ERR_MYSQL_CONNECTION_FAILED", Error, "MySQLError"], + ["ERR_POSTGRES_CONNECTION_REFUSED", Error, "PostgresError"], + ["ERR_MYSQL_CONNECTION_REFUSED", Error, "MySQLError"], ]; export default errors; diff --git a/src/sql/mysql/protocol/AnyMySQLError.rs b/src/sql/mysql/protocol/AnyMySQLError.rs index 99247761d042..d3e8ae850bc6 100644 --- a/src/sql/mysql/protocol/AnyMySQLError.rs +++ b/src/sql/mysql/protocol/AnyMySQLError.rs @@ -5,6 +5,7 @@ pub enum Error { ConnectionClosed, ConnectionFailed, + ConnectionRefused, ConnectionTimedOut, LifetimeTimeout, IdleTimeout, diff --git a/src/sql/postgres/AnyPostgresError.rs b/src/sql/postgres/AnyPostgresError.rs index d52401d2f0ee..26cca6ac063d 100644 --- a/src/sql/postgres/AnyPostgresError.rs +++ b/src/sql/postgres/AnyPostgresError.rs @@ -10,6 +10,7 @@ pub enum AnyPostgresError { ConnectionClosed, ConnectionFailed, + ConnectionRefused, ExpectedRequest, ExpectedStatement, InvalidBackendKeyData, diff --git a/src/sql_jsc/mysql/JSMySQLConnection.rs b/src/sql_jsc/mysql/JSMySQLConnection.rs index 031c7bc51623..03063446695a 100644 --- a/src/sql_jsc/mysql/JSMySQLConnection.rs +++ b/src/sql_jsc/mysql/JSMySQLConnection.rs @@ -1077,7 +1077,7 @@ impl SocketHandler { } pub fn on_connect_error(this: &JSMySQLConnection, _: NewSocketHandler, _: i32) { - this.fail(b"Failed to connect", AnyMySQLErrorT::ConnectionFailed); + this.fail(b"Failed to connect", AnyMySQLErrorT::ConnectionRefused); } pub fn on_timeout(this: &JSMySQLConnection, _: NewSocketHandler) { diff --git a/src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs b/src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs index 8d8f6a8a7875..30d1d0445f9a 100644 --- a/src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs +++ b/src/sql_jsc/mysql/protocol/any_mysql_error_jsc.rs @@ -87,6 +87,7 @@ pub(crate) fn mysql_error_to_js( let code: &'static [u8] = match name { "ConnectionClosed" => b"ERR_MYSQL_CONNECTION_CLOSED", "ConnectionFailed" => b"ERR_MYSQL_CONNECTION_FAILED", + "ConnectionRefused" => b"ERR_MYSQL_CONNECTION_REFUSED", "Overflow" => b"ERR_MYSQL_OVERFLOW", "AuthenticationFailed" => b"ERR_MYSQL_AUTHENTICATION_FAILED", "UnsupportedAuthPlugin" => b"ERR_MYSQL_UNSUPPORTED_AUTH_PLUGIN", diff --git a/src/sql_jsc/postgres/PostgresSQLConnection.rs b/src/sql_jsc/postgres/PostgresSQLConnection.rs index acbcee9ba68a..eee26fd2279a 100644 --- a/src/sql_jsc/postgres/PostgresSQLConnection.rs +++ b/src/sql_jsc/postgres/PostgresSQLConnection.rs @@ -777,7 +777,7 @@ impl PostgresSQLConnection { pub fn on_connect_error(&self) { self.handle_socket_failure(|this| { - this.fail(b"Failed to connect", AnyPostgresError::ConnectionFailed); + this.fail(b"Failed to connect", AnyPostgresError::ConnectionRefused); }); } diff --git a/src/sql_jsc/postgres/error_jsc.rs b/src/sql_jsc/postgres/error_jsc.rs index d302a0056340..84c687de1d06 100644 --- a/src/sql_jsc/postgres/error_jsc.rs +++ b/src/sql_jsc/postgres/error_jsc.rs @@ -57,6 +57,7 @@ pub(crate) fn postgres_error_to_js( let code: &'static [u8] = match err { ConnectionClosed => b"ERR_POSTGRES_CONNECTION_CLOSED", ConnectionFailed => b"ERR_POSTGRES_CONNECTION_FAILED", + ConnectionRefused => b"ERR_POSTGRES_CONNECTION_REFUSED", ExpectedRequest => b"ERR_POSTGRES_EXPECTED_REQUEST", ExpectedStatement => b"ERR_POSTGRES_EXPECTED_STATEMENT", InvalidBackendKeyData => b"ERR_POSTGRES_INVALID_BACKEND_KEY_DATA", diff --git a/test/js/sql/sql-connect-error-reporting.test.ts b/test/js/sql/sql-connect-error-reporting.test.ts index 16414f698b57..2e1053d147ea 100644 --- a/test/js/sql/sql-connect-error-reporting.test.ts +++ b/test/js/sql/sql-connect-error-reporting.test.ts @@ -4,11 +4,15 @@ // (like the container port proxy) accepts the TCP connection and closes it // with no data. Bun previously reported both as a generic // ERR_POSTGRES_CONNECTION_CLOSED "Connection closed", which is misleading — -// the connection was never established. Both are now reported as -// ERR_*_CONNECTION_FAILED with a message saying what actually happened, while -// real server errors (e.g. 57P03 "the database system is starting up") and -// closes of established connections keep their existing reporting. -// See https://github.com/oven-sh/bun/issues/16691. +// the connection was never established. Refused connections are reported as +// ERR_*_CONNECTION_REFUSED and fail fast (nothing is listening; probes and +// healthchecks rely on the immediate error). Pre-handshake closes are +// reported as ERR_*_CONNECTION_FAILED and the pool retries them with backoff +// until connectionTimeout elapses while queries are waiting — so a server +// that becomes ready mid-startup is invisible to the application. Real +// server errors (e.g. 57P03 "the database system is starting up") and closes +// of established connections keep their existing reporting and are not +// retried. See https://github.com/oven-sh/bun/issues/16691. // // Uses plain TCP servers / closed ports so the tests run without Docker. @@ -30,8 +34,10 @@ async function closedPort(): Promise { return port; } +// connectionTimeout (seconds) bounds the connect-retry budget; keep it short +// in tests that expect the failure to surface. async function connectError(url: string): Promise { - const db = new SQL({ url, max: 1 }); + const db = new SQL({ url, max: 1, connectionTimeout: 1 }); try { await db.connect(); throw new Error("expected connect() to reject"); @@ -54,11 +60,14 @@ function postgresAuthOkAndReady(socket: net.Socket) { socket.write(Buffer.concat([authOk, ready])); } -test("postgres: connection refused is reported as a connect failure, not a closed connection", async () => { +test("postgres: connection refused is reported distinctly and fails fast", async () => { const port = await closedPort(); + const start = Date.now(); const err = await connectError(`postgres://postgres@127.0.0.1:${port}/postgres`); expect(err.message).toBe("Failed to connect"); - expect(err.code).toBe("ERR_POSTGRES_CONNECTION_FAILED"); + expect(err.code).toBe("ERR_POSTGRES_CONNECTION_REFUSED"); + // refused is not retried: nothing is listening, fail well inside the budget + expect(Date.now() - start).toBeLessThan(900); }); test("postgres: connection closed before handshake completes is a connect failure", async () => { @@ -74,11 +83,51 @@ test("postgres: connection closed before handshake completes is a connect failur } }); +test("postgres: connect failures are retried while queries wait", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + connections++; + socket.destroy(); + }); + try { + const err = await connectError(`postgres://postgres@127.0.0.1:${port}/postgres`); + expect(err.code).toBe("ERR_POSTGRES_CONNECTION_FAILED"); + // at least one retry happened; the exact count depends on machine speed + expect(connections).toBeGreaterThanOrEqual(2); + } finally { + server.close(); + } +}); + +test("postgres: a server that becomes ready during the retry window is invisible to the application", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + connections++; + if (connections <= 2) { + // still starting up: accept and close with no data + socket.destroy(); + return; + } + socket.on("data", () => postgresAuthOkAndReady(socket)); + }); + const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1 }); + try { + await db.connect(); + expect(connections).toBeGreaterThanOrEqual(3); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); + test("postgres: server ErrorResponse during startup is still surfaced (57P03)", async () => { // A real postgres that is up but still starting replies to the startup // message with FATAL 57P03 and closes. That error must win over the - // socket close that follows it. + // socket close that follows it, and being a real server answer it must + // not be retried. + let connections = 0; const { port, server } = await listeningServer(socket => { + connections++; socket.on("data", () => { const fields: [string, string][] = [ ["S", "FATAL"], @@ -108,6 +157,7 @@ test("postgres: server ErrorResponse during startup is still surfaced (57P03)", expect(err.message).toBe("the database system is starting up"); expect(err.code).toBe("ERR_POSTGRES_SERVER_ERROR"); expect(err.errno).toBe("57P03"); + expect(connections).toBe(1); } finally { server.close(); } @@ -141,11 +191,14 @@ test("postgres: established connection that closes keeps the plain message", asy } }); -test("mysql: connection refused is reported as a connect failure, not a closed connection", async () => { +test("mysql: connection refused is reported distinctly and fails fast", async () => { const port = await closedPort(); + const start = Date.now(); const err = await connectError(`mysql://root@127.0.0.1:${port}/mysql`); expect(err.message).toBe("Failed to connect"); - expect(err.code).toBe("ERR_MYSQL_CONNECTION_FAILED"); + expect(err.code).toBe("ERR_MYSQL_CONNECTION_REFUSED"); + // refused is not retried: nothing is listening, fail well inside the budget + expect(Date.now() - start).toBeLessThan(900); }); test("mysql: connection closed before handshake completes is a connect failure", async () => { @@ -158,3 +211,143 @@ test("mysql: connection closed before handshake completes is a connect failure", server.close(); } }); + +test("mysql: connect failures are retried while queries wait", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + connections++; + socket.destroy(); + }); + try { + const err = await connectError(`mysql://root@127.0.0.1:${port}/mysql`); + expect(err.code).toBe("ERR_MYSQL_CONNECTION_FAILED"); + // at least one retry happened; the exact count depends on machine speed + expect(connections).toBeGreaterThanOrEqual(2); + } finally { + server.close(); + } +}); + +test("postgres: graceful close() resolves while a connect retry is pending", async () => { + // close() with no timeout waits for pending queries; a query stuck behind + // a retrying connection must not deadlock it. + const firstConnection = Promise.withResolvers(); + const { port, server } = await listeningServer(socket => { + firstConnection.resolve(); + socket.destroy(); + }); + const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1 }); + try { + const query = db`SELECT 1`.catch(err => err); + await firstConnection.promise; + await db.close(); + const err = await query; + expect(["ERR_POSTGRES_CONNECTION_FAILED", "ERR_POSTGRES_CONNECTION_CLOSED"]).toContain(err.code); + } finally { + server.close(); + } +}); + +test("postgres: onclose fires once per closed connection, not per retry attempt", async () => { + let connections = 0; + let oncloseCalls = 0; + const { port, server } = await listeningServer(socket => { + connections++; + socket.destroy(); + }); + const db = new SQL({ + url: `postgres://postgres@127.0.0.1:${port}/postgres`, + max: 1, + connectionTimeout: 1, + onclose: () => { + oncloseCalls++; + }, + }); + try { + const err = await db.connect().catch(e => e); + expect(err.code).toBe("ERR_POSTGRES_CONNECTION_FAILED"); + expect(connections).toBeGreaterThanOrEqual(2); + expect(oncloseCalls).toBe(1); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); + +test("postgres: connectionTimeout: 0 disables connect retries", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + connections++; + socket.destroy(); + }); + const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1, connectionTimeout: 0 }); + try { + const err = await db.connect().catch(e => e); + expect(err.code).toBe("ERR_POSTGRES_CONNECTION_FAILED"); + expect(connections).toBe(1); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); + +test("mysql: graceful close() resolves while a connect retry is pending", async () => { + const firstConnection = Promise.withResolvers(); + const { port, server } = await listeningServer(socket => { + firstConnection.resolve(); + socket.destroy(); + }); + const db = new SQL({ url: `mysql://root@127.0.0.1:${port}/mysql`, max: 1 }); + try { + const query = db`SELECT 1`.catch(err => err); + await firstConnection.promise; + await db.close(); + const err = await query; + expect(["ERR_MYSQL_CONNECTION_FAILED", "ERR_MYSQL_CONNECTION_CLOSED"]).toContain(err.code); + } finally { + server.close(); + } +}); + +test("mysql: onclose fires once per closed connection, not per retry attempt", async () => { + let connections = 0; + let oncloseCalls = 0; + const { port, server } = await listeningServer(socket => { + connections++; + socket.destroy(); + }); + const db = new SQL({ + url: `mysql://root@127.0.0.1:${port}/mysql`, + max: 1, + connectionTimeout: 1, + onclose: () => { + oncloseCalls++; + }, + }); + try { + const err = await db.connect().catch(e => e); + expect(err.code).toBe("ERR_MYSQL_CONNECTION_FAILED"); + expect(connections).toBeGreaterThanOrEqual(2); + expect(oncloseCalls).toBe(1); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); + +test("mysql: connectionTimeout: 0 disables connect retries", async () => { + let connections = 0; + const { port, server } = await listeningServer(socket => { + connections++; + socket.destroy(); + }); + const db = new SQL({ url: `mysql://root@127.0.0.1:${port}/mysql`, max: 1, connectionTimeout: 0 }); + try { + const err = await db.connect().catch(e => e); + expect(err.code).toBe("ERR_MYSQL_CONNECTION_FAILED"); + expect(connections).toBe(1); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); diff --git a/test/js/sql/sql-mysql.test.ts b/test/js/sql/sql-mysql.test.ts index 848e8b3d42e3..25c13f49e539 100644 --- a/test/js/sql/sql-mysql.test.ts +++ b/test/js/sql/sql-mysql.test.ts @@ -1092,7 +1092,7 @@ if (isDockerEnabled()) { } catch (err) { error = err; } - expect(error.code).toBe("ERR_MYSQL_CONNECTION_FAILED"); + expect(error.code).toBe("ERR_MYSQL_CONNECTION_REFUSED"); }); test("dynamic table name", async () => { diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 1f0334aefd19..7b8852a294f1 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -2300,7 +2300,7 @@ if (isDockerEnabled()) { } expect(error).toBeInstanceOf(SQL.SQLError); expect(error).toBeInstanceOf(SQL.PostgresError); - expect(error.code).toBe("ERR_POSTGRES_CONNECTION_FAILED"); + expect(error.code).toBe("ERR_POSTGRES_CONNECTION_REFUSED"); }); test("dynamic table name", async () => {