Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions lib/core/database/database_error_mapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,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(
Expand Down
9 changes: 7 additions & 2 deletions lib/core/database/mysql_connection_pool.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -101,6 +102,8 @@ class MysqlConnectionPool {
});
} on StateError {
rethrow;
} on PoolExhaustedException {
rethrow;
} on MysqlConnectionException {
rethrow;
} catch (e, st) {
Expand Down Expand Up @@ -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));
Expand Down
9 changes: 7 additions & 2 deletions lib/core/database/postgres_connection_pool.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -113,6 +114,8 @@ class PostgresConnectionPool {
});
} on StateError {
rethrow;
} on PoolExhaustedException {
rethrow;
} on PostgresConnectionException {
rethrow;
} catch (e, st) {
Expand Down Expand Up @@ -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));
Expand Down
34 changes: 34 additions & 0 deletions lib/core/database/querya_database_exception.dart
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,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(
Expand Down
9 changes: 7 additions & 2 deletions lib/core/database/sqlite_connection_pool.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -104,6 +105,8 @@ class SqliteConnectionPool {
});
} on StateError {
rethrow;
} on PoolExhaustedException {
rethrow;
} on SqliteConnectionException {
rethrow;
} catch (e, st) {
Expand Down Expand Up @@ -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));
Expand Down
47 changes: 47 additions & 0 deletions test/core/database/database_error_mapper_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConnectionLostException>(), reason: text);
expect(e, isNot(isA<HostUnreachableException>()), 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<HostUnreachableException>());
});

test('certificate and handshake failures point at the SSL settings', () {
for (final error in <Object>[
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<TlsFailureException>(), 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<AuthFailedException>());
});
});

group('missing columns (#1310)', () {
test('a column of an existing table is not a missing table', () {
final e = mapDatabaseError(
Expand Down
6 changes: 4 additions & 2 deletions test/core/database/mysql_connection_pool_test.dart
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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 =>
Expand All @@ -107,7 +108,8 @@ void main() {

expect(
() => pool.acquire(_row(id: 2), database: 'db2'),
throwsA(isA<StateError>()),
throwsA(isA<PoolExhaustedException>().having(
(e) => e.toString(), 'text', allOf(contains('MySQL'), contains('Close tabs')))),
);
});

Expand Down
22 changes: 21 additions & 1 deletion test/core/database/postgres_connection_pool_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -489,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<StateError>()),
throwsA(isA<PoolExhaustedException>()),
);
l1.release();
});
Expand Down Expand Up @@ -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<PoolExhaustedException>().having(
(e) => e.toString(),
'text',
allOf(contains('PostgreSQL'), contains('Close tabs'),
isNot(contains('Bad state'))))),
);
});
}
25 changes: 23 additions & 2 deletions test/core/database/sqlite_connection_pool_test.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -95,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 =>
Expand All @@ -107,7 +109,7 @@ void main() {

expect(
() => pool.acquire(_row(id: 3)),
throwsA(isA<StateError>()),
throwsA(isA<PoolExhaustedException>()),
);
});

Expand Down Expand Up @@ -293,4 +295,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<PoolExhaustedException>().having(
(e) => e.toString(),
'text',
allOf(contains('SQLite'), contains('Close tabs'),
isNot(contains('Bad state'))))),
);
});
}
Loading