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
15 changes: 12 additions & 3 deletions qBitControl/Classes/NetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ enum NetworkError: Error, Equatable {
case unauthorized
case invalidResponse
case httpError(statusCode: Int)
case timeout
}

/// A stateless actor responsible for executing generic asynchronous HTTP requests.
Expand All @@ -23,11 +24,19 @@ actor NetworkClient {
/// - Parameters:
/// - baseURL: The base server URL.
/// - basicAuth: Optional basic authentication credentials.
/// - session: The `URLSession` instance to use (supports Dependency Injection for testability).
init(baseURL: String, basicAuth: Server.BasicAuth?, session: URLSession = .shared) {
/// - session: Optional custom `URLSession` (defaults to 15s timeout session).
init(baseURL: String, basicAuth: Server.BasicAuth?, session: URLSession? = nil) {
self.baseURL = baseURL
self.basicAuth = basicAuth
self.session = session

if let session = session {
self.session = session
} else {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15.0
config.timeoutIntervalForResource = 15.0
self.session = URLSession(configuration: config)
}
}

/// Sends an HTTP request and decodes the response to a generic `Decodable` type.
Expand Down
26 changes: 22 additions & 4 deletions qBitControl/Classes/ServersHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,30 @@ class ServersHelper: ObservableObject {
let networkClient = NetworkClient(baseURL: server.url, basicAuth: server.basicAuth)
let newClient = qBittorrentClient(networkClient: networkClient)
do {
try await newClient.login(username: server.username, password: server.password)
let loggedInClient = try await withThrowingTaskGroup(of: qBittorrentClient.self) { group in
group.addTask {
try await newClient.login(username: server.username, password: server.password)
return newClient
}

group.addTask {
try await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds
throw NetworkError.timeout
}

let firstResult = try await group.next()
group.cancelAll()

guard let client = firstResult else {
throw NetworkError.invalidResponse
}
return client
}

self.client = newClient
// Mutating and invoking actor-isolated methods safely on the MainActor
self.client = loggedInClient
self.setActiveServer(id: server.id)

await fetchMetadata()
await self.fetchMetadata()

self.isLoggedIn = true
} catch {
Expand Down
48 changes: 48 additions & 0 deletions qBitControlTests/NetworkClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,52 @@ final class NetworkClientTests: XCTestCase {
// Then
XCTAssertEqual(result, rawVersionString)
}

func testSendRequest_Timeout() async {
// Given
MockURLProtocol.requestHandler = { request in
Thread.sleep(forTimeInterval: 0.3)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, nil)
}

let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
configuration.timeoutIntervalForRequest = 0.1
configuration.timeoutIntervalForResource = 0.1
let mockTimeoutSession = URLSession(configuration: configuration)

let client = NetworkClient(baseURL: baseURL, basicAuth: nil, session: mockTimeoutSession)

// When/Then
do {
let _: MockDecodable = try await client.sendRequest(path: "/test", queryItems: [], cookie: nil)
XCTFail("Expected request to timeout, but it succeeded.")
} catch {
XCTAssertNotNil(error)
}
}

func testTaskGroup_TimeoutRace() async {
// Given/When/Then
do {
let _: Bool = try await withThrowingTaskGroup(of: Bool.self) { group in
group.addTask {
try await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
return true
}
group.addTask {
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds (wins timeout)
throw NetworkError.timeout
}

let first = try await group.next()
group.cancelAll()
return first ?? false
}
XCTFail("Expected task group to fail with timeout error")
} catch {
XCTAssertEqual(error as? NetworkError, NetworkError.timeout)
}
}
}
Loading