Skip to content

RFC: 2.0 API Changes - Swift Concurrency #3411

Open
@AnthonyMDev

Description

@AnthonyMDev

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 adopt 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 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 and macOS 10.14. The new minimum deployment targets would be:

  • iOS 13.0+
  • iPadOS 13.0+
  • macOS 10.15+
  • tvOS 13.0+
  • visionOS 1.0+
  • watchOS 6.0+

ApolloClient APIs

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 interceptors will now return a NextAction (or throw) and the request chain will use that action to proceed onto the next interceptor.

  func intercept<Operation: GraphQLOperation>(
    request: HTTPRequest<Operation>,
    response: HTTPResponse<Operation>?
  ) async throws -> RequestChain.NextAction<Operation>

The NextAction is an enum that provides cases for determining what action the request chain should take next.

public enum NextAction<Operation: GraphQLOperation> {
    case proceed(
      request: HTTPRequest<Operation>,
      response: HTTPResponse<Operation>?
    )

    case proceedAndEmit(
      intermediaryResult: GraphQLResult<Operation.Data>,
      request: HTTPRequest<Operation>,
      response: HTTPResponse<Operation>?
    )

    case multiProceed(AsyncThrowingStream<NextAction<Operation>, any Error>)

    case exitEarlyAndEmit(
      result: GraphQLResult<Operation.Data>,
      request: HTTPRequest<Operation>
    )

    case retry(
      request: HTTPRequest<Operation>
    )
}

The RequestChain will proceed as follows given the NextAction returned:

  • .proceed:
    • The request chain will pass the request and optional response provided to the intercept(request:response:) function of the next interceptor in the chain.
  • .proceedAndEmit:
    • The value passed to the intermediaryResult will be emitted through AsyncThrowingStream for the request by the ApolloClient.
    • Then the request chain will pass the request and optional response provided to the intercept(request:response:) function of the next interceptor in the chain.
    • This is used by the CacheReadInterceptor when using the .returnCacheDataAndFetch cache policy to emit the result returned from the cache while still continuing to complete the network fetch request.
  • .multiProceed:
    • The request chain will await on the stream and proceed through the rest of the interceptors from the current point for each NextAction value provided.
    • This action allows for a request chain to branch into multiple asynchronous request chains from the current interceptor. Values emitted by each of the branched chains will be passed through to the final AsyncThrowingStream for the request returned by the ApolloClient.
    • This is used for multi-part network responses such as HTTP subscriptions and @defer responses.
  • .exitEarlyAndEmit:
    • The value passed to the result will be emitted through AsyncThrowingStream for the request by the ApolloClient, followed by the stream terminating. Subsequent interceptors in the request chain will not be called.
    • This is used by the CacheReadInterceptor when using the .returnCacheDataElseFetch cache policy to emit the result returned from the cache and prevent the request chain from proceeding to the network fetch request.
  • .retry:
    • The request chain will begin again from the first interceptors, passing in the provided request.

Error handling

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.

The NormalizedCache will become an AnyActor protocol, meaning implementations will need to be actor types in 2.0. This ensures thread safety and prevents data races if a NormalizedCache were to be used with multiple ApolloStores (which you probably shouldn't do, but is theoretically possible currently).

Design Questions

These are questions that are currently undecided about this RFC. Please comment on this issue if you have opinions or concerns.

What additional functionality would you like to see enabled by the NormalizedCache.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions