From 8ac8d21cb1da1a3b4f935a92c42ed7daf5a49ef8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 16:50:32 +0300 Subject: [PATCH 1/2] fix(errors): pool exhaustion, lost sessions and TLS failures read as what they are (#1316) - A full pool is a PoolExhaustedException (the session count and what to do), not Bad state: ... - Connection reset / broken pipe / terminating connection / server gone away on an established session is ConnectionLostException with a reconnect hint, not did not accept the connection. - HandshakeException and certificate / TLS-version failures are TlsFailureException pointing at the SSL mode and root certificate. --- lib/core/database/database_error_mapper.dart | 49 +++++++++++++++++++ lib/core/database/mysql_connection_pool.dart | 9 +++- .../database/postgres_connection_pool.dart | 9 +++- .../database/querya_database_exception.dart | 34 +++++++++++++ lib/core/database/sqlite_connection_pool.dart | 9 +++- .../database/database_error_mapper_test.dart | 47 ++++++++++++++++++ .../database/mysql_connection_pool_test.dart | 6 ++- .../postgres_connection_pool_test.dart | 20 ++++++++ .../database/sqlite_connection_pool_test.dart | 20 ++++++++ 9 files changed, 195 insertions(+), 8 deletions(-) diff --git a/lib/core/database/database_error_mapper.dart b/lib/core/database/database_error_mapper.dart index 04a28a6a..0d3e5859 100644 --- a/lib/core/database/database_error_mapper.dart +++ b/lib/core/database/database_error_mapper.dart @@ -125,6 +125,55 @@ QueryaDatabaseException mapDatabaseError( originalError: error, stackTrace: stackTrace, ); + } else if (error is TlsException || + _hasAny(lower, const [ + 'certificate_verify_failed', + 'certificate verify failed', + 'handshakeexception', + 'handshake error', + 'unknown ca', + 'self signed certificate', + 'self-signed certificate', + 'unable to get local issuer', + 'certificate has expired', + 'hostname mismatch', + 'wrong version number', + 'tlsv1 alert', + 'ssl routines', + 'server does not support ssl', + ])) { + mapped = TlsFailureException( + 'The secure (TLS) connection to the server could not be established', + detailedExplanation: _innermost(raw), + remediationHint: 'Check the SSL mode and the root certificate in the ' + 'connection settings; a server without TLS needs SSL turned off', + originalError: error, + stackTrace: stackTrace, + ); + } else if (_hasAny(lower, const [ + 'connection reset by peer', + 'broken pipe', + 'connection closed', + 'connection is closed', + 'connection was closed', + 'connection terminated', + 'terminating connection', + 'server closed the connection', + 'unexpected eof', + 'the database system is shutting down', + 'the database system is restarting', + 'lost connection to mysql server', + 'mysql server has gone away', + 'connection lost', + ])) { + mapped = ConnectionLostException( + 'The connection to the database was lost', + detailedExplanation: _innermost(raw), + remediationHint: 'The server closed the session (a restart, an idle ' + 'timeout or a network drop). Reconnect and try again', + originalError: error, + stackTrace: stackTrace, + ); } else if (error is TimeoutException || _hasAny(lower, const ['timed out', 'timeout expired', 'etimedout'])) { mapped = ConnectionTimeoutException( diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart index 274426ef..02429f0e 100644 --- a/lib/core/database/mysql_connection_pool.dart +++ b/lib/core/database/mysql_connection_pool.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -101,6 +102,8 @@ class MysqlConnectionPool { }); } on StateError { rethrow; + } on PoolExhaustedException { + rethrow; } on MysqlConnectionException { rethrow; } catch (e, st) { @@ -128,8 +131,10 @@ class MysqlConnectionPool { while (_pool.length >= maxEntries) { final idle = _pool.entries.where((e) => e.value.refs == 0).toList(); if (idle.isEmpty) { - throw StateError( - 'MySQL connection pool exhausted: $maxEntries slots in use.', + throw PoolExhaustedException( + 'The MySQL connection pool is full: $maxEntries sessions are in use', + remediationHint: 'Close tabs or connections you no longer use, ' + 'then try again', ); } idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index 5eb51c5c..1a040594 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -113,6 +114,8 @@ class PostgresConnectionPool { }); } on StateError { rethrow; + } on PoolExhaustedException { + rethrow; } on PostgresConnectionException { rethrow; } catch (e, st) { @@ -172,8 +175,10 @@ class PostgresConnectionPool { while (_pool.length >= maxEntries) { final idle = _pool.entries.where((e) => e.value.refs == 0).toList(); if (idle.isEmpty) { - throw StateError( - 'PostgreSQL connection pool exhausted: $maxEntries slots in use.', + throw PoolExhaustedException( + 'The PostgreSQL connection pool is full: $maxEntries sessions are in use', + remediationHint: 'Close tabs or connections you no longer use, ' + 'then try again', ); } idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); diff --git a/lib/core/database/querya_database_exception.dart b/lib/core/database/querya_database_exception.dart index b5a678e8..a0b7cb78 100644 --- a/lib/core/database/querya_database_exception.dart +++ b/lib/core/database/querya_database_exception.dart @@ -119,6 +119,40 @@ final class LockTimeoutException extends QueryaDatabaseException { }); } +/// An established session was closed under the client: the server restarted +/// or closed an idle session, or the network dropped (#1316). +final class ConnectionLostException extends QueryaDatabaseException { + const ConnectionLostException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// The TLS handshake or the server certificate was rejected (#1316). +final class TlsFailureException extends QueryaDatabaseException { + const TlsFailureException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + +/// Every slot of a connection pool is in use (#1316). +final class PoolExhaustedException extends QueryaDatabaseException { + const PoolExhaustedException( + super.message, { + super.detailedExplanation, + super.remediationHint, + super.originalError, + super.stackTrace, + }); +} + /// Anything the mapper does not recognize; keeps the original text. final class UnknownDatabaseException extends QueryaDatabaseException { const UnknownDatabaseException( diff --git a/lib/core/database/sqlite_connection_pool.dart b/lib/core/database/sqlite_connection_pool.dart index 42e0e800..49d7048f 100644 --- a/lib/core/database/sqlite_connection_pool.dart +++ b/lib/core/database/sqlite_connection_pool.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:querya_desktop/core/database/database_error_mapper.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -104,6 +105,8 @@ class SqliteConnectionPool { }); } on StateError { rethrow; + } on PoolExhaustedException { + rethrow; } on SqliteConnectionException { rethrow; } catch (e, st) { @@ -131,8 +134,10 @@ class SqliteConnectionPool { while (_pool.length >= maxEntries) { final idle = _pool.entries.where((e) => e.value.refs == 0).toList(); if (idle.isEmpty) { - throw StateError( - 'SQLite connection pool exhausted: $maxEntries slots in use.', + throw PoolExhaustedException( + 'The SQLite connection pool is full: $maxEntries sessions are in use', + remediationHint: 'Close tabs or connections you no longer use, ' + 'then try again', ); } idle.sort((a, b) => a.value.lastUsed.compareTo(b.value.lastUsed)); diff --git a/test/core/database/database_error_mapper_test.dart b/test/core/database/database_error_mapper_test.dart index 1c510558..614cea03 100644 --- a/test/core/database/database_error_mapper_test.dart +++ b/test/core/database/database_error_mapper_test.dart @@ -10,6 +10,53 @@ import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; void main() { + group('lost sessions and TLS (#1316)', () { + test('a reset or closed session is a lost connection, not a refusal', () { + for (final text in [ + 'SocketException: Connection reset by peer (OS Error: Connection ' + 'reset by peer, errno = 104)', + 'SocketException: Broken pipe', + 'ERROR: terminating connection due to administrator command', + 'Lost connection to MySQL server during query', + 'PgException: Connection is closed', + ]) { + final e = mapDatabaseError(Exception(text)); + expect(e, isA(), reason: text); + expect(e, isNot(isA()), reason: text); + expect(e.remediationHint, contains('Reconnect')); + } + }); + + test('a refused connection is still unreachable', () { + final e = mapDatabaseError(const SocketException( + 'Connection refused (OS Error: Connection refused, errno = 111)')); + expect(e, isA()); + }); + + test('certificate and handshake failures point at the SSL settings', () { + for (final error in [ + const HandshakeException( + 'Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED: ' + 'self signed certificate)'), + Exception('TlsException: unknown ca'), + Exception('WRONG_VERSION_NUMBER: wrong version number'), + ]) { + final e = mapDatabaseError(error, driver: DatabaseDriver.postgres); + expect(e, isA(), reason: '$error'); + expect(e.remediationHint, contains('SSL mode')); + expect(e.remediationHint, contains('root certificate')); + } + }); + + test('an authentication failure is not taken for TLS', () { + final e = mapDatabaseError( + Exception('password authentication failed for user "bob"'), + driver: DatabaseDriver.postgres, + ); + expect(e, isA()); + }); + }); + group('mapDatabaseError PostgreSQL', () { test('wrong password is AuthFailedException', () { final e = mapDatabaseError( diff --git a/test/core/database/mysql_connection_pool_test.dart b/test/core/database/mysql_connection_pool_test.dart index a5f0e19a..8bccae23 100644 --- a/test/core/database/mysql_connection_pool_test.dart +++ b/test/core/database/mysql_connection_pool_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/database/mysql_connection_pool.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; ConnectionRow _row({int? id = 1}) => ConnectionRow( @@ -96,7 +97,7 @@ void main() { ); }); - test('rethrows StateError directly when pool exhausted', () async { + test('a full pool is a typed exception, not Bad state', () async { final pool = MysqlConnectionPool( maxEntries: 1, createAndConnect: (row, {required database, required mode}) async => @@ -107,7 +108,8 @@ void main() { expect( () => pool.acquire(_row(id: 2), database: 'db2'), - throwsA(isA()), + throwsA(isA().having( + (e) => e.toString(), 'text', allOf(contains('MySQL'), contains('Close tabs')))), ); }); diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index a37416fa..29afb272 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/database/postgres_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -718,4 +719,23 @@ void main() { c.release(); }); }); + + test('a full pool is a typed exception with a hint, not Bad state (#1316)', + () async { + final pool = PostgresConnectionPool( + maxEntries: 1, + createAndConnect: (row, {required database, required mode}) async => + FakePostgresConnection(id: row.id ?? 1), + ); + await pool.acquire(_row(id: 1), database: 'db1'); // in use + + await expectLater( + pool.acquire(_row(id: 2), database: 'db2'), + throwsA(isA().having( + (e) => e.toString(), + 'text', + allOf(contains('PostgreSQL'), contains('Close tabs'), + isNot(contains('Bad state'))))), + ); + }); } diff --git a/test/core/database/sqlite_connection_pool_test.dart b/test/core/database/sqlite_connection_pool_test.dart index 2dc493de..c0a29cc2 100644 --- a/test/core/database/sqlite_connection_pool_test.dart +++ b/test/core/database/sqlite_connection_pool_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/querya_database_exception.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/database/sqlite_connection_pool.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -293,4 +294,23 @@ void main() { c.release(); }); }); + + test('a full pool is a typed exception with a hint, not Bad state (#1316)', + () async { + final pool = SqliteConnectionPool( + maxEntries: 1, + createAndConnect: (row, {required mode}) async => + FakeSqliteConnection(id: row.id ?? 1), + ); + await pool.acquire(_row(id: 1)); // in use + + await expectLater( + pool.acquire(_row(id: 2)), + throwsA(isA().having( + (e) => e.toString(), + 'text', + allOf(contains('SQLite'), contains('Close tabs'), + isNot(contains('Bad state'))))), + ); + }); } From 196f046914ee1655e13e7c5d957c3cfeb0d7f2f5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 16:55:06 +0300 Subject: [PATCH 2/2] test(db): the pool exhaustion tests expect the typed exception (#1316) --- test/core/database/postgres_connection_pool_test.dart | 2 +- test/core/database/sqlite_connection_pool_test.dart | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index 29afb272..219e74d2 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -490,7 +490,7 @@ void main() { final l1 = await pool.acquire(_row(id: 1), database: 'postgres'); await expectLater( pool.acquire(_row(id: 2), database: 'postgres'), - throwsA(isA()), + throwsA(isA()), ); l1.release(); }); diff --git a/test/core/database/sqlite_connection_pool_test.dart b/test/core/database/sqlite_connection_pool_test.dart index c0a29cc2..99b30c81 100644 --- a/test/core/database/sqlite_connection_pool_test.dart +++ b/test/core/database/sqlite_connection_pool_test.dart @@ -96,7 +96,8 @@ void main() { l3.release(); }); - test('throws StateError when all slots busy at maxEntries', () async { + test('throws PoolExhaustedException when all slots busy at maxEntries', + () async { final pool = SqliteConnectionPool( maxEntries: 2, createAndConnect: (row, {required mode}) async => @@ -108,7 +109,7 @@ void main() { expect( () => pool.acquire(_row(id: 3)), - throwsA(isA()), + throwsA(isA()), ); });