Request chain customization

Learn how to customize the Apollo iOS request chain via custom interceptors


In Apollo iOS, the ApolloClient uses a NetworkTransport object to fetch GraphQL queries from a remote GraphQL server.

The default NetworkTransport is the RequestChainNetworkTransport. This network transport uses a structure called a request chain to process each operation through a series of discrete interceptor types.

Request chains

A RequestChain manages the flow of a GraphQL request through a series of interceptors that handle different aspects of request execution. The request chain separates interceptor responsibilities into discrete types and implements a bi-directional flow where requests are sent "down" the chain and responses are sent back "up" through the chain.

For each individual request, the RequestChainNetworkTransport creates a RequestChain with interceptors provided by an InterceptorProvider. Each RequestChain is scoped to a single request and handles its execution.

Request chain flow

When an operation is executed, a RequestChain processes the request through the following steps:

  • GraphQLInterceptors receive and may mutate the GraphQLRequest

  • Cache read executed via CacheInterceptor if necessary (based on cache policy)

  • GraphQLRequest.toURLRequest() called to obtain URLRequest

  • HTTPInterceptors receive and may mutate URLRequest

  • ApolloURLSession handles networking with URLRequest

  • HTTPInterceptors receive stream of HTTPResponse objects for each chunk & may mutate raw chunk Data stream

  • ResponseParsingInterceptor receives HTTPResponse and parses data chunks into stream of GraphQLResponses

  • GraphQLInterceptors receive and may mutate ParsedResult with parsed GraphQLResponse

  • Cache write executed via CacheInterceptor if necessary (based on cache policy)

  • GraphQLResponse stream emitted out to NetworkTransport

Response streams

Unlike Apollo iOS 1.x, the 2.0 request chain processes results through streams to support multi-part responses such as subscriptions and operations using @defer. For single-response operations, these streams emit one value and then terminate.

Interceptor types

There are four discrete interceptor types used by the RequestChain

GraphQLInterceptor

GraphQLInterceptor handles pre-flight and post-flight work on the GraphQLRequest and GraphQLResponse.

  • Pre-flight: Inspect and mutate the GraphQLRequest before processing

  • Post-flight: Inspect and mutate the GraphQLResponse and parsed results

  • Error handling: Use .mapErrors() to handle errors from subsequent steps

HTTPInterceptor

HTTPInterceptor handles pre-flight and post-flight work on HTTP requests and responses.

  • Pre-flight: Inspect and mutate the URLRequest before network fetch

  • Post-flight: Inspect the HTTPResponse and mutate raw response data chunks

CacheInterceptor

CacheInterceptor handles cache read and write operations.

These can be used to implement custom cache manipulation beyond what is available using type/field policies.

  • Cache reads: Called before network fetch (based on cache policy)

  • Cache writes: Called after response parsing (based on cache policy)

ResponseParsingInterceptor

ResponseParsingInterceptor handles parsing response data chunks into GraphQLResponse objects.

Providing custom interceptors to a RequestChain

When constructing a request chain for a GraphQL operation, RequestChainNetworkTransport passes operations to an object conforming to the InterceptorProvider protocol. To provide custom interceptors to the RequestChain, create a custom implementation of the InterceptorProvider protocol and initialize your RequestChainNetworkTransport with it.

Default implementations of each of the InterceptorProvider methods are included. These implementations provide common-sense defaults. It is recommended that you include the default GraphQL and HTTP interceptors provided by DefaultInterceptorProvider.shared in your custom interceptor provider as well.

Example

A custom interceptor that adds an AuthenticationInterceptor to the GraphQL interceptors could be implemented like this:

Swift
1struct CustomInterceptorProvider: InterceptorProvider {
2  func graphQLInterceptors<Operation: GraphQLOperation>(for operation: Operation) -> [any GraphQLInterceptor] {
3    return DefaultInterceptorProvider.shared.graphQLInterceptors(for: operation) + [
4      CustomAuthenticationInterceptor(),
5    ]
6  }
7}

This interceptor provider will use the default graphQLInterceptors and add a CustomAuthenticationInterceptor to the end of the list. It does not override the other interceptor provider methods, so it will use the default HTTP, caching and response parsing interceptors.

Default interceptors

The default interceptors provided by Apollo iOS are:

GraphQL Interceptors:

Cache Interceptor:

HTTP Interceptors:

Response Parser:

Implementing custom interceptors

To add custom functionality to your request chain, you can implement custom interceptors for any of the four interceptor types. Each interceptor type has a specific protocol to implement and handles different aspects of request processing.

GraphQLInterceptor

GraphQLInterceptor requires you to implement the intercept(request: next:) function to perform pre-flight work on the GraphQLRequest and post-flight work on the parsed response data.

The intercept function works as follows:

  • Pre-flight

    • You may execute any custom pre-flight logic.

  • Call next(request)

    • You must call the provided next closure, passing in either the original request or a modified version.

    • This request will be sent to the next interceptor.

  • Post-flight

    • The next closure returns an InterceptorResultStream, which will emit results after the network fetch and parsing.

      • For multi-part responses (subscriptions and queries using @defer), this will emit multiple results, otherwise it will only emit a single result and then terminate.

    • To execute post-flight logic, map the result stream. Each time the stream receives a result, it will be passed into the map closure.

    • The map closure can return the original result or a modified version.

  • Return the stream

    • When finished, your interceptor must return the InterceptorResultStream, which will be passed back up the chain to the previous interceptor.

Example - Request logging interceptor

Swift
1struct RequestLoggingInterceptor: GraphQLInterceptor {
2  let logger: Logger
3
4  func intercept<Request: GraphQLRequest>(
5    request: Request,
6    next: NextInterceptorFunction<Request>
7  ) async throws -> InterceptorResultStream<Request> {
8    // Pre-flight: log the outgoing request
9    logger.log("🚀 Request: \(request.operation.operationName)")
10
11    // Call next interceptor and handle the response stream
12    return await next(request)
13      .map { result in
14        // Post-flight: log the response
15        logger.log("✅ Response received for: \(request.operation.operationName)")
16        return result
17      }
18      .mapErrors { error in
19        // Handle errors from later steps
20        logger.log("❌ Request failed: \(error)")
21        throw error
22      }
23  }
24}

Example - Authentication header interceptor

Swift
1struct AuthHeaderInterceptor: GraphQLInterceptor {
2  let authToken: String
3
4  func intercept<Request: GraphQLRequest>(
5    request: Request,
6    next: NextInterceptorFunction<Request>
7  ) async throws -> InterceptorResultStream<Request> {
8    // Pre-flight: add authentication header
9    var authenticatedRequest = request
10    authenticatedRequest.additionalHeaders["Authorization"] = "Bearer \(authToken)"
11
12    // Execute request and handle response
13    return await next(authenticatedRequest)
14  }
15}

HTTPInterceptor

HTTPInterceptor requires you to implement the intercept(request: next:) function to perform pre-flight work on the URLRequest and post-flight work on the HTTPResponse, including the raw response Data stream.

After the RequestChain proceeds through each GraphQLInterceptor provided by it's InterceptorProvider, it will call GraphQLRequest/toURLRequest() on the final GraphQLRequest. Each HTTPInterceptor provided by the InterceptorProvider will then have it's intercept(request:next:) function called in sequential order prior to fetching the request.

The intercept function works as follows:

  • Pre-flight

    • You may execute any custom pre-flight logic.

  • Call next(request)

    • You must call the provided next closure, passing in either the original request or a modified version.

    • This request will be sent to the next interceptor.

  • Post-flight

    • The next closure returns an HTTPResponse, which provides the HTTPURLResponse headers and a chunks stream that will emit the raw response data chunks as they are received.

      • For multi-part responses (subscriptions and queries using @defer), this will emit multiple results, otherwise it will only emit a single result and then terminate.

    • You may inspect the response headers at this point.

    • To execute post-flight logic on the response data, call response.mapChunks to intercept the data stream.

    • The mapChunks closure can return the original data stream or a modified version.

  • Return the HTTPResponse

    • When finished, your interceptor must return the HTTPResponse, which will be passed back up the chain to the previous HTTPInterceptor.

note
Because an HTTPInterceptor does not have access to the GraphQLRequest, it cannot trigger a RequestChain.Retry. We recommend throwing a custom error from a failing HTTPInterceptor and implementing a GraphQLInterceptor responsible for error handling to catch the error and trigger a retry with the appropriate GraphQLRequest.

CacheInterceptor

CacheInterceptor implementations handle cache read and write operations with custom logic beyond what's available through type/field policies. A CacheInterceptor must provide two functions, readCacheData(from store: request:) and writeCacheData(to store: request: response).

Example - Custom cache validation interceptor

Swift
1struct ValidatingCacheInterceptor: CacheInterceptor {
2
3  func readCacheData<Request: GraphQLRequest>(
4    from store: ApolloStore,
5    request: Request
6  ) async throws -> GraphQLResponse<Request.Operation>? {
7    // Try to read from cache
8    guard let cachedResponse = try await store.load(request.operation) else {
9      return nil
10    }
11
12    // Custom validation logic
13    if isCacheDataStale(cachedResponse) {
14      // Return nil to force network fetch
15      return nil
16    }
17
18    return cachedResponse
19  }
20
21  func writeCacheData<Request: GraphQLRequest>(
22    to store: ApolloStore,
23    request: Request,
24    response: ParsedResult<Request.Operation>
25  ) async throws {
26    // Custom write logic with validation
27    guard let records = response.cacheRecords,
28          shouldCache(request: request, response: response) else {
29      return
30    }
31
32    try await store.publish(records: records)
33  }
34
35  private func isCacheDataStale(_ response: GraphQLResponse<some GraphQLOperation>) -> Bool {
36    // Implementation specific cache validation logic
37    return false
38  }
39
40  private func shouldCache<Request: GraphQLRequest>(
41    request: Request,
42    response: ParsedResult<Request.Operation>
43  ) -> Bool {
44    // Implementation specific caching rules
45    return true
46  }
47}

ResponseParsingInterceptor

ResponseParsingInterceptor implementations handle parsing of HTTP response data into GraphQLResponse objects.

The default JSONResponseParsingInterceptor parses GraphQL specification compiliant response JSON and supports multi-part responses for subscriptions over HTTP and operations using the @defer directive.

If you are using a response format other than JSON or that differs from the GraphQL specification, you may need to provide a custom ResponseParsingInterceptor.

Request retries

Any GraphQLInterceptor can trigger a request retry by throwing a RequestChain.Retry error. When a RequestChain receives a thrown Retry error, it will restart from the beginning of the request chain flow using the request provided by the error. This allows the request to be modified to correct errors that may be causing the failure prior to beginning again.

Preventing infinite retry loops

If a retried request continues to fail, an interceptor that throws a Retry error may continue to throw Retry errors indefinitely, creating an infinite loop. To prevent this use a MaxRetryInterceptor. The DefaultInterceptorProvider includes a MaxRetryInterceptor with a default limit of 3 retries. When creating custom InterceptorProvider implementations, it is highly recommended to include a MaxRetryInterceptor early in the GraphQL interceptor chain.

If you are not using MaxRetryInterceptor, any interceptor that throws Retry errors must maintain state to ensure it does not trigger retries infinitely.

Error handling

Both pre-flight and post-flight errors can be handled using custom GraphQLInterceptors. An interceptor can use the mapErrors(_:) function of the InterceptorResultStream returned by calling the next closure. This will catch any errors thrown in later steps of the RequestChain, including:

  • Pre-flight errors thrown by GraphQLInterceptors later in the RequestChain.

  • Networking errors thrown by the ApolloURLSession or HTTPInterceptors in the RequestChain.

  • Parsing errors thrown by the ResponseParsingInterceptor of the RequestChain.

  • Post-flight errors thrown by GraphQLInterceptors later in the request chain.

Your mapErrors(_:) closure may rethrow the same error or a different error, which will then be passed up through the rest of the request chain. If possible, you may recover from the error by constructing and returning a ParsedResult. Returning nil will suppress the error and terminate the RequestChain's stream without emitting a result.

It is not required that every interceptor implement error handling. A GraphQLInterceptor that does not call mapErrors(_:) will be skipped if an error is emitted.

Example - Re-authentication interceptor

In this example, when an AuthencticationError is thrown, the interceptor will attempt to refresh the user's auth token and then retry the request.

Swift
1struct ReauthenticationInterceptor: GraphQLInterceptor {
2  let authManager: AuthenticationManager
3
4  func intercept<Request: GraphQLRequest>(
5    request: Request,
6    next: @escaping NextGraphQLInterceptorFunction<Request>
7  ) -> InterceptorResultStream<Request> {
8    return await next(request)
9      .mapErrors { error in
10        if let authError = error as? AuthenticationError {
11          // Handle authentication errors by refreshing an auth token and then retrying the request
12          let newAuthToken = try await authManager.refreshUserToken()
13          var refreshedRequest = request
14          refreshedRequest.additionalHeaders["Authorization": "Bearer \(newAuthToken)"]
15          throw RequestChain<Request>.Retry(request: refreshedRequest)
16        }
17        throw error // Re-throw other errors
18      }
19  }
20}

Dependency injection via TaskLocal values

Apollo iOS takes full advantage of Swift 6 structured concurrency, enabling you to use @TaskLocal values to pass context and dependencies between interceptors in a request chain. This provides a clean way to share information across the entire request lifecycle without explicitly passing values through each interceptor.

When a RequestChain is kicked off, the entire request chain runs within the same task context. Any @TaskLocal values set before the request begins are accessible to all interceptors throughout the chain execution. Interceptors can also set @TaskLocal values that will be accessible for the remainder of the request chain's execution.

note
@TaskLocal values rely on child task inheritance. Interceptors may run call their next functions or return from within a child Task, but you should avoid using Task.detached as this will create a new async context and @TaskLocal values will not be passed through the chain.

Example: Request tracing with correlation IDs

Here's how to use @TaskLocal for request correlation across interceptors:

1. Define the TaskLocal value:

You may define a @TaskLocal value anywhere you would like. In this example, we define a @TaskLocal value in a TracingInterceptor.

Swift
1struct TracingInterceptor: GraphQLInterceptor {
2  @TaskLocal
3  static var correlationId: String?
4
5  // ...
6}

2. Set the value:

The @TaskLocal value can be set by the interceptor before continuing with the request chain.

Swift
1struct TracingInterceptor: GraphQLInterceptor {
2  @TaskLocal
3  static var correlationId: String?
4
5  func intercept<Request: GraphQLRequest>(
6    request: Request,
7    next: NextInterceptorFunction<Request>
8  ) async throws -> InterceptorResultStream<Request> {
9    let correlationId = UUID().uuidString
10
11    return try await TracingInterceptor.$correlationId.withValue(correlationId) {
12      return await next(request)
13    }
14  }
15}

Alternatively, other parts of your application can set @TaskLocal values prior to beginning the request. These values will be accessible by any interceptors in the request chain.

Swift
1let correlationId = UUID().uuidString
2
3try await TracingInterceptor.$correlationId.withValue(correlationId) {
4  let result = try await apolloClient.fetch(query: query, cachePolicy: cachePolicy)
5}

3. Access the value in interceptors:

Once the @TaskLocal value is set, it can be accessed by any interceptor in the request chain.

Swift
1struct CorrelatedOperationInterceptor: GraphQLInterceptor {
2  func intercept<Request: GraphQLRequest>(
3    request: Request,
4    next: NextInterceptorFunction<Request>
5  ) async throws -> InterceptorResultStream<Request> {
6    return await next(request)
7      .map { result in
8        let correlationId = TracingInterceptor.correlationId ?? "unknown"
9        print("🔍 [Trace: \(correlationId)] Request completed successfully")
10        return result
11      }
12  }
13}

Example: User context sharing

For applications that need user context throughout the request chain:

1. Define user context:

Swift
1struct UserContext {
2  let userId: String
3  let tenantId: String
4  let permissions: Set<String>
5}
6
7extension TaskLocal where Value == UserContext? {
8  @TaskLocal
9  static var userContext: UserContext?
10}

2. Set context when making authenticated requests:

Swift
1struct AuthenticatedApolloClient {
2  private let apolloClient: ApolloClient
3
4  func fetchAsUser<Query: GraphQLQuery>(
5    query: Query,
6    userContext: UserContext,
7    cachePolicy: CachePolicy = .default
8  ) async throws -> GraphQLResponse<Query.Data> {
9
10    return try await TaskLocal.$userContext.withValue(userContext) {
11      return try await apolloClient.fetch(query: query, cachePolicy: cachePolicy)
12    }
13  }
14}

3. Use context in interceptors:

Swift
1struct AuthorizationInterceptor: GraphQLInterceptor {
2  func intercept<Request: GraphQLRequest>(
3    request: Request,
4    next: NextInterceptorFunction<Request>
5  ) async throws -> InterceptorResultStream<Request> {
6
7    guard let userContext = TaskLocal.userContext else {
8      throw AuthError.noUserContext
9    }
10
11    // Add user context to request
12    var authenticatedRequest = request
13    authenticatedRequest.additionalHeaders["X-User-ID"] = userContext.userId
14    authenticatedRequest.additionalHeaders["X-Tenant-ID"] = userContext.tenantId
15
16    return await next(authenticatedRequest)
17      .mapErrors { error in
18        // Log error with user context
19        print("Request failed for user \(userContext.userId): \(error)")
20        throw error
21      }
22  }
23}
24
25struct AuditInterceptor: GraphQLInterceptor {
26  func intercept<Request: GraphQLRequest>(
27    request: Request,
28    next: NextInterceptorFunction<Request>
29  ) async throws -> InterceptorResultStream<Request> {
30
31    return await next(request)
32      .map { result in
33        // Audit successful operations
34        if let userContext = TaskLocal.userContext {
35          auditLog.record(
36            operation: request.operation.operationName ?? "unknown",
37            userId: userContext.userId,
38            tenantId: userContext.tenantId,
39            timestamp: Date()
40          )
41        }
42        return result
43      }
44  }
45}
Feedback

Edit on GitHub

Ask Community