diff --git a/Package@swift-5.10.swift b/Package@swift-5.10.swift index 5c251ce..b3d59bf 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.2.1"), + .package(url: "https://github.com/space-code/typhoon", exact: "1.4.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 382a9fb..67157c8 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.2.1"), + .package(url: "https://github.com/space-code/typhoon", exact: "1.4.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 794acbb..cd335a7 100644 --- a/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift +++ b/Sources/NetworkLayer/Classes/Core/Services/RequestProcessor/RequestProcessor.swift @@ -9,41 +9,57 @@ import Typhoon // MARK: - RequestProcessor -/// An object that handles request processing. +/// An actor responsible for executing network requests in a thread-safe manner. +/// +/// `RequestProcessor` manages the entire lifecycle of a request, including construction, +/// authentication adaptation, execution, credential refreshing, and retry logic. actor RequestProcessor { - // MARK: Properties + // MARK: - Properties - /// The network layer's configuration. + /// The network layer's configuration containing session settings and decoders. private let configuration: Configuration - /// The object that coordinates a group of related, network data transfer tasks. + + /// The underlying `URLSession` used to manage data transfer tasks. private let session: URLSession - /// The data request handler. + + /// The handler responsible for managing the state and events of a specific data task. private let dataRequestHandler: any IDataRequestHandler - /// The request builder. + + /// The component used to transform `IRequest` models into `URLRequest` objects. private let requestBuilder: IRequestBuilder - /// The retry policy service. - private let retryPolicyService: IRetryPolicyService - /// The authenticator interceptor. + + /// An optional service that handles request retries based on specific strategies. + private let retryPolicyService: IRetryPolicyService? + + /// An optional interceptor for modifying requests and handling authentication challenges. private let interceptor: IAuthenticationInterceptor? - /// The delegate. + + /// A thread-safe delegate for observing and validating request processor events. private var delegate: SafeRequestProcessorDelegate? - // MARK: Initialization + /// 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)? + + // MARK: - Initialization /// Creates a new `RequestProcessor` instance. /// /// - Parameters: - /// - configure: The network layer's configuration. + /// - configuration: The network layer's configuration. /// - requestBuilder: The request builder. /// - dataRequestHandler: The data request handler. /// - retryPolicyService: The retry policy service. + /// - delegate: A thread-safe delegate for processor events. + /// - interceptor: An authenticator interceptor. init( configuration: Configuration, requestBuilder: IRequestBuilder, dataRequestHandler: any IDataRequestHandler, - retryPolicyService: IRetryPolicyService, + retryPolicyService: IRetryPolicyService?, delegate: SafeRequestProcessorDelegate?, - interceptor: IAuthenticationInterceptor? + interceptor: IAuthenticationInterceptor?, + retryEvaluator: (@Sendable (Error) -> Bool)? ) { self.configuration = configuration self.requestBuilder = requestBuilder @@ -51,7 +67,10 @@ actor RequestProcessor { self.retryPolicyService = retryPolicyService self.delegate = delegate self.interceptor = interceptor + self.retryEvaluator = retryEvaluator + self.dataRequestHandler.urlSessionDelegate = configuration.sessionDelegate + session = URLSession( configuration: configuration.sessionConfiguration, delegate: dataRequestHandler, @@ -59,78 +78,90 @@ actor RequestProcessor { ) } - // MARK: Private + // MARK: - Private Methods - /// Performs a network request. + // swiftlint:disable function_body_length + /// Orchestrates the execution of a network request, including building, adaptation, and error handling. /// /// - Parameters: - /// - request: The network request. - /// - strategy: The retry policy strategy. - /// - delegate: A protocol that defines methods that URL session instances call on their delegates - /// to handle session-level events, like session life cycle changes. - /// - configure: A closure to configure the URLRequest. - /// - /// - Returns: The response from the network request. + /// - request: The network request model. + /// - strategy: An optional override for the retry policy strategy. + /// - delegate: A delegate to handle session-level events. + /// - configure: A closure for final modifications to the `URLRequest`. + /// - Returns: A `Response` object containing the raw `Data`. private func performRequest( _ request: some IRequest, strategy: RetryPolicyStrategy? = nil, delegate: URLSessionDelegate?, - configure: (@Sendable (inout URLRequest) throws -> Void)? + configure: (@Sendable (inout URLRequest) throws -> Void)?, + shouldRetry: (@Sendable (Error) -> Bool)? ) async throws -> Response { - try await performRequest(strategy: strategy) { [weak self] in - guard let self, var urlRequest = try requestBuilder.build(request, configure) else { - throw NetworkLayerError.badURL - } + try await performRequest( + strategy: strategy, + send: { [weak self] in + guard let self else { throw NetworkLayerError.badURL } - try await adapt(request, urlRequest: &urlRequest, session: session) + var urlRequest = try requestBuilder.build(request, configure) ?? { throw NetworkLayerError.badURL }() - try await self.delegate?.wrappedValue?.requestProcessor(self, willSendRequest: urlRequest) + try await adapt(request, urlRequest: &urlRequest, session: session) - let task = session.dataTask(with: urlRequest) + try await self.delegate?.wrappedValue?.requestProcessor(self, willSendRequest: urlRequest) - do { - let response = try await dataRequestHandler.startDataTask(task, delegate: delegate) + let task = session.dataTask(with: urlRequest) - if request.requiresAuthentication { - let isRefreshedCredential = try await refresh( - urlRequest: urlRequest, - response: response, - session: session - ) + do { + let response = try await dataRequestHandler.startDataTask(task, delegate: delegate) - if isRefreshedCredential { - throw AuthenticatorInterceptorError.missingCredential + 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 } + }, shouldRetry: { [weak self] error in + guard let self else { return false } - try await validate(response) + let globalResult = retryEvaluator?(error) ?? true - return response - } catch { - throw error + let localResult = shouldRetry?(error) ?? true + + return globalResult && localResult } - } + ) } - /// Adapts an initial request. + // swiftlint:enable function_body_length + + /// Modifies the `URLRequest` to include authentication credentials if required. /// /// - Parameters: - /// - request: The request model. - /// - urlRequest: The request that needs to be authenticated. - /// - session: The URLSession for which the request is being refreshed. + /// - request: The initial request model. + /// - urlRequest: The `URLRequest` being prepared for transport. + /// - session: The current `URLSession`. private func adapt(_ request: some IRequest, urlRequest: inout URLRequest, session: URLSession) async throws { guard request.requiresAuthentication else { return } try await interceptor?.adapt(request: &urlRequest, for: session) } - /// Refreshes credential. + /// Checks if a request requires a credential refresh and performs it if necessary. /// /// - Parameters: - /// - urlRequest: The request that needs to be authenticated. - /// - response: The metadata associated with the response to an HTTP protocol URL load request. - /// - session: The URLSession for which the request is being refreshed. - /// - /// - Returns: `true` if the request's token is refreshed, false otherwise. + /// - 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( urlRequest: URLRequest, response: Response, @@ -146,24 +177,28 @@ actor RequestProcessor { return false } - /// Performs a request with a retry policy. + /// Wraps a request operation with retry logic provided by the `retryPolicyService`. /// /// - Parameters: - /// - strategy: The strategy for retrying the request. - /// - send: The closure that sends the request. - /// - /// - Returns: The response from the network request. + /// - 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: The result of the request if successful. private func performRequest( strategy: RetryPolicyStrategy? = nil, - _ send: @Sendable () async throws -> T + send: @Sendable () async throws -> T, + shouldRetry: @Sendable @escaping (Error) -> Bool ) async throws -> T { - do { - return try await send() - } catch { - return try await retryPolicyService.retry(strategy: strategy, send) + if let retryPolicyService { + try await retryPolicyService.retry(strategy: strategy, onFailure: shouldRetry, send) + } else { + try await send() } } + /// Triggers the delegate's validation logic for the received HTTP response. + /// + /// - Parameter response: The response object to validate. private func validate(_ response: Response) throws { guard let urlResponse = response.response as? HTTPURLResponse else { return } try delegate?.wrappedValue?.requestProcessor( @@ -178,13 +213,32 @@ actor RequestProcessor { // MARK: IRequestProcessor extension RequestProcessor: IRequestProcessor { + /// Sends a network request and decodes the response into a specified type. + /// + /// - Parameters: + /// - request: The request model. + /// - strategy: Optional retry strategy override. + /// - delegate: Optional session delegate. + /// - configure: Optional closure to modify the `URLRequest`. + /// - shouldRetry: Optional closure to handle specific error filtering. + /// - Returns: A `Response` object containing the decoded model of type `M`. func send( _ request: some IRequest, strategy: RetryPolicyStrategy? = nil, delegate: URLSessionDelegate? = nil, - configure: (@Sendable (inout URLRequest) throws -> Void)? = nil + configure: (@Sendable (inout URLRequest) throws -> Void)? = nil, + shouldRetry: (@Sendable (Error) -> Bool)? = nil ) async throws -> Response { - let response = try await performRequest(request, strategy: strategy, delegate: delegate, configure: configure) - return try response.map { data in try self.configuration.jsonDecoder.decode(M.self, from: data) } + let response = try await performRequest( + request, + strategy: strategy, + delegate: delegate, + configure: configure, + shouldRetry: shouldRetry + ) + + return try response.map { data in + try self.configuration.jsonDecoder.decode(M.self, from: data) + } } } diff --git a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift index 6e9a8c2..0460b3c 100644 --- a/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift +++ b/Sources/NetworkLayer/Classes/DI/NetworkLayerAssembly.swift @@ -7,22 +7,35 @@ import Foundation import NetworkLayerInterfaces import Typhoon +/// A final class responsible for assembling the network layer components. +/// It configures the `IRequestProcessor` with necessary dependencies such as retry policies, interceptors, and encoders. public final class NetworkLayerAssembly: INetworkLayerAssembly { // MARK: Properties - /// The network layer's configuration. + /// The configuration settings for the network session and decoding. private let configure: Configuration - /// The retry policy service. + /// The specific strategy used to handle request retries. private let retryPolicyStrategy: RetryPolicyStrategy? - /// The request processor delegate. + /// A thread-safe wrapper for the request processor delegate. private var delegate: SafeRequestProcessorDelegate? - /// The authenticator interceptor. + /// An optional interceptor for handling authentication (e.g., adding tokens). private let interceptor: IAuthenticationInterceptor? - /// The json encoder. + /// The encoder used to transform objects into JSON data for request bodies. 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)? // MARK: Initialization + /// Initializes a new instance of the assembly. + /// - Parameters: + /// - configure: The network configuration. Defaults to a standard session setup. + /// - retryStrategy: The strategy to determine how retries are handled. Defaults to `.none`. + /// - delegate: An optional delegate to observe request lifecycle events. + /// - interceptor: An optional interceptor for request/response modification. + /// - jsonEncoder: The encoder used for request bodies. + /// - retryEvaluator: A global evaluator to determine if a retry should be attempted based on the error. public init( configure: Configuration = .init( sessionConfiguration: .default, @@ -30,37 +43,47 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { sessionDelegateQueue: nil, jsonDecoder: JSONDecoder() ), - retryPolicyStrategy: RetryPolicyStrategy? = nil, + retryStrategy: RetryStrategy = .none, delegate: RequestProcessorDelegate? = nil, interceptor: IAuthenticationInterceptor? = nil, - jsonEncoder: JSONEncoder = JSONEncoder() + jsonEncoder: JSONEncoder = JSONEncoder(), + retryEvaluator: (@Sendable (Error) -> Bool)? = nil ) { self.configure = configure - self.retryPolicyStrategy = retryPolicyStrategy self.delegate = SafeRequestProcessorDelegate(delegate: delegate) self.interceptor = interceptor self.jsonEncoder = jsonEncoder + self.retryEvaluator = retryEvaluator + + switch retryStrategy { + case .none: + retryPolicyStrategy = nil + case .default: + retryPolicyStrategy = .constant(retry: 5, duration: .seconds(1)) + case let .custom(strategy): + retryPolicyStrategy = strategy + } } // MARK: INetworkLayerAssembly + /// Assembles and returns a fully configured request processor. + /// - Returns: An object conforming to `IRequestProcessor` ready to handle network calls. public func assemble() -> IRequestProcessor { RequestProcessor( configuration: configure, requestBuilder: requestBuilder, dataRequestHandler: DataRequestHandler(), - retryPolicyService: RetryPolicyService(strategy: retryPolicyStrategy ?? defaultStrategy), + retryPolicyService: retryPolicyStrategy.map { RetryPolicyService(strategy: $0) }, delegate: delegate, - interceptor: interceptor + interceptor: interceptor, + retryEvaluator: retryEvaluator ) } - // MARK: Private - - private var defaultStrategy: RetryPolicyStrategy { - .constant(retry: 5, duration: .seconds(1)) - } + // MARK: - Private Computed Properties + /// Creates a request builder with the necessary encoders and formatters. private var requestBuilder: IRequestBuilder { RequestBuilder( parametersEncoder: parametersEncoder, @@ -69,14 +92,17 @@ public final class NetworkLayerAssembly: INetworkLayerAssembly { ) } + /// Provides the encoder for general request parameters. private var parametersEncoder: IRequestParametersEncoder { RequestParametersEncoder() } + /// Provides the encoder for the request body, utilizing the assembly's JSON encoder. private var requestBodyEncoder: IRequestBodyEncoder { RequestBodyEncoder(jsonEncoder: jsonEncoder) } + /// Provides the formatter for URL query parameters. private var queryFormatter: IQueryParametersFormatter { QueryParametersFormatter() } diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift index 3fbd4b9..2ca5308 100644 --- a/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/RequestBody.swift @@ -5,8 +5,18 @@ import Foundation +/// Represents the supported types of HTTP request bodies. +/// +/// `RequestBody` provides multiple ways to supply a request payload, +/// depending on how the data is structured or generated. public enum RequestBody { + /// Raw binary data used directly as the request body. case data(Data) + + /// A value conforming to `Encodable` that will be serialized + /// (typically to JSON) before being sent. case encodable(Encodable) + + /// A dictionary-based request body. case dictionary([String: Any]) } diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Models/RetryStrategy.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Models/RetryStrategy.swift new file mode 100644 index 0000000..090fd00 --- /dev/null +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Models/RetryStrategy.swift @@ -0,0 +1,19 @@ +// +// network-layer +// Copyright © 2025 Space Code. All rights reserved. +// + +import enum Typhoon.RetryPolicyStrategy + +// MARK: Type + +/// Defines the behavior for retrying failed network requests. +public enum RetryStrategy { + /// No retry attempts will be made. + case none + /// Uses the standard system-defined retry policy. + case `default` + /// Uses a specific, custom-defined retry policy. + /// - Parameter strategy: An instance of `RetryPolicyStrategy` defining the custom rules. + case custom(RetryPolicyStrategy) +} diff --git a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift index c69d2d4..bc811fb 100644 --- a/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift +++ b/Sources/NetworkLayerInterfaces/Classes/Core/Services/IRequestProcessor.swift @@ -22,7 +22,8 @@ public protocol IRequestProcessor { _ request: some IRequest, strategy: RetryPolicyStrategy?, delegate: URLSessionDelegate?, - configure: (@Sendable (inout URLRequest) throws -> Void)? + configure: (@Sendable (inout URLRequest) throws -> Void)?, + shouldRetry: (@Sendable (Error) -> Bool)? ) async throws -> Response } @@ -35,6 +36,14 @@ extension IRequestProcessor { _ request: some IRequest, strategy: RetryPolicyStrategy? ) async throws -> Response { - try await send(request, strategy: strategy, delegate: nil, configure: nil) + try await send(request, strategy: strategy, delegate: nil, configure: nil, shouldRetry: nil) + } + + /// Sends a network request with default parameters. + /// + /// - Parameters: + /// - request: The request object conforming to the `IRequest` protocol, representing the network request to be sent. + func send(_ request: some IRequest) async throws -> Response { + try await send(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 b02c60f..bc10eb1 100644 --- a/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift +++ b/Sources/NetworkLayerInterfaces/Classes/DI/INetworkLayerAssembly.swift @@ -8,34 +8,55 @@ import enum Typhoon.RetryPolicyStrategy // MARK: - INetworkLayerAssembly -/// A type that represents a network layer assembly. +/// A protocol defining the blueprint for constructing the network layer's infrastructure. +/// +/// Use implementations of this protocol to manage dependencies, configure session settings, +/// and produce a functional `IRequestProcessor`. public protocol INetworkLayerAssembly { - /// Creates a new `INetworkLayerAssembly` instance. + /// Initializes a new instance of the assembly with specific components. /// /// - Parameters: - /// - configure: The network layer's configuration. - /// - retryPolicyStrategy: The retry policy strategy. - /// - delegate: The request processor delegate. - /// - interceptor: The authenticator interceptor. - /// - jsonEncoder: The json encoder. + /// - configure: High-level configuration for the network session (decoders, session types, etc.). + /// - retryStrategy: The strategy governing if and how failed requests should be retried. + /// - delegate: An optional object to monitor or intercept request lifecycle events. + /// - interceptor: An optional component for handling authentication logic, such as token injection or refreshing. + /// - jsonEncoder: The encoder used for serializing request body parameters into JSON. + /// - retryEvaluator: A global evaluator to determine if a retry should be attempted based on the error. init( configure: Configuration, - retryPolicyStrategy: RetryPolicyStrategy?, + retryStrategy: RetryStrategy, delegate: RequestProcessorDelegate?, interceptor: IAuthenticationInterceptor?, - jsonEncoder: JSONEncoder + jsonEncoder: JSONEncoder, + retryEvaluator: (@Sendable (Error) -> Bool)? ) - /// Assembles a request processor. + /// Construct and link all internal components to create a request processor. /// - /// - Returns: A request processor. + /// This method resolves all dependencies (builders, handlers, strategies) and returns + /// a ready-to-use engine for executing network calls. + /// + /// - Returns: A fully configured instance conforming to `IRequestProcessor`. func assemble() -> IRequestProcessor } +// MARK: - Default Implementation + public extension INetworkLayerAssembly { - init( - configure: Configuration - ) { - self.init(configure: configure, retryPolicyStrategy: nil, delegate: nil, interceptor: nil, jsonEncoder: JSONEncoder()) + /// Provides a simplified initializer with default values for common components. + /// + /// This initializer uses `.none` for retry strategy, no delegate or interceptor, + /// and a standard `JSONEncoder`. + /// + /// - Parameter configure: The network layer's configuration. + init(configure: Configuration) { + self.init( + configure: configure, + retryStrategy: .none, + delegate: nil, + interceptor: nil, + jsonEncoder: JSONEncoder(), + retryEvaluator: nil + ) } } diff --git a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift index 35c142c..53cbb48 100644 --- a/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift +++ b/Tests/NetworkLayerTests/Classes/Helpers/Helpers/RequestProcessor+Mock.swift @@ -29,7 +29,8 @@ extension RequestProcessor { dataRequestHandler: DataRequestHandler(), retryPolicyService: RetryPolicyService(strategy: .constant(retry: 1, duration: .seconds(0))), delegate: SafeRequestProcessorDelegate(delegate: requestProcessorDelegate), - interceptor: interceptor + interceptor: interceptor, + retryEvaluator: { _ in true } ) } diff --git a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift index e45cf25..5c77258 100644 --- a/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift +++ b/Tests/NetworkLayerTests/Classes/Tests/UnitTests/RequestProcessorTests.swift @@ -8,6 +8,8 @@ import NetworkLayerInterfaces import Typhoon import XCTest +// MARK: - RequestProcessorTests + final class RequestProcessorTests: XCTestCase { // MARK: Properties @@ -44,7 +46,8 @@ final class RequestProcessorTests: XCTestCase { dataRequestHandler: dataRequestHandler, retryPolicyService: retryPolicyMock, delegate: SafeRequestProcessorDelegate(delegate: delegateMock), - interceptor: interceptorMock + interceptor: interceptorMock, + retryEvaluator: { _ in true } ) } @@ -58,9 +61,9 @@ final class RequestProcessorTests: XCTestCase { super.tearDown() } - // MARK: Tests + // MARK: Authentication Tests - func test_thatRequestProcessorSignsRequest_whenRequestRequiresAuthentication() async { + func test_send_appliesAuthenticationInterceptor_whenRequestRequiresAuthentication() async { // given requestBuilderMock.stubbedBuildResult = URLRequest.fake() dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: .init(), task: .fake()) @@ -78,7 +81,7 @@ final class RequestProcessorTests: XCTestCase { XCTAssertFalse(interceptorMock.invokedRefresh) } - func test_thatRequestProcessorDoesNotSignRequest_whenRequestDoesNotRequireAuthentication() async { + func test_send_skipsAuthenticationInterceptor_whenRequestDoesNotRequireAuthentication() async { // given requestBuilderMock.stubbedBuildResult = URLRequest.fake() dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: .init(), task: .fake()) @@ -96,7 +99,7 @@ final class RequestProcessorTests: XCTestCase { XCTAssertFalse(interceptorMock.invokedRefresh) } - func test_thatRequestProcessorRefreshesCredential_whenCredentialIsNotValid() async { + func test_send_refreshesCredential_whenAuthenticationIsRequiredAndCredentialIsInvalid() async { // given requestBuilderMock.stubbedBuildResult = URLRequest.fake() dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: HTTPURLResponse(), task: .fake()) @@ -115,7 +118,7 @@ final class RequestProcessorTests: XCTestCase { XCTAssertTrue(interceptorMock.invokedRefresh) } - func test_thatRequestProcessorDoesNotRefreshesCredential_whenRequestDoesNotRequireAuthentication() async { + func test_send_skipsCredentialRefresh_whenRequestDoesNotRequireAuthentication() async { // given requestBuilderMock.stubbedBuildResult = URLRequest.fake() dataRequestHandler.startDataTaskThrowError = URLError(.unknown) @@ -132,4 +135,283 @@ final class RequestProcessorTests: XCTestCase { XCTAssertFalse(interceptorMock.invokedAdapt) XCTAssertFalse(interceptorMock.invokedRefresh) } + + // MARK: Retry Policy Tests + + func test_send_retriesRequest_whenRequestFailsAndRetryPolicyIsConfigured() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertGreaterThan( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Request should have been retried multiple times" + ) + } + + func test_send_doesNotRetry_whenRetryPolicyIsNotConfigured() async { + // given + sut = RequestProcessor( + configuration: Configuration( + sessionConfiguration: .default, + sessionDelegate: nil, + sessionDelegateQueue: nil, + jsonDecoder: JSONDecoder() + ), + requestBuilder: requestBuilderMock, + dataRequestHandler: dataRequestHandler, + retryPolicyService: nil, + delegate: SafeRequestProcessorDelegate(delegate: delegateMock), + interceptor: interceptorMock, + retryEvaluator: { _ in true } + ) + + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertEqual( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Request should not have been retried without retry policy" + ) + } + + func test_send_stopsRetrying_whenGlobalRetryEvaluatorReturnsFalse() async { + // given + sut = RequestProcessor( + configuration: Configuration( + sessionConfiguration: .default, + sessionDelegate: nil, + sessionDelegateQueue: nil, + jsonDecoder: JSONDecoder() + ), + requestBuilder: requestBuilderMock, + dataRequestHandler: dataRequestHandler, + retryPolicyService: retryPolicyMock, + delegate: SafeRequestProcessorDelegate(delegate: delegateMock), + interceptor: interceptorMock, + retryEvaluator: { _ in false } + ) + + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertEqual( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Request should not be retried when global evaluator returns false" + ) + } + + func test_send_stopsRetrying_whenLocalRetryEvaluatorReturnsFalse() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send( + request, + shouldRetry: { _ in false } + ) as Response + } catch {} + + // then + XCTAssertEqual( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Request should not be retried when local evaluator returns false" + ) + } + + func test_send_retriesRequest_whenBothEvaluatorsReturnTrue() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send( + request, + shouldRetry: { _ in true } + ) as Response + } catch {} + + // then + XCTAssertGreaterThan( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Request should be retried when both evaluators return true" + ) + } + + func test_send_retriesWithCustomStrategy_whenStrategyIsProvided() async { + // given + let customRetryCount = 3 + let customStrategy = RetryPolicyStrategy.constant(retry: customRetryCount, duration: .seconds(.zero)) + + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send( + request, + strategy: customStrategy + ) as Response + } catch {} + + // then + XCTAssertGreaterThan( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Request should be retried with custom strategy" + ) + XCTAssertLessThanOrEqual( + dataRequestHandler.invokedStartDataTaskCount, + customRetryCount + 1, + "Should not exceed custom retry count plus initial attempt" + ) + } + + func test_send_throwsError_whenAllRetriesExhausted() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + var thrownError: Error? + do { + _ = try await sut.send(request) as Response + } catch { + thrownError = error + } + + // then + XCTAssertNotNil(thrownError, "Should throw error when all retries are exhausted") + XCTAssertGreaterThan( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Should have attempted retries before throwing error" + ) + } + + func test_send_invokesRequestBuilderOnce_whenRequestSucceedsOnFirstAttempt() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.stubbedStartDataTask = .init(data: Data(), response: HTTPURLResponse(), task: .fake()) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertEqual( + dataRequestHandler.invokedStartDataTaskCount, + 1, + "Should only attempt request once when successful" + ) + } + + func test_send_retainsRequestParameters_acrossRetryAttempts() async { + // given + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + dataRequestHandler.startDataTaskThrowError = URLError(.networkConnectionLost) + + let request = RequestMock() + request.stubbedRequiresAuthentication = false + + // when + do { + _ = try await sut.send(request) as Response + } catch {} + + // then + XCTAssertGreaterThan( + dataRequestHandler.invokedStartDataTaskParametersList.count, + 1, + "Should have multiple retry attempts recorded" + ) + + let firstDelegate = dataRequestHandler.invokedStartDataTaskParametersList.first?.delegate + let lastDelegate = dataRequestHandler.invokedStartDataTaskParametersList.last?.delegate + XCTAssertTrue( + (firstDelegate == nil && lastDelegate == nil) || (firstDelegate != nil && lastDelegate != nil), + "Delegate should be consistent across retries" + ) + } + + func test_send_evaluatesErrorType_beforeRetrying() async { + // given + let errorBox = Box() + requestBuilderMock.stubbedBuildResult = URLRequest.fake() + let specificError = URLError(.networkConnectionLost) + dataRequestHandler.startDataTaskThrowError = specificError + + // when + do { + _ = try await sut.send( + RequestMock(), + shouldRetry: { error in + errorBox.value = error + return false + } + ) as Response + } catch {} + + // then + XCTAssertEqual((errorBox.value as? URLError)?.code, specificError.code) + } +} + +// MARK: RequestProcessorTests.Box + +private extension RequestProcessorTests { + final class Box: @unchecked Sendable { + var value: T? + } }