diff --git a/Package.swift b/Package.swift index e85aff9..427c881 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/space-code/atomic", exact: "1.1.1"), - .package(url: "https://github.com/space-code/typhoon", exact: "1.4.0"), + .package(url: "https://github.com/space-code/typhoon", exact: "3.0.0"), .package(url: "https://github.com/WeTransfer/Mocker", exact: "3.0.2"), ], targets: [ diff --git a/Package@swift-5.10.swift b/Package@swift-5.10.swift index b3d59bf..ba8c63b 100644 --- a/Package@swift-5.10.swift +++ b/Package@swift-5.10.swift @@ -18,7 +18,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/space-code/atomic", exact: "1.1.0"), - .package(url: "https://github.com/space-code/typhoon", exact: "1.4.0"), + .package(url: "https://github.com/space-code/typhoon", exact: "3.0.0"), .package(url: "https://github.com/WeTransfer/Mocker", exact: "3.0.1"), ], targets: [ diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 67157c8..5057180 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -18,7 +18,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/space-code/atomic", exact: "1.1.0"), - .package(url: "https://github.com/space-code/typhoon", exact: "1.4.0"), + .package(url: "https://github.com/space-code/typhoon", exact: "3.0.0"), .package(url: "https://github.com/WeTransfer/Mocker", exact: "3.0.1"), ], targets: [ diff --git a/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift b/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift index cd335a7..aa3137b 100644 --- a/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift +++ b/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift @@ -39,7 +39,11 @@ actor RequestProcessor { /// A global evaluator to determine if a retry should be attempted based on the error. /// This applies to all requests processed by this instance. - private let retryEvaluator: (@Sendable (Error) -> Bool)? + private let retryEvaluator: (@Sendable (Error) -> RetryAction)? + + /// A shared in-flight refresh task. All requests that need a token refresh + /// will await this single task instead of each triggering their own refresh. + private var pendingRefreshTask: Task? // MARK: - Initialization @@ -59,7 +63,7 @@ actor RequestProcessor { retryPolicyService: IRetryPolicyService?, delegate: SafeRequestProcessorDelegate?, interceptor: IAuthenticationInterceptor?, - retryEvaluator: (@Sendable (Error) -> Bool)? + retryEvaluator: (@Sendable (Error) -> RetryAction)? ) { self.configuration = configuration self.requestBuilder = requestBuilder @@ -80,7 +84,6 @@ actor RequestProcessor { // MARK: - Private Methods - // swiftlint:disable function_body_length /// Orchestrates the execution of a network request, including building, adaptation, and error handling. /// /// - Parameters: @@ -94,56 +97,20 @@ actor RequestProcessor { strategy: RetryPolicyStrategy? = nil, delegate: URLSessionDelegate?, configure: (@Sendable (inout URLRequest) throws -> Void)?, - shouldRetry: (@Sendable (Error) -> Bool)? + shouldRetry: (@Sendable (Error) -> RetryAction)? ) async throws -> Response { try await performRequest( strategy: strategy, send: { [weak self] in guard let self else { throw NetworkLayerError.badURL } - - var urlRequest = try requestBuilder.build(request, configure) ?? { throw NetworkLayerError.badURL }() - - try await adapt(request, urlRequest: &urlRequest, session: session) - - try await self.delegate?.wrappedValue?.requestProcessor(self, willSendRequest: urlRequest) - - let task = session.dataTask(with: urlRequest) - - do { - let response = try await dataRequestHandler.startDataTask(task, delegate: delegate) - - if request.requiresAuthentication { - let isRefreshedCredential = try await refresh( - urlRequest: urlRequest, - response: response, - session: session - ) - - if isRefreshedCredential { - throw AuthenticatorInterceptorError.missingCredential - } - } - - try await validate(response) - - return response - } catch { - throw error - } + return try await execute(request, delegate: delegate, configure: configure) }, shouldRetry: { [weak self] error in - guard let self else { return false } - - let globalResult = retryEvaluator?(error) ?? true - - let localResult = shouldRetry?(error) ?? true - - return globalResult && localResult + guard let self else { return .stop } + return await handleRetry(error, shouldRetry: shouldRetry) } ) } - // swiftlint:enable function_body_length - /// Modifies the `URLRequest` to include authentication credentials if required. /// /// - Parameters: @@ -155,26 +122,46 @@ actor RequestProcessor { try await interceptor?.adapt(request: &urlRequest, for: session) } - /// Checks if a request requires a credential refresh and performs it if necessary. + /// Ensures that only a single token refresh is in flight at any given time. + /// + /// When the first request detects that a refresh is needed it creates a shared + /// `Task` and stores it in `pendingRefreshTask`. Every subsequent request that + /// arrives while the refresh is still running will simply `await` that same task + /// instead of starting a new one. Once the task completes (successfully or with + /// an error) `pendingRefreshTask` is set to `nil` so that the next refresh + /// cycle can start fresh. /// /// - Parameters: /// - urlRequest: The failed or unauthorized request. /// - response: The received network response. /// - session: The current `URLSession`. - /// - Returns: `true` if a refresh was triggered, `false` otherwise. - private func refresh( + /// - Returns: `true` if a refresh was triggered or awaited, `false` otherwise. + private func refreshIfNeeded( urlRequest: URLRequest, response: Response, session: URLSession ) async throws -> Bool { - guard let interceptor, let response = response.response as? HTTPURLResponse else { return false } - - if interceptor.isRequireRefresh(urlRequest, response: response) { - try await interceptor.refresh(urlRequest, with: response, for: session) + guard + let interceptor, + let httpResponse = response.response as? HTTPURLResponse, + interceptor.isRequireRefresh(urlRequest, response: httpResponse) + else { return false } + + if let existingTask = pendingRefreshTask { + try await existingTask.value return true } - return false + let refreshTask = Task { + try await interceptor.refresh(urlRequest, with: httpResponse, for: session) + } + + pendingRefreshTask = refreshTask + + defer { pendingRefreshTask = nil } + + try await refreshTask.value + return true } /// Wraps a request operation with retry logic provided by the `retryPolicyService`. @@ -187,7 +174,7 @@ actor RequestProcessor { private func performRequest( strategy: RetryPolicyStrategy? = nil, send: @Sendable () async throws -> T, - shouldRetry: @Sendable @escaping (Error) -> Bool + shouldRetry: @Sendable @escaping (Error) async -> RetryAction ) async throws -> T { if let retryPolicyService { try await retryPolicyService.retry(strategy: strategy, onFailure: shouldRetry, send) @@ -196,6 +183,22 @@ actor RequestProcessor { } } + /// Wraps a request operation with retry logic and returns a detailed `RetryResult`. + /// + /// - Parameters: + /// - strategy: The strategy to apply for retries. + /// - send: An asynchronous closure that executes the request logic. + /// - shouldRetry: A closure to decide if a retry should occur based on the error. + /// - Returns: A `RetryResult` containing the result and retry metadata. + private func performRequestWithResult( + strategy: RetryPolicyStrategy? = nil, + send: @Sendable () async throws -> T, + shouldRetry: @Sendable @escaping (Error) async -> RetryAction + ) async throws -> RetryResult { + let service = retryPolicyService ?? RetryPolicyService(strategy: .constant(retry: 0, dispatchDuration: .seconds(0))) + return try await service.retryWithResult(strategy: strategy, onFailure: shouldRetry, send) + } + /// Triggers the delegate's validation logic for the received HTTP response. /// /// - Parameter response: The response object to validate. @@ -208,6 +211,107 @@ actor RequestProcessor { task: response.task ) } + + /// Builds and sends a URL request, applying adapters and delegate hooks, + /// with optional token refresh on authentication failure. + /// + /// - Parameters: + /// - request: The request object conforming to `IRequest`. + /// - delegate: An optional `URLSessionDelegate` for task-level events. + /// - configure: An optional closure to mutate the `URLRequest` before sending. + /// - Returns: A `Response` containing the raw response data. + /// - Throws: `NetworkLayerError.badURL` if the request URL cannot be built, + /// or any error produced by adapters, delegate hooks, or the data task. + private func execute( + _ request: some IRequest, + delegate: URLSessionDelegate?, + configure: (@Sendable (inout URLRequest) throws -> Void)? + ) async throws -> Response { + var urlRequest = try requestBuilder.build(request, configure) ?? { throw NetworkLayerError.badURL }() + + try await adapt(request, urlRequest: &urlRequest, session: session) + try await self.delegate?.wrappedValue?.requestProcessor(self, willSendRequest: urlRequest) + + var response = try await performDataTask(urlRequest: urlRequest, delegate: delegate) + + if request.requiresAuthentication { + response = try await authenticatedRetryIfNeeded( + urlRequest: urlRequest, + response: response, + delegate: delegate + ) + } + + try validate(response) + return response + } + + /// Retries the request once if the initial response requires a token refresh. + /// Throws if the response is still unauthorized after the retry. + /// + /// - Parameters: + /// - urlRequest: The original `URLRequest` to retry. + /// - response: The initial response received before any retry attempt. + /// - delegate: An optional `URLSessionDelegate` for task-level events. + /// - Returns: The original response if no retry was needed, or the retried response on success. + /// - Throws: `AuthenticatorInterceptorError.missingCredential` if the request + /// remains unauthorized after a single retry. + private func authenticatedRetryIfNeeded( + urlRequest: URLRequest, + response: Response, + delegate: URLSessionDelegate? + ) async throws -> Response { + guard try await refreshIfNeeded(urlRequest: urlRequest, response: response, session: session) else { + return response + } + + let retryResponse = try await performDataTask(urlRequest: urlRequest, delegate: delegate) + + guard try await !refreshIfNeeded(urlRequest: urlRequest, response: retryResponse, session: session) else { + throw AuthenticatorInterceptorError.missingCredential + } + + return retryResponse + } + + /// Starts a data task for the given `URLRequest` and returns the response. + /// + /// - Parameters: + /// - urlRequest: The configured `URLRequest` to execute. + /// - delegate: An optional `URLSessionDelegate` for task-level events. + /// - Returns: A `Response` containing the raw response data. + private func performDataTask( + urlRequest: URLRequest, + delegate: URLSessionDelegate? + ) async throws -> Response { + let task = session.dataTask(with: urlRequest) + return try await dataRequestHandler.startDataTask(task, delegate: delegate) + } + + /// Resolves the final retry action by combining global and local retry evaluators. + /// + /// The most restrictive action takes precedence: `.stop` > `.skipDelay` > `.retry`. + /// + /// - Parameters: + /// - error: The error that triggered the retry evaluation. + /// - shouldRetry: An optional local closure that returns a `RetryAction` for the given error. + /// - Returns: The resolved `RetryAction` based on both evaluators. + private func handleRetry( + _ error: Error, + shouldRetry: (@Sendable (Error) -> RetryAction)? + ) -> RetryAction { + let globalResult = retryEvaluator?(error) ?? .retry + let localResult = shouldRetry?(error) ?? .retry + + switch (globalResult, localResult) { + case (_, .stop), (.stop, _): + return .stop + case (_, .skipDelay), (.skipDelay, _): + return .skipDelay + case (.retry, .retry): + return .retry + } + } } // MARK: IRequestProcessor @@ -227,7 +331,7 @@ extension RequestProcessor: IRequestProcessor { strategy: RetryPolicyStrategy? = nil, delegate: URLSessionDelegate? = nil, configure: (@Sendable (inout URLRequest) throws -> Void)? = nil, - shouldRetry: (@Sendable (Error) -> Bool)? = nil + shouldRetry: (@Sendable (Error) -> RetryAction)? = nil ) async throws -> Response { let response = try await performRequest( request, @@ -241,4 +345,37 @@ extension RequestProcessor: IRequestProcessor { try self.configuration.jsonDecoder.decode(M.self, from: data) } } + + /// Sends a network request and returns the result along with retry information. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol. + /// - strategy: An optional override for the retry policy strategy. + /// - delegate: An optional `URLSessionDelegate`. + /// - configure: An optional closure to modify the `URLRequest`. + /// - shouldRetry: An optional closure to determine if a retry should be attempted. + /// - Returns: A retry result containing the response. + func sendWithResult( + _ request: some IRequest, + strategy: RetryPolicyStrategy? = nil, + delegate: URLSessionDelegate? = nil, + configure: (@Sendable (inout URLRequest) throws -> Void)? = nil, + shouldRetry: (@Sendable (Error) -> RetryAction)? = nil + ) async throws -> RetryResult> { + try await performRequestWithResult( + strategy: strategy, + send: { [weak self] in + guard let self else { throw NetworkLayerError.badURL } + + let response = try await execute(request, delegate: delegate, configure: configure) + + return try response.map { data in + try self.configuration.jsonDecoder.decode(M.self, from: data) + } + }, shouldRetry: { [weak self] error in + guard let self else { return .stop } + return await handleRetry(error, shouldRetry: shouldRetry) + } + ) + } } diff --git a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift index 0460b3c..3c21721 100644 --- a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift +++ b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift @@ -24,7 +24,7 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { private let jsonEncoder: JSONEncoder /// A global evaluator to determine if a retry should be attempted based on the error. /// This applies to all requests processed by this instance. - private let retryEvaluator: (@Sendable (Error) -> Bool)? + private let retryEvaluator: (@Sendable (Error) -> RetryAction)? // MARK: Initialization @@ -47,7 +47,7 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { delegate: RequestProcessorDelegate? = nil, interceptor: IAuthenticationInterceptor? = nil, jsonEncoder: JSONEncoder = JSONEncoder(), - retryEvaluator: (@Sendable (Error) -> Bool)? = nil + retryEvaluator: (@Sendable (Error) -> RetryAction)? = nil ) { self.configure = configure self.delegate = SafeRequestProcessorDelegate(delegate: delegate) @@ -59,7 +59,7 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { case .none: retryPolicyStrategy = nil case .default: - retryPolicyStrategy = .constant(retry: 5, duration: .seconds(1)) + retryPolicyStrategy = .constant(retry: 5, dispatchDuration: .seconds(1)) case let .custom(strategy): retryPolicyStrategy = strategy } diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift index d162d03..c032969 100644 --- a/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/Response.swift @@ -35,6 +35,6 @@ public struct Response { } } -// MARK: @unchecked Sendable +// MARK: Sendable -extension Response: @unchecked Sendable where T: Sendable {} +extension Response: Sendable where T: Sendable {} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift index bc811fb..8b3c26a 100644 --- a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift @@ -23,8 +23,25 @@ public protocol IRequestProcessor { strategy: RetryPolicyStrategy?, delegate: URLSessionDelegate?, configure: (@Sendable (inout URLRequest) throws -> Void)?, - shouldRetry: (@Sendable (Error) -> Bool)? + shouldRetry: (@Sendable (Error) -> RetryAction)? ) async throws -> Response + + /// Sends a network request and returns the result along with retry information. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol. + /// - strategy: An optional override for the retry policy strategy. + /// - delegate: An optional `URLSessionDelegate`. + /// - configure: An optional closure to modify the `URLRequest`. + /// - shouldRetry: An optional closure to determine if a retry should be attempted. + /// - Returns: A retry result containing the response. + func sendWithResult( + _ request: some IRequest, + strategy: RetryPolicyStrategy?, + delegate: URLSessionDelegate?, + configure: (@Sendable (inout URLRequest) throws -> Void)?, + shouldRetry: (@Sendable (Error) -> RetryAction)? + ) async throws -> RetryResult> } extension IRequestProcessor { @@ -46,4 +63,23 @@ extension IRequestProcessor { func send(_ request: some IRequest) async throws -> Response { try await send(request, strategy: nil, delegate: nil, configure: nil, shouldRetry: nil) } + + /// Sends a network request with result with default parameters. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol. + func sendWithResult( + _ request: some IRequest, + strategy: RetryPolicyStrategy? + ) async throws -> RetryResult> { + try await sendWithResult(request, strategy: strategy, delegate: nil, configure: nil, shouldRetry: nil) + } + + /// Sends a network request with result with default parameters. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol. + func sendWithResult(_ request: some IRequest) async throws -> RetryResult> { + try await sendWithResult(request, strategy: nil, delegate: nil, configure: nil, shouldRetry: nil) + } } diff --git a/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift b/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift index bc10eb1..ce27bf2 100644 --- a/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift +++ b/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift @@ -4,6 +4,7 @@ // import Foundation +import enum Typhoon.RetryAction import enum Typhoon.RetryPolicyStrategy // MARK: - INetworkLayerAssembly @@ -28,7 +29,7 @@ public protocol INetworkLayerAssembly { delegate: RequestProcessorDelegate?, interceptor: IAuthenticationInterceptor?, jsonEncoder: JSONEncoder, - retryEvaluator: (@Sendable (Error) -> Bool)? + retryEvaluator: (@Sendable (Error) -> RetryAction)? ) /// Construct and link all internal components to create a request processor. diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift index 53cbb48..a95ec5b 100644 --- a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift +++ b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift @@ -27,7 +27,7 @@ extension RequestProcessor { queryFormatter: QueryParametersFormatter() ), dataRequestHandler: DataRequestHandler(), - retryPolicyService: RetryPolicyService(strategy: .constant(retry: 1, duration: .seconds(0))), + retryPolicyService: RetryPolicyService(strategy: .constant(retry: 1, dispatchDuration: .seconds(0))), delegate: SafeRequestProcessorDelegate(delegate: requestProcessorDelegate), interceptor: interceptor, retryEvaluator: { _ in true } diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift index 7c7e051..e5d6941 100644 --- a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/AuthentificatorInterceptorMock.swift @@ -23,12 +23,14 @@ final class AuthentificatorInterceptorMock: IAuthenticationInterceptor, @uncheck var invokedRefreshCount = 0 var invokedRefreshParameters: (request: URLRequest, response: HTTPURLResponse, session: URLSession)? var invokedRefreshParametersList = [(request: URLRequest, response: HTTPURLResponse, session: URLSession)]() + var refreshClosure: (() -> Void)? func refresh(_ request: URLRequest, with response: HTTPURLResponse, for session: URLSession) { invokedRefresh = true invokedRefreshCount += 1 invokedRefreshParameters = (request, response, session) invokedRefreshParametersList.append((request, response, session)) + refreshClosure?() } var invokedIsRequireRefresh = false @@ -36,12 +38,13 @@ final class AuthentificatorInterceptorMock: IAuthenticationInterceptor, @uncheck var invokedIsRequireRefreshParameters: (request: URLRequest, response: HTTPURLResponse)? var invokedIsRequireRefreshParametersList = [(request: URLRequest, response: HTTPURLResponse)]() var stubbedIsRequireRefreshResult: Bool! = false + var isRequireRefreshClosure: (() -> Bool)? func isRequireRefresh(_ request: URLRequest, response: HTTPURLResponse) -> Bool { invokedIsRequireRefresh = true invokedIsRequireRefreshCount += 1 invokedIsRequireRefreshParameters = (request, response) invokedIsRequireRefreshParametersList.append((request, response)) - return stubbedIsRequireRefreshResult + return isRequireRefreshClosure?() ?? stubbedIsRequireRefreshResult } } diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift index 3b662b1..daa2468 100644 --- a/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift +++ b/Tests/NetworkLayerTests/Classes/Helpers/Mocks/DataRequestHandlerMock.swift @@ -30,6 +30,7 @@ final class DataRequestHandlerMock: NSObject, IDataRequestHandler, @unchecked Se var invokedStartDataTaskParametersList = [(task: URLSessionDataTask, delegate: URLSessionDelegate?)]() var stubbedStartDataTask: Response! var startDataTaskThrowError: Error? + var startDataTaskClosure: ((URLSessionDataTask, URLSessionDelegate?) async throws -> Response)? func startDataTask( _ task: URLSessionDataTask, @@ -39,6 +40,11 @@ final class DataRequestHandlerMock: NSObject, IDataRequestHandler, @unchecked Se invokedStartDataTaskCount += 1 invokedStartDataTaskParameters = (task, delegate) invokedStartDataTaskParametersList.append((task, delegate)) + + if let startDataTaskClosure { + return try await startDataTaskClosure(task, delegate) + } + if let error = startDataTaskThrowError { throw error } diff --git a/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift index f97cea0..df02830 100644 --- a/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift +++ b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorAuthenticationTests.swift @@ -63,7 +63,7 @@ final class RequestProcessorAuthenicationTests: XCTestCase { try await test_failAuthentication( adaptError: URLError(.unknown), refreshError: nil, - expectedError: RetryPolicyError.retryLimitExceeded + expectedUnderlyingError: URLError(.unknown) ) } @@ -71,13 +71,15 @@ final class RequestProcessorAuthenicationTests: XCTestCase { try await test_failAuthentication( adaptError: nil, refreshError: URLError(.unknown), - expectedError: RetryPolicyError.retryLimitExceeded + expectedUnderlyingError: URLError(.unknown) ) } - // MARK: Private - - private func test_failAuthentication(adaptError: Error?, refreshError: Error?, expectedError: Error) async throws { + private func test_failAuthentication( + adaptError: Error?, + refreshError: Error?, + expectedUnderlyingError: Error + ) async throws { class FailInterceptor: IAuthenticationInterceptor, @unchecked Sendable { let adaptError: Error? let refreshError: Error? @@ -105,17 +107,25 @@ final class RequestProcessorAuthenicationTests: XCTestCase { // given let interceptor = FailInterceptor(adaptError: adaptError, refreshError: refreshError) let sut = RequestProcessor.mock(interceptor: interceptor) - let request = makeRequest(.user) DynamicStubs.register(stubs: [.user], statusCode: 200) // when + var thrownError: Error? do { let _: Response = try await sut.send(request) } catch { - XCTAssertEqual(error as NSError, expectedError as NSError) + thrownError = error + } + + // then + guard case let .retryLimitExceeded(errors) = thrownError as? RetryPolicyError else { + XCTFail("Expected RetryPolicyError.retryLimitExceeded, got \(String(describing: thrownError))") + return } + XCTAssertFalse(errors.isEmpty, "Collected errors should not be empty") + XCTAssertEqual(errors.last as? NSError, expectedUnderlyingError as NSError) } private func makeRequest(_ path: String) -> IRequest { diff --git a/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift index 8bd3a90..7ad40d9 100644 --- a/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift +++ b/Tests/NetworkLayerTests/Classes/Tests/IntegrationTests/RequestProcessorRequestTests.swift @@ -30,7 +30,7 @@ final class RequestProcessorRequestTests: XCTestCase { XCTAssertNotNil(user.data.avatarUrl) } - func test_thatRequestProcessorThrowsRretryLimitExceededError_whenRequestDidFail() async { + func test_thatRequestProcessorThrowsRetryLimitExceededError_whenRequestDidFail() async { // given DynamicStubs.register(stubs: [.user], statusCode: 500) @@ -39,11 +39,19 @@ final class RequestProcessorRequestTests: XCTestCase { let request = makeRequest(.user) // when + var thrownError: Error? do { let _: Response = try await sut.send(request) } catch { - XCTAssertEqual(error as NSError, RetryPolicyError.retryLimitExceeded as NSError) + thrownError = error } + + // then + guard case let .retryLimitExceeded(errors) = thrownError as? RetryPolicyError else { + XCTFail("Expected RetryPolicyError.retryLimitExceeded, got \(String(describing: thrownError))") + return + } + XCTAssertFalse(errors.isEmpty, "Collected errors should not be empty") } func test_thatRequestProcessorConfigureARequest() async throws { diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift index 5c77258..d3d04c1 100644 --- a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift @@ -30,7 +30,7 @@ final class RequestProcessorTests: XCTestCase { retryPolicyMock = RetryPolicyService( strategy: .constant( retry: 5, - duration: .seconds(.zero) + dispatchDuration: .seconds(.zero) ) ) delegateMock = RequestProcessorDelegateMock() @@ -281,8 +281,8 @@ final class RequestProcessorTests: XCTestCase { func test_send_retriesWithCustomStrategy_whenStrategyIsProvided() async { // given - let customRetryCount = 3 - let customStrategy = RetryPolicyStrategy.constant(retry: customRetryCount, duration: .seconds(.zero)) + let customRetryCount: UInt = 3 + let customStrategy = RetryPolicyStrategy.constant(retry: customRetryCount, dispatchDuration: .seconds(.zero)) requestBuilderMock.stubbedBuildResult = URLRequest.fake() dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) @@ -305,7 +305,7 @@ final class RequestProcessorTests: XCTestCase { "Request should be retried with custom strategy" ) XCTAssertLessThanOrEqual( - dataRequestHandler.invokedStartDataTaskCount, + UInt(dataRequestHandler.invokedStartDataTaskCount), customRetryCount + 1, "Should not exceed custom retry count plus initial attempt" ) @@ -406,6 +406,213 @@ final class RequestProcessorTests: XCTestCase { // then XCTAssertEqual((errorBox.value as? URLError)?.code, specificError.code) } + + func test_send_skipsDelay_whenRetryActionIsSkipDelay() async { + // given + let strategy = RetryPolicyStrategy.constant(retry: 1, dispatchDuration: .seconds(100)) + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let startTime = Date() + + // when + do { + _ = try await sut.send(RequestMock(), strategy: strategy, shouldRetry: { _ in + .skipDelay + }) as Response + } catch {} + + // then + let duration = Date().timeIntervalSince(startTime) + XCTAssertLessThan( + duration, + 1.0, + "Request should have retried immediately despite large strategy delay" + ) + } + + func test_sendWithResult_returnsRetryResult_whenRequestSucceedsAfterRetries() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + + var attempt = 0 + dataRequestHandler.startDataTaskClosure = { _, _ in + attempt += 1 + if attempt == 1 { + throw URLError(.networkConnectionLost) + } + return .init(data: "123".data(using: .utf8)!, response: HTTPURLResponse(), task: .fake()) + } + + // when + let result = try? await sut.sendWithResult(RequestMock()) as RetryResult> + + // then + XCTAssertNotNil(result) + XCTAssertEqual(result?.attempts, 2) + XCTAssertEqual(result?.value.data, 123) + XCTAssertEqual(result?.errors.count, 1) + } + + func test_sendWithResult_returnsRetryResultWithMultipleErrors_whenRequestFailsAllRetries() async { + // given + let maxRetries: UInt = 2 + let strategy = RetryPolicyStrategy.constant(retry: maxRetries, dispatchDuration: .seconds(0)) + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + // when + do { + _ = try await sut.sendWithResult(RequestMock(), strategy: strategy) as RetryResult> + } catch {} + + var attempt = 0 + dataRequestHandler.startDataTaskClosure = { _, _ in + attempt += 1 + if attempt <= 2 { + throw URLError(.networkConnectionLost) + } + return .init(data: .data, response: HTTPURLResponse(), task: .fake()) + } + + // then + let result = try? await sut.sendWithResult(RequestMock(), strategy: strategy) as RetryResult> + + XCTAssertEqual(result?.attempts, 3) + XCTAssertEqual(result?.errors.count, 2) + } + + func test_send_doesNotThrow_whenCredentialRefreshedSuccessfullyOnSecondAttempt() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + interceptorMock.stubbedIsRequireRefreshResult = true + + var attempt = 0 + dataRequestHandler.startDataTaskClosure = { _, _ in + attempt += 1 + return .init(data: Data(), response: HTTPURLResponse(), task: .fake()) + } + + var refreshCallCount = 0 + interceptorMock.isRequireRefreshClosure = { + refreshCallCount += 1 + return refreshCallCount == 1 + } + + dataRequestHandler.startDataTaskClosure = { _, _ in + attempt += 1 + return .init(data: .data, response: HTTPURLResponse(), task: .fake()) + } + + let request = RequestMock() + request.stubbedRequiresAuthentication = true + + // when + var thrownError: Error? + do { + _ = try await sut.send(request) as Response + } catch { + thrownError = error + } + + // then + XCTAssertNil(thrownError, "Should not throw when credential is refreshed successfully") + XCTAssertEqual(attempt, 2, "Should have made exactly 2 data task attempts") + XCTAssertEqual(refreshCallCount, 2, "Should have called refresh twice") + } + + func test_send_throwsMissingCredential_whenRefreshFailsTwice() async throws { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + + let httpResponse = try XCTUnwrap(try HTTPURLResponse( + url: XCTUnwrap(URL(string: "https://example.com")), + statusCode: 401, + httpVersion: nil, + headerFields: nil + )) + + dataRequestHandler.stubbedStartDataTask = .init( + data: .data, + response: httpResponse, + task: .fake() + ) + + interceptorMock.isRequireRefreshClosure = { true } + + let request = RequestMock() + request.stubbedRequiresAuthentication = true + + // when + var thrownError: Error? + do { + _ = try await sut.send(request) as Response + } catch { + thrownError = error + } + + // then + XCTAssertNotNil(thrownError, "Should throw when refresh fails twice") + + guard case let .retryLimitExceeded(errors) = thrownError as? RetryPolicyError else { + XCTFail("Expected RetryPolicyError.retryLimitExceeded, got \(String(describing: thrownError))") + return + } + + let underlyingError = errors.last as? AuthenticatorInterceptorError + XCTAssertEqual(underlyingError, .missingCredential, "Should throw missingCredential specifically") + } + + func test_send_doesNotMakeSecondRequest_whenRefreshIsNotRequired() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: HTTPURLResponse(), task: .fake()) + + interceptorMock.stubbedIsRequireRefreshResult = false + + let request = RequestMock() + request.stubbedRequiresAuthentication = true + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertEqual( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Should not make a second request when refresh is not required" + ) + XCTAssertTrue(interceptorMock.invokedIsRequireRefresh, "Refresh check should still be evaluated") + } + + func test_send_makesExactlyTwoRequests_whenFirstRefreshIsRequired() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: HTTPURLResponse(), task: .fake()) + + var refreshCallCount = 0 + interceptorMock.isRequireRefreshClosure = { + refreshCallCount += 1 + return refreshCallCount == 1 + } + + let request = RequestMock() + request.stubbedRequiresAuthentication = true + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertEqual( + dataRequestHandler.invokedStartDataTaskCount, + 2, + "Should make exactly 2 requests: initial + one retry after refresh" + ) + } } // MARK: RequestProcessorTests.Box @@ -415,3 +622,9 @@ private extension RequestProcessorTests { var value: T? } } + +// MARK: Constants + +private extension Data { + static let data = "123".data(using: .utf8)! +}