Description
This RFC is a work in progress. Additions and changes will be made throughout the design process. Changes will be accompanied by a comment indicating what sections have changed.
Background
The upcoming release of Swift 6 brings some significant changes to the language. The new structured concurrency model is incompatible with the internal mutable state of the existing Apollo iOS infrastructure. While @unchecked Sendable
can be used to silence most of the errors the current library faces in Swift 6, many of our data structures are only implicitly thread safe, but allows for unsafe usage in ways that would be difficult to account for and prevent if using @unchecked Sendable
.
The Apollo iOS team has planned to do a large overhaul of the networking APIs for a 2.0 release in the future. Swift 6 is pushing us to move that up on our roadmap.
Proposal
In order to properly support Swift structured concurrency and Swift 6, we believe significant breaking changes to the library need to be made. We are hoping to use this opportunity to make some of the other breaking changes to the networking layer that we have been planning and release a 2.0 version for Swift 6 compatibility. Due to the time constraints and urgency of releasing a version alongside the official stable release of Swift 6, we do not expect this 2.0 version to encompass the entire scope of changes we initially wanted to make. This will be an iterative (though significant) improvement on the existing code base. It is likely that a 3.0 version will be released in the future with additional breaking changes to provide for additional functionality that is out of scope for the Swift 6 compatible 2.0 release.
Impact - Breaking Changes
For users who are not building custom interceptors, the impact of the 2.0 migration would primarily involve adopting Swift concurrency in your calling code and updating API calls. How easy this would be is dependent on how your existing code is structured. This is the direction the language is going, and if you are upgrading to Swift 6, most of these changes will be necessary anyways.
For users who are doing advanced networking, the migration could require a bit more work. The 2.0 proposal includes significant changes to the way the RequestChain
, ApolloInterceptor
, and NormalizedCache
work. Anyone who is implementing their own custom versions of any of these are going to need to restructure their code and make their implementations thread safe.
Users who are unable to migrate will still be able to use Apollo iOS 1.0 with the @preconcurrency import
annotation. This would downgrade the compiler errors into warnings in Swift 6.
Deployment Target
Apollo iOS 2.0 would drop support for iOS 12-14 and macOS 10.x-11. The new minimum deployment targets would be:
- iOS 15.0+
- iPadOS 15.0+
- macOS 12+
- tvOS 15.0+
- visionOS 1.0+
- watchOS 8.0+
ApolloClient
APIs
Under Construction
The ApolloClient
will have new API's introduced that support Swift Concurrency. Because GraphQL requests may return results multiple times, the request methods will return an AsyncThrowingStream
.
public func fetch<Query: GraphQLQuery>(
query: Query,
cachePolicy: CachePolicy = .default,
context: (any RequestContext)? = nil
) -> AsyncThrowingStream<GraphQLResult<Query.Data>, any Error>
The watch(query:)
, subscribe(subscription:)
, and perform(mutation:)
methods will also have new versions following the same format.
The returned stream can be awaited upon to receive values from the request. The returned stream will finish when the request has been fully completed or an error is thrown. In order to prevent blocking of the current thread, awaiting on the request stream should be done on a detached Task
.
let task = Task.detached {
let request = client.fetch(query: MyQuery())
for try await response in request {
await MainActor.run {
// Run some code using the response on the MainActor.
}
}
}
RequestChain
and RequestChainInterceptor
In 1.0, RequestChain
was a protocol, with a provided implementation InterceptorRequestChain
. We have not identified any situation in which a custom implementation of RequestChain
is useful. In 2.0, RequestChain
will no longer be a protocol and the implementation of InterceptorRequestChain
will become the RequestChain
itself.
As in 1.0, you will create a RequestChainNetworkTransport
to initialize the ApolloClient
with. Each individual network request will have its own RequestChain
instantiated by the RequestChainNetworkTransport
. In order to allow the interceptors in the chain to be configured on a per-request basis, an InterceptorProvider
can be provided. While the APIs of these types may be slightly altered, the basic structure remains the same as 1.0.
ApolloInterceptor
will be renamed RequestChainInterceptor
. Currently, all steps in the request chain are performed using interceptors that provide the following method:
func interceptAsync<Operation: GraphQLOperation>(
chain: any RequestChain,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?
) -> Result<GraphQLResult<Operation.Data>, any Error>
Instead of passing the RequestChain
to the interceptors and having them call chain.proceedAsync()
, the interceptor API will be changed to:
typealias NextInterceptorFunction<Request: GraphQLRequest> = @Sendable (Request) async throws -> InterceptorResultStream<Request.Operation>
func intercept<Request: GraphQLRequest>(
request: Request,
next: NextInterceptorFunction<Request>
) async throws -> InterceptorResultStream<Request.Operation>
Interceptor's now will only receive the request initially. You can modify the request and then pass it to the next(request)
block. The next(request)
block returns a InterceptorResultStream
which you can then call map
on to transform the output response objects. This means that it is clear when you have access to the response, instead of it being an optional parameter and needing to make sure that you place your response handling interceptors after the interceptor that handles network fetches. Also, because you are working with a stream with a map function, it is more clear that this map block may be called multiple times (for multipart responses to support defer and subscriptions).
Interceptors are now traversed down the stack for modifying the request and then back up the stack with the response. This means you'll need to adjust the order of your interceptors. It's no longer just a linear list of actions.
NetworkFetchInterceptor
-> ApolloURLSession
There is no need for a NetworkFetchInterceptor
anymore. You can provide a URLSession
object or another object that conforms to the ApolloURLSession
protocol, and when the request chain gets to the last interceptor, it will handle sending a network request with the given ApolloURLSession
.
You still do need to provide the JSONResponseParsingInterceptor
somewhere in your chain to parse the network response into a parsed GraphQLResult
though.
Cache Interceptors
Cache interceptors are separate from the request interceptors. They have a different API with functions for reads and writes. And now you only need to provide one that will have its read/write functions called at appropriate times by the RequestChain
. Because we now have CacheInterceptor
, we are considering renaming ApolloInterceptor
to RequestInterceptor
for clarity.
Retrying
In 1.0, an interceptor could call chain.retry()
to kickoff a retry of a request. In 2.0, interceptors don't have a reference to their enclosing chain directly. Instead, an interceptor may throw a RequestChainRetry
error. This error will be caught by the RequestChain
and will trigger a retry of the request. The RequestChainRetry
error will never be rethrown out of the RequestChain
to ApolloClient
.
Error handling
Under Construction
ApolloErrorInterceptor
will be renamed RequestChainErrorInterceptor
. In 1.0, interceptors returned a Result
, which could be a .failure
with an error. Using async/await
in 2.0, an interceptor can throw
an error instead of returning a NextAction
.
Your InterceptorProvider
may provide RequestChainErrorInterceptor
with the function:
func handleError<Operation: GraphQLOperation>(
error: any Error,
request: HTTPRequest<Operation>,
response: HTTPResponse<Operation>?
) async throws -> RequestChain.NextAction<Operation>
If your InterceptorProvider
provides a RequestChainErrorInterceptor
, thrown errors will be passed to its handleError
function. If the error interceptor can recover from the error, it may return a NextAction
, and the request chain will continue with that action as described above. Otherwise the error interceptor may re-throw the error (or throw another error).
If the error interceptor throws an error (or no RequestChainErrorInterceptor
is provided), the request chain will terminate and the AsyncThrowingStream
for the request returned by the ApolloClient
will complete, throwing the provided error.
Normalized Cache
This section is in progress and requires more research.
The NormalizedCache
API has been too limited, and we are investigating how to allow for more customization of caching implementations. This will likely mean expanding the protocol to receive more information during loading and writing of data to allow for custom implementations to make better decisions about their behavior. We are looking for feedback on what additional functionality users would like to see enabled by the NormalizedCache
.
Design Questions
These are questions that are currently undecided about this RFC. Please comment on this issue if you have opinions or concerns.