diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b4922f82..912a90c35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, and DynamoDB: long-running queries no longer fail at 30 seconds when Settings > Query timeout is set higher. The HTTP transport now uses the configured query timeout plus a 30-second grace, so the server's `max_execution_time` (or equivalent) fires before the client gives up. Setting "No limit" raises the transport ceiling to 1 hour. (#1267)
- AI Chat: DeepSeek V4 thinking content (`reasoning_content`) is now captured during streaming and passed back in subsequent turns, fixing 400 errors when using deepseek-v4-pro or deepseek-v4-flash.
- MongoDB: the connection form now shows a Username field. It was hidden for databases where authentication is optional, so connections to auth-enabled servers saved with no credentials and every query failed with "requires authentication" even though the connection looked healthy.
- SQL import dropped statements when the database executed them slower than the file was parsed, so a re-imported export could fail with errors like "relation does not exist". The parser now waits for each statement to be consumed before reading more. (#1264)
diff --git a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift
index d28597aa9..45d774f39 100644
--- a/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift
+++ b/Plugins/BigQueryDriverPlugin/BigQueryConnection.swift
@@ -284,6 +284,7 @@ internal final class BigQueryConnection: @unchecked Sendable {
private var _currentJobId: String?
private var _currentJobLocation: String?
private var _queryTimeoutSeconds: Int = 300
+ private let _queryTimeout = HttpQueryTimeoutBox()
private let location: String?
private static let logger = Logger(subsystem: "com.TablePro", category: "BigQueryConnection")
private static let baseUrl = "https://bigquery.googleapis.com/bigquery/v2"
@@ -294,6 +295,7 @@ internal final class BigQueryConnection: @unchecked Sendable {
func setQueryTimeout(_ seconds: Int) {
lock.withLock { _queryTimeoutSeconds = max(seconds, 30) }
+ _queryTimeout.set(serverTimeoutSeconds: seconds)
}
init(config: DriverConnectionConfig) {
@@ -306,8 +308,8 @@ internal final class BigQueryConnection: @unchecked Sendable {
let authProvider = try createAuthProvider()
let sessionConfig = URLSessionConfiguration.default
- sessionConfig.timeoutIntervalForRequest = 60
- sessionConfig.timeoutIntervalForResource = 300
+ sessionConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
+ sessionConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
let urlSession = URLSession(configuration: sessionConfig)
lock.withLock {
@@ -836,8 +838,10 @@ internal final class BigQueryConnection: @unchecked Sendable {
_ request: URLRequest,
session: URLSession
) async throws -> (Data, URLResponse) {
- try await withCheckedThrowingContinuation { continuation in
- let task = session.dataTask(with: request) { [weak self] data, response, error in
+ var timedRequest = request
+ timedRequest.timeoutInterval = _queryTimeout.requestTimeoutInterval
+ return try await withCheckedThrowingContinuation { continuation in
+ let task = session.dataTask(with: timedRequest) { [weak self] data, response, error in
self?.lock.withLock { self?._currentTask = nil }
if let error {
if (error as? URLError)?.code == .cancelled {
diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist
index f0137ccd7..b542d52ba 100644
--- a/Plugins/BigQueryDriverPlugin/Info.plist
+++ b/Plugins/BigQueryDriverPlugin/Info.plist
@@ -4,5 +4,7 @@
TableProPluginKitVersion
12
+ TableProMinAppVersion
+ 0.42.0
diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
index 90e48ba27..39a2e42e3 100644
--- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
+++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
@@ -145,6 +145,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private var currentTask: URLSessionDataTask?
private var _currentDatabase: String
private var _lastQueryId: String?
+ private let _queryTimeout = HttpQueryTimeoutBox()
private static let logger = Logger(subsystem: "com.TablePro", category: "ClickHousePluginDriver")
@@ -196,8 +197,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func connect() async throws {
let urlConfig = URLSessionConfiguration.default
- urlConfig.timeoutIntervalForRequest = 30
- urlConfig.timeoutIntervalForResource = 300
+ urlConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
+ urlConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
lock.lock()
if let delegate = ClickHouseTLSDelegate.make(for: config.ssl) {
@@ -732,6 +733,7 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
func applyQueryTimeout(_ seconds: Int) async throws {
+ _queryTimeout.set(serverTimeoutSeconds: seconds)
guard seconds > 0 else { return }
_ = try await execute(query: "SET max_execution_time = \(seconds)")
}
@@ -802,7 +804,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
lock.unlock()
- let request = try buildRequest(query: query, database: database, queryId: queryId)
+ var request = try buildRequest(query: query, database: database, queryId: queryId)
+ request.timeoutInterval = _queryTimeout.requestTimeoutInterval
let isSelect = Self.isSelectLikeQuery(query)
let (data, response) = try await withTaskCancellationHandler {
@@ -861,7 +864,8 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
lock.unlock()
- let request = try buildRequest(query: query, database: database, queryId: queryId, params: params)
+ var request = try buildRequest(query: query, database: database, queryId: queryId, params: params)
+ request.timeoutInterval = _queryTimeout.requestTimeoutInterval
let isSelect = Self.isSelectLikeQuery(query)
let (data, response) = try await withTaskCancellationHandler {
diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
index 67a253bfd..92cf379bb 100644
--- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
+++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
@@ -182,6 +182,13 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
lock.unlock()
}
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ lock.lock()
+ let client = httpClient
+ lock.unlock()
+ client?.setQueryTimeout(seconds)
+ }
+
// MARK: - Streaming
func streamRows(query: String) -> AsyncThrowingStream {
diff --git a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift
index b821b98dd..12db3513a 100644
--- a/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift
+++ b/Plugins/CloudflareD1DriverPlugin/D1HttpClient.swift
@@ -5,6 +5,7 @@
import Foundation
import os
+import TableProPluginKit
// MARK: - API Response Types
@@ -136,6 +137,7 @@ final class D1HttpClient: @unchecked Sendable {
private var _databaseId: String
private var session: URLSession?
private var currentTask: URLSessionDataTask?
+ private let queryTimeout = HttpQueryTimeoutBox()
var databaseId: String {
get {
@@ -156,10 +158,14 @@ final class D1HttpClient: @unchecked Sendable {
self._databaseId = databaseId
}
+ func setQueryTimeout(_ seconds: Int) {
+ queryTimeout.set(serverTimeoutSeconds: seconds)
+ }
+
func createSession() {
let config = URLSessionConfiguration.default
- config.timeoutIntervalForRequest = 30
- config.timeoutIntervalForResource = 300
+ config.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
+ config.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
lock.lock()
session = URLSession(configuration: config)
@@ -307,6 +313,7 @@ final class D1HttpClient: @unchecked Sendable {
var request = URLRequest(url: url)
request.httpMethod = method
+ request.timeoutInterval = queryTimeout.requestTimeoutInterval
request.setValue("Bearer \(apiToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body
diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist
index f0137ccd7..b542d52ba 100644
--- a/Plugins/CloudflareD1DriverPlugin/Info.plist
+++ b/Plugins/CloudflareD1DriverPlugin/Info.plist
@@ -4,5 +4,7 @@
TableProPluginKitVersion
12
+ TableProMinAppVersion
+ 0.42.0
diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift
index 6dde1b281..690994b28 100644
--- a/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift
+++ b/Plugins/DynamoDBDriverPlugin/DynamoDBConnection.swift
@@ -261,6 +261,7 @@ internal final class DynamoDBConnection: @unchecked Sendable {
private var _session: URLSession?
private var _credentials: AWSCredentials?
private var _currentTask: URLSessionDataTask?
+ private let _queryTimeout = HttpQueryTimeoutBox()
private let region: String
private let endpointUrl: String
private static let logger = Logger(subsystem: "com.TablePro", category: "DynamoDBConnection")
@@ -270,6 +271,10 @@ internal final class DynamoDBConnection: @unchecked Sendable {
lock.withLock { _session }
}
+ func setQueryTimeout(_ seconds: Int) {
+ _queryTimeout.set(serverTimeoutSeconds: seconds)
+ }
+
init(config: DriverConnectionConfig) {
self.config = config
self.region = config.additionalFields["awsRegion"] ?? "us-east-1"
@@ -298,8 +303,8 @@ internal final class DynamoDBConnection: @unchecked Sendable {
func connect() async throws {
let credentials = try resolveCredentials()
let sessionConfig = URLSessionConfiguration.default
- sessionConfig.timeoutIntervalForRequest = 30
- sessionConfig.timeoutIntervalForResource = 60
+ sessionConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
+ sessionConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
let urlSession = URLSession(configuration: sessionConfig)
lock.withLock {
@@ -442,6 +447,7 @@ internal final class DynamoDBConnection: @unchecked Sendable {
urlRequest.setValue(hostHeader, forHTTPHeaderField: "Host")
signRequest(&urlRequest, body: bodyData, credentials: credentials)
+ urlRequest.timeoutInterval = _queryTimeout.requestTimeoutInterval
let (data, response) = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<(Data, URLResponse), Error>) in
diff --git a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift
index 78c99a243..17423515a 100644
--- a/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift
+++ b/Plugins/DynamoDBDriverPlugin/DynamoDBPluginDriver.swift
@@ -197,7 +197,9 @@ internal final class DynamoDBPluginDriver: PluginDatabaseDriver, @unchecked Send
connection?.cancelCurrentRequest()
}
- func applyQueryTimeout(_ seconds: Int) async throws {}
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ connection?.setQueryTimeout(seconds)
+ }
// MARK: - Schema Operations
diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist
index f0137ccd7..b542d52ba 100644
--- a/Plugins/DynamoDBDriverPlugin/Info.plist
+++ b/Plugins/DynamoDBDriverPlugin/Info.plist
@@ -4,5 +4,7 @@
TableProPluginKitVersion
12
+ TableProMinAppVersion
+ 0.42.0
diff --git a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift
index 5e66500a9..fbfea0968 100644
--- a/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift
+++ b/Plugins/EtcdDriverPlugin/EtcdHttpClient.swift
@@ -315,6 +315,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
private var authToken: String?
private var _isAuthenticating = false
private var apiPrefix = "v3"
+ private let queryTimeout = HttpQueryTimeoutBox()
private static let logger = Logger(subsystem: "com.TablePro", category: "EtcdHttpClient")
@@ -322,6 +323,10 @@ internal final class EtcdHttpClient: @unchecked Sendable {
self.config = config
}
+ func setQueryTimeout(_ seconds: Int) {
+ queryTimeout.set(serverTimeoutSeconds: seconds)
+ }
+
// MARK: - Base URL
private var tlsEnabled: Bool {
@@ -347,8 +352,8 @@ internal final class EtcdHttpClient: @unchecked Sendable {
let tlsMode = config.additionalFields["etcdTlsMode"] ?? "Disabled"
let urlConfig = URLSessionConfiguration.default
- urlConfig.timeoutIntervalForRequest = 30
- urlConfig.timeoutIntervalForResource = 300
+ urlConfig.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
+ urlConfig.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
let delegate: URLSessionDelegate?
switch tlsMode {
@@ -718,6 +723,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
var request = URLRequest(url: url)
request.httpMethod = "POST"
+ request.timeoutInterval = queryTimeout.requestTimeoutInterval
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token {
request.setValue(token, forHTTPHeaderField: "Authorization")
diff --git a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
index f0b18fc69..2ccdf799b 100644
--- a/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
+++ b/Plugins/EtcdDriverPlugin/EtcdPluginDriver.swift
@@ -264,7 +264,9 @@ final class EtcdPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
httpClient?.cancelCurrentRequest()
}
- func applyQueryTimeout(_ seconds: Int) async throws {}
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ httpClient?.setQueryTimeout(seconds)
+ }
// MARK: - Schema Operations
diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist
index f0137ccd7..b542d52ba 100644
--- a/Plugins/EtcdDriverPlugin/Info.plist
+++ b/Plugins/EtcdDriverPlugin/Info.plist
@@ -4,5 +4,7 @@
TableProPluginKitVersion
12
+ TableProMinAppVersion
+ 0.42.0
diff --git a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift
index 24080efac..3ccefe59c 100644
--- a/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift
+++ b/Plugins/LibSQLDriverPlugin/HranaHttpClient.swift
@@ -5,6 +5,7 @@
import Foundation
import os
+import TableProPluginKit
// MARK: - Hrana Protocol Types
@@ -114,16 +115,21 @@ final class HranaHttpClient: @unchecked Sendable {
private let lock = NSLock()
private var session: URLSession?
private var currentTask: URLSessionDataTask?
+ private let queryTimeout = HttpQueryTimeoutBox()
init(baseUrl: URL, authToken: String?) {
self.baseUrl = baseUrl
self.authToken = authToken
}
+ func setQueryTimeout(_ seconds: Int) {
+ queryTimeout.set(serverTimeoutSeconds: seconds)
+ }
+
func createSession() {
let config = URLSessionConfiguration.default
- config.timeoutIntervalForRequest = 30
- config.timeoutIntervalForResource = 300
+ config.timeoutIntervalForRequest = HttpQueryTimeout.sessionBootstrapRequestTimeout
+ config.timeoutIntervalForResource = HttpQueryTimeout.sessionResourceTimeout
lock.lock()
session = URLSession(configuration: config)
@@ -211,6 +217,7 @@ final class HranaHttpClient: @unchecked Sendable {
var request = URLRequest(url: url)
request.httpMethod = "POST"
+ request.timeoutInterval = queryTimeout.requestTimeoutInterval
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = authToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist
index f0137ccd7..b542d52ba 100644
--- a/Plugins/LibSQLDriverPlugin/Info.plist
+++ b/Plugins/LibSQLDriverPlugin/Info.plist
@@ -4,5 +4,7 @@
TableProPluginKitVersion
12
+ TableProMinAppVersion
+ 0.42.0
diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
index d84e1071e..7a09d1419 100644
--- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
+++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
@@ -161,6 +161,13 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
lock.unlock()
}
+ func applyQueryTimeout(_ seconds: Int) async throws {
+ lock.lock()
+ let client = httpClient
+ lock.unlock()
+ client?.setQueryTimeout(seconds)
+ }
+
// MARK: - Streaming
func streamRows(query: String) -> AsyncThrowingStream {
diff --git a/Plugins/TableProPluginKit/HttpQueryTimeout.swift b/Plugins/TableProPluginKit/HttpQueryTimeout.swift
new file mode 100644
index 000000000..bf231c9f4
--- /dev/null
+++ b/Plugins/TableProPluginKit/HttpQueryTimeout.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+public struct HttpQueryTimeout: Sendable, Equatable {
+ public static let bootstrapSeconds: Int = 60
+ public static let defaultGraceSeconds: Int = 30
+ public static let resourceCeilingSeconds: Int = 3_600
+
+ public let serverTimeoutSeconds: Int
+ public let graceSeconds: Int
+
+ public init(
+ serverTimeoutSeconds: Int = Self.bootstrapSeconds,
+ graceSeconds: Int = Self.defaultGraceSeconds
+ ) {
+ self.serverTimeoutSeconds = serverTimeoutSeconds
+ self.graceSeconds = max(graceSeconds, 0)
+ }
+
+ public var requestTimeoutInterval: TimeInterval {
+ guard serverTimeoutSeconds > 0 else {
+ return TimeInterval(Self.resourceCeilingSeconds)
+ }
+ return TimeInterval(serverTimeoutSeconds + graceSeconds)
+ }
+
+ public static var sessionResourceTimeout: TimeInterval {
+ TimeInterval(resourceCeilingSeconds)
+ }
+
+ public static var sessionBootstrapRequestTimeout: TimeInterval {
+ TimeInterval(bootstrapSeconds)
+ }
+}
diff --git a/Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift b/Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift
new file mode 100644
index 000000000..376cc59cd
--- /dev/null
+++ b/Plugins/TableProPluginKit/HttpQueryTimeoutBox.swift
@@ -0,0 +1,28 @@
+import Foundation
+
+public final class HttpQueryTimeoutBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var stored: HttpQueryTimeout
+
+ public init(_ initial: HttpQueryTimeout = HttpQueryTimeout()) {
+ self.stored = initial
+ }
+
+ public func set(serverTimeoutSeconds seconds: Int, graceSeconds grace: Int = HttpQueryTimeout.defaultGraceSeconds) {
+ lock.lock()
+ stored = HttpQueryTimeout(serverTimeoutSeconds: seconds, graceSeconds: grace)
+ lock.unlock()
+ }
+
+ public var current: HttpQueryTimeout {
+ lock.lock()
+ defer { lock.unlock() }
+ return stored
+ }
+
+ public var requestTimeoutInterval: TimeInterval {
+ lock.lock()
+ defer { lock.unlock() }
+ return stored.requestTimeoutInterval
+ }
+}
diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift
index f7ee93088..d469f7d02 100644
--- a/TablePro/Core/Database/DatabaseManager+Health.swift
+++ b/TablePro/Core/Database/DatabaseManager+Health.swift
@@ -139,16 +139,13 @@ extension DatabaseManager {
throw error
}
- // Apply timeout (best-effort)
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
- if timeoutSeconds > 0 {
- do {
- try await driver.applyQueryTimeout(timeoutSeconds)
- } catch {
- Self.logger.warning(
- "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)"
- )
- }
+ do {
+ try await driver.applyQueryTimeout(timeoutSeconds)
+ } catch {
+ Self.logger.warning(
+ "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)"
+ )
}
await executeStartupCommands(
@@ -237,16 +234,13 @@ extension DatabaseManager {
)
try await driver.connect()
- // Apply timeout (best-effort)
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
- if timeoutSeconds > 0 {
- do {
- try await driver.applyQueryTimeout(timeoutSeconds)
- } catch {
- Self.logger.warning(
- "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)"
- )
- }
+ do {
+ try await driver.applyQueryTimeout(timeoutSeconds)
+ } catch {
+ Self.logger.warning(
+ "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)"
+ )
}
await executeStartupCommands(
diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift
index c0d526721..97d72fa24 100644
--- a/TablePro/Core/Database/DatabaseManager+Sessions.swift
+++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift
@@ -102,16 +102,12 @@ extension DatabaseManager {
try await driver.connect()
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
- if timeoutSeconds > 0 {
- do {
- try await driver.applyQueryTimeout(timeoutSeconds)
- } catch {
- // Best-effort: some PostgreSQL-compatible databases like Aurora DSQL
- // don't support SET statement_timeout.
- Self.logger.warning(
- "Query timeout not supported for \(connection.name): \(error.localizedDescription)"
- )
- }
+ do {
+ try await driver.applyQueryTimeout(timeoutSeconds)
+ } catch {
+ Self.logger.warning(
+ "Query timeout not supported for \(connection.name): \(error.localizedDescription)"
+ )
}
await executeStartupCommands(
diff --git a/TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift b/TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift
new file mode 100644
index 000000000..584e4d3d7
--- /dev/null
+++ b/TableProTests/Plugins/HttpQueryTimeoutBoxTests.swift
@@ -0,0 +1,60 @@
+//
+// HttpQueryTimeoutBoxTests.swift
+// TableProTests
+//
+
+import Foundation
+import TableProPluginKit
+import Testing
+
+@Suite("HttpQueryTimeoutBox")
+struct HttpQueryTimeoutBoxTests {
+ @Test("Default-initialized box exposes bootstrap policy")
+ func defaultBoxIsBootstrap() {
+ let box = HttpQueryTimeoutBox()
+ #expect(box.current.serverTimeoutSeconds == HttpQueryTimeout.bootstrapSeconds)
+ #expect(box.requestTimeoutInterval == TimeInterval(60 + 30))
+ }
+
+ @Test("set updates both current and requestTimeoutInterval")
+ func setUpdatesBoth() {
+ let box = HttpQueryTimeoutBox()
+ box.set(serverTimeoutSeconds: 600)
+ #expect(box.current.serverTimeoutSeconds == 600)
+ #expect(box.requestTimeoutInterval == TimeInterval(630))
+ }
+
+ @Test("set with serverTimeoutSeconds = 0 falls back to resource ceiling")
+ func setZeroUsesCeiling() {
+ let box = HttpQueryTimeoutBox()
+ box.set(serverTimeoutSeconds: 0)
+ #expect(box.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds))
+ }
+
+ @Test("set with custom grace is honored")
+ func setCustomGrace() {
+ let box = HttpQueryTimeoutBox()
+ box.set(serverTimeoutSeconds: 120, graceSeconds: 5)
+ #expect(box.requestTimeoutInterval == TimeInterval(125))
+ }
+
+ @Test("Concurrent set and read does not crash")
+ func concurrentAccess() async {
+ let box = HttpQueryTimeoutBox()
+ await withTaskGroup(of: Void.self) { group in
+ for index in 0..<32 {
+ group.addTask { box.set(serverTimeoutSeconds: 30 + index * 10) }
+ group.addTask { _ = box.requestTimeoutInterval }
+ }
+ }
+ #expect(box.requestTimeoutInterval >= TimeInterval(30))
+ }
+
+ @Test("Custom initial timeout is preserved until set is called")
+ func customInitial() {
+ let box = HttpQueryTimeoutBox(HttpQueryTimeout(serverTimeoutSeconds: 300, graceSeconds: 15))
+ #expect(box.requestTimeoutInterval == TimeInterval(315))
+ box.set(serverTimeoutSeconds: 600)
+ #expect(box.requestTimeoutInterval == TimeInterval(630))
+ }
+}
diff --git a/TableProTests/Plugins/HttpQueryTimeoutTests.swift b/TableProTests/Plugins/HttpQueryTimeoutTests.swift
new file mode 100644
index 000000000..9e82e6228
--- /dev/null
+++ b/TableProTests/Plugins/HttpQueryTimeoutTests.swift
@@ -0,0 +1,64 @@
+//
+// HttpQueryTimeoutTests.swift
+// TableProTests
+//
+
+import Foundation
+import TableProPluginKit
+import Testing
+
+@Suite("HttpQueryTimeout")
+struct HttpQueryTimeoutTests {
+ @Test("Default values match the documented bootstrap policy")
+ func defaultsMatchBootstrap() {
+ let timeout = HttpQueryTimeout()
+ #expect(timeout.serverTimeoutSeconds == HttpQueryTimeout.bootstrapSeconds)
+ #expect(timeout.graceSeconds == HttpQueryTimeout.defaultGraceSeconds)
+ #expect(timeout.requestTimeoutInterval == TimeInterval(60 + 30))
+ }
+
+ @Test("Positive server timeout adds grace to request interval")
+ func positiveServerTimeoutAddsGrace() {
+ let timeout = HttpQueryTimeout(serverTimeoutSeconds: 600, graceSeconds: 30)
+ #expect(timeout.requestTimeoutInterval == TimeInterval(630))
+ }
+
+ @Test("Custom grace is honored")
+ func customGrace() {
+ let timeout = HttpQueryTimeout(serverTimeoutSeconds: 120, graceSeconds: 5)
+ #expect(timeout.requestTimeoutInterval == TimeInterval(125))
+ }
+
+ @Test("Zero server timeout falls back to the resource ceiling")
+ func zeroServerTimeoutUsesCeiling() {
+ let timeout = HttpQueryTimeout(serverTimeoutSeconds: 0)
+ #expect(timeout.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds))
+ }
+
+ @Test("Negative server timeout is treated as unlimited")
+ func negativeServerTimeoutUsesCeiling() {
+ let timeout = HttpQueryTimeout(serverTimeoutSeconds: -1)
+ #expect(timeout.requestTimeoutInterval == TimeInterval(HttpQueryTimeout.resourceCeilingSeconds))
+ }
+
+ @Test("Negative grace is clamped to zero")
+ func negativeGraceClamped() {
+ let timeout = HttpQueryTimeout(serverTimeoutSeconds: 60, graceSeconds: -10)
+ #expect(timeout.graceSeconds == 0)
+ #expect(timeout.requestTimeoutInterval == TimeInterval(60))
+ }
+
+ @Test("Static session timeouts expose the documented ceilings")
+ func staticSessionTimeouts() {
+ #expect(HttpQueryTimeout.sessionBootstrapRequestTimeout == TimeInterval(60))
+ #expect(HttpQueryTimeout.sessionResourceTimeout == TimeInterval(3_600))
+ }
+
+ @Test("URLRequest.timeoutInterval can be assigned from the helper")
+ func appliesToUrlRequest() {
+ var request = URLRequest(url: URL(string: "https://example.com")!)
+ let timeout = HttpQueryTimeout(serverTimeoutSeconds: 300)
+ request.timeoutInterval = timeout.requestTimeoutInterval
+ #expect(request.timeoutInterval == TimeInterval(330))
+ }
+}
diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx
index cecd29c74..563461388 100644
--- a/docs/customization/settings.mdx
+++ b/docs/customization/settings.mdx
@@ -36,7 +36,7 @@ System (default), English, Tiếng Việt, Türkçe. Changing the language requi
Maximum seconds a query runs before cancellation. Default 60, range 0-600 (0 means no limit).
-Enforced at the database level: `statement_timeout` (PostgreSQL), `max_execution_time` (MySQL), `max_statement_time` (MariaDB), `sqlite3_busy_timeout` (SQLite). Applies on new connections; change requires reconnect.
+Enforced at the database level where supported: `statement_timeout` (PostgreSQL), `max_execution_time` (MySQL, ClickHouse), `max_statement_time` (MariaDB), `sqlite3_busy_timeout` (SQLite). HTTP-based drivers (ClickHouse, BigQuery, CloudflareD1, LibSQL, Etcd, DynamoDB) also bound the HTTP request timeout to the configured value plus a 30-second grace, so the server-side error fires first. Setting "No limit" raises the HTTP transport ceiling to 1 hour. Applies on new connections; change requires reconnect.
### Software Update