Skip to content
Open
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: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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.
GraphQLAPIKit is available under the MIT license. See the [LICENSE file](LICENSE) for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,28 @@ 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:
/// - url: The GraphQL endpoint URL.
/// - 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
}
}
55 changes: 55 additions & 0 deletions Sources/GraphQLAPIKit/Configuration/GraphQLQueryRetryPolicy.swift
Original file line number Diff line number Diff line change
@@ -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<URLError.Code>

/// 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<URLError.Code>
) {
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))
}
}
114 changes: 71 additions & 43 deletions Sources/GraphQLAPIKit/GraphQLAPIAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -101,68 +102,63 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable {
networkTransport: networkTransport,
store: store
)
self.queryRetryPolicy = configuration.queryRetryPolicy
}

public func fetch<Query: GraphQLQuery>(
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
}
}

public func perform<Mutation: GraphQLMutation>(
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)
}
}

Expand Down Expand Up @@ -221,6 +217,38 @@ public final class GraphQLAPIAdapter: GraphQLAPIAdapterProtocol, Sendable {

// MARK: - Private Helpers

private func fetchOnce<Query: GraphQLQuery>(
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<Operation: GraphQLOperation>(
_ apolloStream: AsyncThrowingStream<GraphQLResponse<Operation>, Error>
Expand Down
13 changes: 8 additions & 5 deletions Tests/GraphQLAPIKitTests/GraphQLAPIAdapterIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand All @@ -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)
Expand Down
18 changes: 10 additions & 8 deletions Tests/GraphQLAPIKitTests/GraphQLNetworkObserverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading