Skip to content

Commit e326808

Browse files
committed
Shutdown the pool on hard errors, add some tests for this
1 parent 1be5882 commit e326808

File tree

7 files changed

+212
-17
lines changed

7 files changed

+212
-17
lines changed

Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ let package = Package(
1212
],
1313
dependencies: [
1414
.package(url: "https://github.com/apple/swift-collections", from: "1.0.2"),
15-
.package(url: "https://github.com/vapor/postgres-nio.git", from: "1.14.0"),
15+
.package(url: "https://github.com/vapor/postgres-nio.git", from: "1.14.2"),
1616
.package(url: "https://github.com/vapor/postgres-kit", from: "2.10.0"),
1717
],
1818
targets: [
@@ -23,4 +23,9 @@ let package = Package(
2323
.product(name: "PostgresNIO", package: "postgres-nio"),
2424
.product(name: "PostgresKit", package: "postgres-kit"),
2525
]),
26+
.testTarget(
27+
name: "PostgresConnectionPoolTests",
28+
dependencies: [
29+
.target(name: "PostgresConnectionPool"),
30+
]),
2631
])
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// Created by Thomas Rasch on 05.05.23.
3+
//
4+
5+
import PostgresNIO
6+
7+
extension PSQLError: CustomStringConvertible {
8+
9+
public var description: String {
10+
if let serverInfo = self.serverInfo,
11+
let severity = serverInfo[.severity],
12+
let message = serverInfo[.message]
13+
{
14+
return "\(severity): \(message)"
15+
}
16+
17+
return "Database error: \(self.code.description)"
18+
}
19+
20+
}

Sources/PostgresConnectionPool/PoolError.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,18 @@ public enum PoolError: Error {
2222
case unknown
2323

2424
}
25+
26+
extension PoolError: CustomStringConvertible {
27+
28+
public var description: String {
29+
switch self {
30+
case .cancelled: return "<PoolError: cancelled>"
31+
case .connectionFailed: return "<PoolError: connectionFailed>"
32+
case .poolDestroyed: return "<PoolError: poolDestroyed>"
33+
case .postgresError(let psqlError): return "<PoolError: postgresError='\(psqlError.description)'>"
34+
case .queryCancelled(query: let query, runtime: let runtime): return "<PoolError: query '\(query)' cancelled after \(runtime.rounded(toPlaces: 3))s>"
35+
case .unknown: return "<PoolError: unknown>"
36+
}
37+
}
38+
39+
}

Sources/PostgresConnectionPool/PostgresConnectionPool.swift

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public actor PostgresConnectionPool {
3333
private var inUseConnectionCounts: Deque<Int> = []
3434

3535
private var didStartWatcherTask = false
36-
private var didShutdown = false
36+
public private(set) var didShutdown = false
37+
public private(set) var shutdownError: PoolError?
3738

3839
// MARK: -
3940

@@ -158,6 +159,8 @@ public actor PostgresConnectionPool {
158159
///
159160
/// Must be done here since Swift doesn't yet allow async deinit.
160161
public func shutdown() async {
162+
guard !didShutdown else { return }
163+
161164
logger.debug("[\(poolName)] shutdown()")
162165

163166
didShutdown = true
@@ -181,6 +184,15 @@ public actor PostgresConnectionPool {
181184
try? await eventLoopGroup.shutdownGracefully()
182185
}
183186

187+
// Shutdown due to an unrecoverable error
188+
private func shutdown(withError error: PoolError) async {
189+
guard !didShutdown else { return }
190+
191+
shutdownError = error
192+
193+
await shutdown()
194+
}
195+
184196
/// Information about the pool and its open connections.
185197
func poolInfo() async -> PoolInfo {
186198
let connections = connections.compactMap { connection -> PoolInfo.ConnectionInfo? in
@@ -438,23 +450,54 @@ public actor PostgresConnectionPool {
438450
let logMessage: Logger.Message = "[\(poolName)] Connection \(poolConnection.id) failed after \(connectionRuntime.rounded(toPlaces: 2))s: \(error)"
439451

440452
// Don't just open a new connection, check first if this is a permanent error like wrong password etc.
441-
if let pslError = error as? PSQLError,
442-
let serverInfo = pslError.serverInfo,
443-
let code = serverInfo[.sqlState]
444-
{
445-
// TODO: List of hard errors
446-
switch PostgresError.Code(raw: code) {
447-
case .adminShutdown,
448-
.cannotConnectNow,
449-
.invalidAuthorizationSpecification,
450-
.invalidName,
451-
.invalidPassword:
453+
454+
// TODO: Check which of these errors is actually fatal
455+
if let psqlError = error as? PSQLError {
456+
switch psqlError.code {
457+
case .authMechanismRequiresPassword,
458+
.connectionClosed,
459+
.connectionError,
460+
.connectionQuiescing,
461+
.sslUnsupported,
462+
.failedToAddSSLHandler,
463+
.invalidCommandTag,
464+
.messageDecodingFailure,
465+
.receivedUnencryptedDataAfterSSLRequest,
466+
.saslError,
467+
.server,
468+
.unexpectedBackendMessage,
469+
.unsupportedAuthMechanism:
452470
logger.error(logMessage)
471+
await shutdown(withError: PoolError.postgresError(psqlError))
453472
return
454473

474+
case .queryCancelled,
475+
.tooManyParameters,
476+
.uncleanShutdown:
477+
break
478+
455479
default:
456480
break
457481
}
482+
483+
if let serverInfo = psqlError.serverInfo,
484+
let code = serverInfo[.sqlState]
485+
{
486+
// TODO: Check which of these errors is actually fatal
487+
switch PostgresError.Code(raw: code) {
488+
case .adminShutdown,
489+
.cannotConnectNow,
490+
.invalidAuthorizationSpecification,
491+
.invalidName,
492+
.invalidPassword:
493+
logger.error(logMessage)
494+
await shutdown(withError: PoolError.postgresError(psqlError))
495+
return
496+
497+
default:
498+
break
499+
}
500+
}
458501
}
459502

460503
logger.warning(logMessage)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//
2+
// Created by Thomas Rasch on 05.05.23.
3+
//
4+
5+
import PostgresConnectionPool
6+
import PostgresNIO
7+
import XCTest
8+
9+
final class ConnectionErrorTests: XCTestCase {
10+
11+
private var logger: Logger = {
12+
var logger = Logger(label: "ConnectionErrorTests")
13+
logger.logLevel = .info
14+
return logger
15+
}()
16+
17+
// TODO: Clean up the error checking
18+
19+
private func withConfiguration(
20+
_ configuration: PoolConfiguration,
21+
expectedErrorDescription: String)
22+
async throws
23+
{
24+
let pool = PostgresConnectionPool(configuration: configuration, logger: logger)
25+
do {
26+
try await pool.connection { connection in
27+
try await connection.query("SELECT 1", logger: logger)
28+
}
29+
await pool.shutdown()
30+
31+
XCTFail("Can't connect, so we should have an exception")
32+
}
33+
catch {
34+
XCTAssertTrue(error is PoolError)
35+
36+
let shutdownError = await pool.shutdownError
37+
XCTAssertEqual(shutdownError?.description, expectedErrorDescription)
38+
}
39+
let didShutdown = await pool.didShutdown
40+
XCTAssertTrue(didShutdown)
41+
}
42+
43+
func testConnectWrongHost() async throws {
44+
try await withConfiguration(self.poolConfiguration(host: "notworking"), expectedErrorDescription: "<PoolError: postgresError='Database error: connectionError'>")
45+
}
46+
47+
func testConnectWrongPort() async throws {
48+
try await withConfiguration(self.poolConfiguration(port: 99999), expectedErrorDescription: "<PoolError: postgresError='Database error: connectionError'>")
49+
}
50+
51+
func testConnectWrongUsername() async throws {
52+
try await withConfiguration(self.poolConfiguration(username: "notworking"), expectedErrorDescription: "<PoolError: postgresError='FATAL: password authentication failed for user \"notworking\"'>")
53+
}
54+
55+
func testConnectWrongPassword() async throws {
56+
try await withConfiguration(self.poolConfiguration(password: "notworking"), expectedErrorDescription: "<PoolError: postgresError='FATAL: password authentication failed for user \"test_username\"'>")
57+
}
58+
59+
func testConnectInvalidTLSConfig() async throws {
60+
var tlsConfiguration: TLSConfiguration = .clientDefault
61+
tlsConfiguration.maximumTLSVersion = .tlsv1 // New Postgres versions want at least TLSv1.2
62+
63+
let tls: PostgresConnection.Configuration.TLS = .require(try .init(configuration: tlsConfiguration))
64+
try await withConfiguration(self.poolConfiguration(tls: tls), expectedErrorDescription: "<PoolError: postgresError='Database error: sslUnsupported'>")
65+
}
66+
67+
// MARK: -
68+
69+
private func poolConfiguration(
70+
host: String? = nil,
71+
port: Int? = nil,
72+
username: String? = nil,
73+
password: String? = nil,
74+
database: String? = nil,
75+
tls: PostgresConnection.Configuration.TLS = .disable)
76+
-> PoolConfiguration
77+
{
78+
let postgresConfiguration = PostgresConnection.Configuration(
79+
host: host ?? env("POSTGRES_HOSTNAME") ?? "localhost",
80+
port: port ?? env("POSTGRES_PORT").flatMap(Int.init(_:)) ?? 5432,
81+
username: username ?? env("POSTGRES_USER") ?? "test_username",
82+
password: password ?? env("POSTGRES_PASSWORD") ?? "test_password",
83+
database: database ?? env("POSTGRES_DB") ?? "test_database",
84+
tls: tls)
85+
return PoolConfiguration(
86+
applicationName: "ConnectionErrorTests",
87+
postgresConfiguration: postgresConfiguration,
88+
connectTimeout: 10.0,
89+
queryTimeout: 10.0,
90+
poolSize: 5,
91+
maxIdleConnections: 1)
92+
}
93+
94+
private func env(_ name: String) -> String? {
95+
getenv(name).flatMap { String(cString: $0) }
96+
}
97+
}

docker-compose.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
version: '3.7'
2+
3+
x-shared-config: &shared_config
4+
environment:
5+
POSTGRES_HOST_AUTH_METHOD: "${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256}"
6+
POSTGRES_USER: test_username
7+
POSTGRES_DB: test_database
8+
POSTGRES_PASSWORD: test_password
9+
ports:
10+
- 5432:5432
11+
12+
services:
13+
psql-14:
14+
image: postgres:14
15+
<<: *shared_config

0 commit comments

Comments
 (0)