From 88fa94ad75edfaa725a8b97579de11e02d51359c Mon Sep 17 00:00:00 2001 From: janpollak <40995260+janpollak@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:05:06 +0200 Subject: [PATCH 1/2] Add configurable GraphQL query retries --- README.md | 15 +- .../GraphQLAPIConfiguration.swift | 8 +- .../GraphQLQueryRetryPolicy.swift | 55 +++ Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift | 114 +++--- .../GraphQLQueryRetryTests.swift | 326 ++++++++++++++++++ 5 files changed, 473 insertions(+), 45 deletions(-) create mode 100644 Sources/GraphQLAPIKit/Configuration/GraphQLQueryRetryPolicy.swift create mode 100644 Tests/GraphQLAPIKitTests/GraphQLQueryRetryTests.swift diff --git a/README.md b/README.md index 373f050..990776c 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,19 @@ let queryResult = try await apiAdapter.fetch(query: query) let mutationResult = try await apiAdapter.perform(mutation: mutation) ``` +### Query retries + +Single-response queries can retry selected URL errors on the same underlying `URLSession`: + +```swift +let configuration = GraphQLAPIConfiguration( + url: URL(string: "https://api.example.com/graphql")!, + queryRetryPolicy: .transientNetworkFailures +) +``` + +The predefined policy retries `networkConnectionLost` and `timedOut` once. Retries are disabled by default and never apply to mutations, subscriptions, or incremental responses. + ### Subscriptions ```swift let subscriptionStream = try await apiAdapter.subscribe(subscription: MySubscription()) @@ -152,4 +165,4 @@ for try await data in deferredStream { ## License -GraphQLAPIKit is available under the MIT license. See the [LICENSE file](LICENSE) for more information. \ No newline at end of file +GraphQLAPIKit is available under the MIT license. See the [LICENSE file](LICENSE) for more information. diff --git a/Sources/GraphQLAPIKit/Configuration/GraphQLAPIConfiguration.swift b/Sources/GraphQLAPIKit/Configuration/GraphQLAPIConfiguration.swift index 48b64aa..18a1a76 100644 --- a/Sources/GraphQLAPIKit/Configuration/GraphQLAPIConfiguration.swift +++ b/Sources/GraphQLAPIKit/Configuration/GraphQLAPIConfiguration.swift @@ -17,6 +17,9 @@ public struct GraphQLAPIConfiguration: Sendable { /// Network observers for monitoring requests (logging, analytics, etc.). public let networkObservers: [any GraphQLNetworkObserver] + /// Retry policy for single-response GraphQL queries. Defaults to no retries. + public let queryRetryPolicy: GraphQLQueryRetryPolicy + /// Creates a new GraphQL API configuration. /// /// - Parameters: @@ -24,15 +27,18 @@ public struct GraphQLAPIConfiguration: Sendable { /// - urlSessionConfiguration: URL session configuration. Defaults to `.default`. /// - defaultHeaders: Headers to include in every request. Defaults to empty. /// - networkObservers: Network observers for monitoring requests. Defaults to empty. + /// - queryRetryPolicy: Retry policy for single-response queries. Defaults to no retries. public init( url: URL, urlSessionConfiguration: URLSessionConfiguration = .default, defaultHeaders: [String: String] = [:], - networkObservers: [any GraphQLNetworkObserver] = [] + networkObservers: [any GraphQLNetworkObserver] = [], + queryRetryPolicy: GraphQLQueryRetryPolicy = .none ) { self.url = url self.urlSessionConfiguration = urlSessionConfiguration self.defaultHeaders = defaultHeaders self.networkObservers = networkObservers + self.queryRetryPolicy = queryRetryPolicy } } diff --git a/Sources/GraphQLAPIKit/Configuration/GraphQLQueryRetryPolicy.swift b/Sources/GraphQLAPIKit/Configuration/GraphQLQueryRetryPolicy.swift new file mode 100644 index 0000000..583082b --- /dev/null +++ b/Sources/GraphQLAPIKit/Configuration/GraphQLQueryRetryPolicy.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Defines automatic retry behavior for single-response GraphQL queries. +public struct GraphQLQueryRetryPolicy: Sendable { + /// Disables automatic retries. + public static let none = GraphQLQueryRetryPolicy( + maxRetryCount: 0, + retryableURLErrorCodes: [] + ) + + /// Immediately retries connection loss and timeout errors once. + public static let transientNetworkFailures = GraphQLQueryRetryPolicy( + maxRetryCount: 1, + retryableURLErrorCodes: [ + .networkConnectionLost, + .timedOut + ] + ) + + /// Maximum number of retries after the initial request. + public let maxRetryCount: UInt + + /// URL error codes that can trigger a retry. + public let retryableURLErrorCodes: Set + + /// Creates a query retry policy. + /// + /// - Parameters: + /// - maxRetryCount: Maximum number of retries after the initial request. + /// - retryableURLErrorCodes: URL error codes that can trigger a retry. + public init( + maxRetryCount: UInt, + retryableURLErrorCodes: Set + ) { + self.maxRetryCount = maxRetryCount + self.retryableURLErrorCodes = retryableURLErrorCodes + } + + func shouldRetry(error: Error) -> Bool { + if let adapterError = error as? GraphQLAPIAdapterError { + switch adapterError { + case let .network(_, underlyingError), + let .connection(underlyingError), + let .unhandled(underlyingError): + return shouldRetry(error: underlyingError) + case .cancelled, .graphQl: + return false + } + } + + let error = error as NSError + return error.domain == NSURLErrorDomain && + retryableURLErrorCodes.contains(URLError.Code(rawValue: error.code)) + } +} diff --git a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift index bfecea3..d084bff 100644 --- a/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift +++ b/Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift @@ -77,6 +77,7 @@ public protocol GraphQLAPIAdapterProtocol: AnyObject, Sendable { public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { private let apollo: ApolloClient + private let queryRetryPolicy: GraphQLQueryRetryPolicy /// Creates a new GraphQL API adapter with the given configuration. /// @@ -101,37 +102,28 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { networkTransport: networkTransport, store: store ) + self.queryRetryPolicy = configuration.queryRetryPolicy } public func fetch( query: Query, configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() ) async throws -> Query.Data where Query.ResponseFormat == SingleResponseFormat { - try await RequestHeadersContext.$headers.withValue(configuration.headers) { - let config = RequestConfiguration(writeResultsToCache: false) - - let response = try await apollo.fetch( - query: query, - cachePolicy: .networkOnly, - requestConfiguration: config - ) - - if let errors = response.errors, !errors.isEmpty { - throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) - } - - guard let data = response.data else { - assertionFailure("No data received") - throw GraphQLAPIAdapterError.unhandled( - NSError( - domain: "GraphQLAPIKit", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No data received"] - ) - ) + var retryCount: UInt = 0 + + while true { + do { + return try await fetchOnce(query: query, configuration: configuration) + } catch { + guard retryCount < queryRetryPolicy.maxRetryCount, + queryRetryPolicy.shouldRetry(error: error) else { + throw GraphQLAPIAdapterError(error: error) + } + guard !Task.isCancelled else { + throw GraphQLAPIAdapterError.cancelled + } + retryCount += 1 } - - return data } } @@ -139,30 +131,34 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { mutation: Mutation, configuration: GraphQLRequestConfiguration = GraphQLRequestConfiguration() ) async throws -> Mutation.Data where Mutation.ResponseFormat == SingleResponseFormat { - try await RequestHeadersContext.$headers.withValue(configuration.headers) { - let config = RequestConfiguration(writeResultsToCache: false) + do { + return try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) - let response = try await apollo.perform( - mutation: mutation, - requestConfiguration: config - ) + let response = try await apollo.perform( + mutation: mutation, + requestConfiguration: config + ) - if let errors = response.errors, !errors.isEmpty { - throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) - } + if let errors = response.errors, !errors.isEmpty { + throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + } - guard let data = response.data else { - assertionFailure("No data received") - throw GraphQLAPIAdapterError.unhandled( - NSError( - domain: "GraphQLAPIKit", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No data received"] + guard let data = response.data else { + assertionFailure("No data received") + throw GraphQLAPIAdapterError.unhandled( + NSError( + domain: "GraphQLAPIKit", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No data received"] + ) ) - ) - } + } - return data + return data + } + } catch { + throw GraphQLAPIAdapterError(error: error) } } @@ -221,6 +217,38 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable { // MARK: - Private Helpers + private func fetchOnce( + query: Query, + configuration: GraphQLRequestConfiguration + ) async throws -> Query.Data where Query.ResponseFormat == SingleResponseFormat { + try await RequestHeadersContext.$headers.withValue(configuration.headers) { + let config = RequestConfiguration(writeResultsToCache: false) + + let response = try await apollo.fetch( + query: query, + cachePolicy: .networkOnly, + requestConfiguration: config + ) + + if let errors = response.errors, !errors.isEmpty { + throw GraphQLAPIAdapterError(error: ApolloError(errors: errors)) + } + + guard let data = response.data else { + assertionFailure("No data received") + throw GraphQLAPIAdapterError.unhandled( + NSError( + domain: "GraphQLAPIKit", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No data received"] + ) + ) + } + + return data + } + } + /// Transforms an Apollo response stream into a data stream with error mapping. private func transformStream( _ apolloStream: AsyncThrowingStream, Error> diff --git a/Tests/GraphQLAPIKitTests/GraphQLQueryRetryTests.swift b/Tests/GraphQLAPIKitTests/GraphQLQueryRetryTests.swift new file mode 100644 index 0000000..4267e77 --- /dev/null +++ b/Tests/GraphQLAPIKitTests/GraphQLQueryRetryTests.swift @@ -0,0 +1,326 @@ +@_spi(Execution) +@_spi(Unsafe) +import ApolloAPI +import Foundation +@testable import GraphQLAPIKit +import XCTest + +final class GraphQLQueryRetryTests: XCTestCase { + private var testURL: URL { + guard let url = URL(string: "https://proof.example/graphql") else { + preconditionFailure("Invalid test URL") + } + return url + } + + func testRetriesNetworkConnectionLost() async throws { + StubURLProtocol.reset(responses: [ + .failure(.networkConnectionLost), + .success + ]) + + let data = try await makeAdapter(policy: .transientNetworkFailures) + .fetch(query: TestQuery()) + + XCTAssertEqual(data.value, "ok") + XCTAssertEqual(StubURLProtocol.requestCount, 2) + } + + func testRetriesTimedOut() async throws { + StubURLProtocol.reset(responses: [ + .failure(.timedOut), + .success + ]) + + let data = try await makeAdapter(policy: .transientNetworkFailures) + .fetch(query: TestQuery()) + + XCTAssertEqual(data.value, "ok") + XCTAssertEqual(StubURLProtocol.requestCount, 2) + } + + func testDoesNotRetryByDefault() async { + StubURLProtocol.reset(responses: [ + .failure(.networkConnectionLost), + .success + ]) + + do { + _ = try await makeAdapter().fetch(query: TestQuery()) + XCTFail("Expected the query to fail") + } catch { + assertAdapterError(error, wraps: .networkConnectionLost) + } + + XCTAssertEqual(StubURLProtocol.requestCount, 1) + } + + func testDoesNotRetryOtherURLErrors() async { + StubURLProtocol.reset(responses: [ + .failure(.notConnectedToInternet), + .success + ]) + + do { + _ = try await makeAdapter(policy: .transientNetworkFailures) + .fetch(query: TestQuery()) + XCTFail("Expected the query to fail") + } catch { + assertAdapterError(error, wraps: .notConnectedToInternet) + } + + XCTAssertEqual(StubURLProtocol.requestCount, 1) + } + + func testStopsAfterConfiguredRetryCount() async { + StubURLProtocol.reset(responses: [ + .failure(.timedOut), + .failure(.timedOut), + .failure(.timedOut), + .success + ]) + let policy = GraphQLQueryRetryPolicy( + maxRetryCount: 2, + retryableURLErrorCodes: [.timedOut] + ) + + do { + _ = try await makeAdapter(policy: policy).fetch(query: TestQuery()) + XCTFail("Expected the query to fail") + } catch { + assertAdapterError(error, wraps: .timedOut) + } + + XCTAssertEqual(StubURLProtocol.requestCount, 3) + } + + func testDoesNotRetryMutations() async { + StubURLProtocol.reset(responses: [ + .failure(.networkConnectionLost), + .success + ]) + + do { + _ = try await makeAdapter(policy: .transientNetworkFailures) + .perform(mutation: TestMutation()) + XCTFail("Expected the mutation to fail") + } catch { + assertAdapterError(error, wraps: .networkConnectionLost) + } + + XCTAssertEqual(StubURLProtocol.requestCount, 1) + } + + private func makeAdapter( + policy: GraphQLQueryRetryPolicy = .none + ) -> GraphQLAPIAdapter { + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.protocolClasses = [StubURLProtocol.self] + return GraphQLAPIAdapter( + configuration: GraphQLAPIConfiguration( + url: testURL, + urlSessionConfiguration: sessionConfiguration, + queryRetryPolicy: policy + ) + ) + } + + private func assertAdapterError( + _ error: Error, + wraps expectedCode: URLError.Code + ) { + guard let adapterError = error as? GraphQLAPIAdapterError else { + XCTFail("Expected GraphQLAPIAdapterError, received \(error)") + return + } + + let underlyingError: Error + switch adapterError { + case let .network(_, error), + let .connection(error), + let .unhandled(error): + underlyingError = error + case .cancelled, .graphQl: + XCTFail("Expected an error with an underlying URL error") + return + } + + let urlError = underlyingError as NSError + XCTAssertEqual(urlError.domain, NSURLErrorDomain) + XCTAssertEqual(urlError.code, expectedCode.rawValue) + } +} + +private enum StubResponse { + case failure(URLError.Code) + case success +} + +private final class StubURLProtocol: URLProtocol { + private static let lock = NSLock() + private static var responses: [StubResponse] = [] + private static var storedRequestCount = 0 + + static var requestCount: Int { + lock.lock() + defer { lock.unlock() } + return storedRequestCount + } + + static func reset(responses: [StubResponse]) { + lock.lock() + defer { lock.unlock() } + self.responses = responses + storedRequestCount = 0 + } + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + switch Self.nextResponse { + case let .failure(code): + client?.urlProtocol(self, didFailWithError: URLError(code)) + case .success: + sendSuccess() + } + } + + override func stopLoading() { + } + + private func sendSuccess() { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/2", + headerFields: ["Content-Type": "application/json"] + ) else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotParseResponse)) + return + } + let data = Data(#"{"data":{"value":"ok"}}"#.utf8) + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + + private static var nextResponse: StubResponse { + lock.lock() + defer { lock.unlock() } + storedRequestCount += 1 + + guard !responses.isEmpty else { + return .success + } + return responses.removeFirst() + } +} + +// swiftlint:disable identifier_name +private struct TestQuery: GraphQLQuery { + static let operationName = "TestQuery" + static let operationDocument: OperationDocument = .init( + definition: .init(#"query TestQuery { value }"#) + ) + + struct Data: TestSelectionSet { + let __data: DataDict + + init(_dataDict: DataDict) { + __data = _dataDict + } + + static var __parentType: any ParentType { + TestObjects.Query + } + + static var __selections: [Selection] { + [.field("value", String.self)] + } + + static var __fulfilledFragments: [any SelectionSet.Type] { + [Data.self] + } + + var value: String { + __data["value"] + } + } +} + +private struct TestMutation: GraphQLMutation { + static let operationName = "TestMutation" + static let operationDocument: OperationDocument = .init( + definition: .init(#"mutation TestMutation { value }"#) + ) + + struct Data: TestSelectionSet { + let __data: DataDict + + init(_dataDict: DataDict) { + __data = _dataDict + } + + static var __parentType: any ParentType { + TestObjects.Mutation + } + + static var __selections: [Selection] { + [.field("value", String.self)] + } + + static var __fulfilledFragments: [any SelectionSet.Type] { + [Data.self] + } + + var value: String { + __data["value"] + } + } +} +// swiftlint:enable identifier_name + +private protocol TestSelectionSet: SelectionSet & RootSelectionSet where Schema == TestSchemaMetadata {} + +private enum TestSchemaMetadata: SchemaMetadata { + static let configuration: any SchemaConfiguration.Type = TestSchemaConfiguration.self + + static func objectType(forTypename typename: String) -> Object? { + switch typename { + case "Mutation": + return TestObjects.Mutation + case "Query": + return TestObjects.Query + default: + return nil + } + } +} + +private enum TestSchemaConfiguration: SchemaConfiguration { + static func cacheKeyInfo(for type: Object, object: ObjectData) -> CacheKeyInfo? { + nil + } +} + +private enum TestObjects { + static let Mutation = Object( + typename: "Mutation", + implementedInterfaces: [], + keyFields: nil + ) + + static let Query = Object( + typename: "Query", + implementedInterfaces: [], + keyFields: nil + ) +} From cdd9dc53970362be0ec619b637b0ce57807627fe Mon Sep 17 00:00:00 2001 From: janpollak <40995260+janpollak@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:05:17 +0200 Subject: [PATCH 2/2] Fix force unwraps in network tests --- .../GraphQLAPIAdapterIntegrationTests.swift | 13 ++++++++----- .../GraphQLNetworkObserverTests.swift | 18 ++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift index 02fa8c5..eae274d 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift @@ -37,7 +37,12 @@ final class IntegrationMockObserver: GraphQLNetworkObserver, @unchecked Sendable // MARK: - Integration Tests final class GraphQLAPIAdapterIntegrationTests: XCTestCase { - let testURL = URL(string: "https://api.example.com/graphql")! + private var testURL: URL { + guard let url = URL(string: "https://api.example.com/graphql") else { + preconditionFailure("Invalid test URL") + } + return url + } // MARK: - Initialization Tests @@ -105,9 +110,8 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { func testObserverCallbackSequence() { let observer = IntegrationMockObserver() - let url = URL(string: "https://api.example.com/graphql")! - var request = URLRequest(url: url) + var request = URLRequest(url: testURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -121,8 +125,7 @@ final class GraphQLAPIAdapterIntegrationTests: XCTestCase { func testObserverErrorCallback() { let observer = IntegrationMockObserver() - let url = URL(string: "https://api.example.com/graphql")! - let request = URLRequest(url: url) + let request = URLRequest(url: testURL) let context = observer.willSendRequest(request) let error = NSError(domain: "TestDomain", code: 500, userInfo: nil) diff --git a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift index b7f3e75..379ad5c 100644 --- a/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift +++ b/Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift @@ -2,6 +2,12 @@ import XCTest final class GraphQLNetworkObserverTests: XCTestCase { + private var testURL: URL { + guard let url = URL(string: "https://api.example.com/graphql") else { + preconditionFailure("Invalid test URL") + } + return url + } // MARK: - MockObserver @@ -49,10 +55,8 @@ final class GraphQLNetworkObserverTests: XCTestCase { func testProtocolMethodSignatures() { let observer = MockObserver() - // swiftlint:disable:next force_unwrapping - let url = URL(string: "https://api.example.com/graphql")! - var request = URLRequest(url: url) + var request = URLRequest(url: testURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") @@ -61,7 +65,7 @@ final class GraphQLNetworkObserverTests: XCTestCase { let context = observer.willSendRequest(request) XCTAssertTrue(observer.willSendRequestCalled) XCTAssertNotNil(context.requestId) - XCTAssertEqual(observer.lastRequest?.url, url) + XCTAssertEqual(observer.lastRequest?.url, testURL) XCTAssertEqual(observer.lastRequest?.httpMethod, "POST") XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Content-Type"), "application/json") XCTAssertEqual(observer.lastRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer test-token") @@ -77,8 +81,7 @@ final class GraphQLNetworkObserverTests: XCTestCase { func testObserverContextContainsTimingInfo() { let observer = MockObserver() - let url = URL(string: "https://api.example.com/graphql")! - let request = URLRequest(url: url) + let request = URLRequest(url: testURL) let beforeTime = Date() let context = observer.willSendRequest(request) @@ -91,8 +94,7 @@ final class GraphQLNetworkObserverTests: XCTestCase { func testObserverContextRequestIdIsUnique() { let observer = MockObserver() - let url = URL(string: "https://api.example.com/graphql")! - let request = URLRequest(url: url) + let request = URLRequest(url: testURL) let context1 = observer.willSendRequest(request) let context2 = observer.willSendRequest(request)