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 theGraphQLRequestCache read executed via
CacheInterceptorif necessary (based on cache policy)GraphQLRequest.toURLRequest()called to obtainURLRequestHTTPInterceptorsreceive and may mutateURLRequestApolloURLSessionhandles networking withURLRequestHTTPInterceptorsreceive stream ofHTTPResponseobjects for each chunk & may mutate raw chunkDatastreamResponseParsingInterceptorreceivesHTTPResponseand parses data chunks into stream ofGraphQLResponsesGraphQLInterceptors receive and may mutateParsedResultwith parsedGraphQLResponseCache write executed via
CacheInterceptorif necessary (based on cache policy)GraphQLResponsestream emitted out toNetworkTransport
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
GraphQLRequestbefore processingPost-flight: Inspect and mutate the
GraphQLResponseand parsed resultsError 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
URLRequestbefore network fetchPost-flight: Inspect the
HTTPResponseand 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:
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:
MaxRetryInterceptor- Limits request retries (default: 3 retries)AutomaticPersistedQueryInterceptor- Handles APQ retry logic
Cache Interceptor:
DefaultCacheInterceptor- Handles cache reads and writes
HTTP Interceptors:
ResponseCodeInterceptor- Handles HTTP error status codes
Response Parser:
JSONResponseParsingInterceptor- Parses standard GraphQL JSON responses
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
nextclosure, passing in either the original request or a modified version.This request will be sent to the next interceptor.
Post-flight
The
nextclosure returns anInterceptorResultStream, 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,
mapthe result stream. Each time the stream receives a result, it will be passed into themapclosure.The
mapclosure 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
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
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
nextclosure, passing in either the original request or a modified version.This request will be sent to the next interceptor.
Post-flight
The
nextclosure returns anHTTPResponse, which provides theHTTPURLResponseheaders and achunksstream 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.mapChunksto intercept the data stream.The
mapChunksclosure can return the original data stream or a modified version.
Return the
HTTPResponseWhen finished, your interceptor must return the
HTTPResponse, which will be passed back up the chain to the previousHTTPInterceptor.
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
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 theRequestChain.Networking errors thrown by the
ApolloURLSessionorHTTPInterceptors in theRequestChain.Parsing errors thrown by the
ResponseParsingInterceptorof theRequestChain.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.
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.
@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.
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.
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.
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.
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:
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:
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:
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}