Experimental WebSockets
Historically, WebSockets have been one of the most complex and error-prone parts of Apollo Kotlin because:
-
The WebSocket transport protocol has no official specification and different implementations have different behaviours.
-
WebSockets are stateful and making them work using the old Kotlin native memory model was challenging.
Because WebSockets are long-lived connections, they are more exposed to errors and knowing when (or if) to retry is hard.
Not all subscriptions happen on WebSockets. Some use HTTP multipart for an example.
Starting with 4.0.0, Apollo Kotlin provides a new com.apollographql.apollo.network.websocket
package containing new WebSocketNetworkTransport
and WebSocketEngine
implementations (instead of com.apollographql.apollo.network.ws
for the current implementations).
The com.apollographql.apollo.network.websocket
implementation provides the following:
Defaults to the graphql-ws protocol, which has become the de facto standard. Using the other protocols is still possible but having a main, specified, protocol ensures we can write a good and solid test suite.
Does not inherit from the old memory model design, making the code considerably simpler. In particular,
WebSocketEngine
is now event based and no attempt at flow control is done. If your buffers grow too much, your subscription fails.Plays nicely with the ApolloClient
retryOnError
API.Handles different Subscription transports more consistently.
Status
.websocket
APIs are more robust than the non-experimental .ws
ones, especially in scenarios involving retries/connection errors. They are safe to use in non-lib use cases.
The @ApolloExperimental
annotation accounts for required API breaking changes based on community feedback. Ideally no change will be needed.
After a feedback phase, the current .ws
APIs will become deprecated and the .websocket
one promoted to stable by removing the @ApolloExperimental
annotations.
Handling errors
Changing the WebSocket HTTP header on Error
The HTTP headers of ApolloCall
are honored and different WebSockets are created for different header values. This means you can use an interceptor to change the header value on error:
1private class UpdateAuthorizationHeaderInterceptor : ApolloInterceptor {
2 @OptIn(ExperimentalCoroutinesApi::class)
3 override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
4 return flow {
5 // Retrieve a new access token every time
6 val request = request.newBuilder()
7 .addHttpHeader("Authorization", "Bearer ${accessToken()}")
8 .build()
9
10 emitAll(chain.proceed(request))
11 }
12 }
13}
Changing the connectionPayload on Error
The connection payload is WsProtocol
specific and you can pass a lambda to your WsProtocol
constructor that is evaluated every time a WebSocket needs to be created.
1ApolloClient.Builder()
2 .httpServerUrl(mockServer.url())
3 .subscriptionNetworkTransport(
4 WebSocketNetworkTransport.Builder()
5 .serverUrl(mockServer.url())
6 .wsProtocol(GraphQLWsProtocol(
7 connectionPayload = {
8 getFreshConnectionPayload()
9 }
10 ))
11 .build()
12 )
13 .build()
retryOnErrorInterceptor
By default, ApolloClient
does not retry. You can override that behaviour with retryOnErrorInterceptor
. You can combine that interceptor with the UpdateAuthorizationHeaderInterceptor
:
1private object RetryException : Exception()
2
3private class RetryOnErrorInterceptor : ApolloInterceptor {
4 override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
5 var attempt = 0
6 return flow {
7 val request = request.newBuilder()
8 .addHttpHeader("Authorization", "Bearer ${accessToken()}")
9 .build()
10
11 emitAll(chain.proceed(request))
12 }.onEach {
13 when (val exception = it.exception) {
14 is ApolloWebSocketClosedException -> {
15 if (exception.code == 1002 && exception.reason == "unauthorized") {
16 attempt = 0 // retry immediately
17 throw RetryException
18 }
19 }
20 is ApolloNetworkException -> {
21 // Retry all network exceptions
22 throw RetryException
23 }
24 else -> {
25 // Terminate the subscription
26 }
27 }
28 }.retryWhen { cause, _ ->
29 cause is RetryException
30 }
31 }
32}
Migration guide
Package name
In simple cases where you did not configure the underlying WsProtocol
or retry logic, the migration should be about replacing com.apollographql.apollo.network.ws
with com.apollographql.apollo.network.websocket
everywhere:
1// Replace
2import com.apollographql.apollo.network.ws.WebSocketNetworkTransport
3import com.apollographql.apollo.network.ws.WebSocketEngine
4// etc...
5
6// With
7import com.apollographql.apollo.network.websocket.WebSocketNetworkTransport
8import com.apollographql.apollo.network.websocket.WebSocketEngine
9// etc...
Because we can't remove the current APIs just yet, the ApolloClient.Builder
shortcut APIs are still pointing to the .ws
implementations. To use the newer .websocket
implementation, pass a websocket.WebSocketNetworkTransport
directly:
1// Replace
2val apolloClient = ApolloClient.Builder()
3 .serverUrl(serverUrl)
4 .webSocketServerUrl(webSocketServerUrl)
5 .webSocketEngine(myWebSocketEngine)
6 .webSocketIdleTimeoutMillis(10_000)
7 .build()
8
9// With
10import com.apollographql.apollo.network.websocket.*
11
12// [...]
13
14ApolloClient.Builder()
15 .serverUrl(serverUrl)
16 .subscriptionNetworkTransport(
17 WebSocketNetworkTransport.Builder()
18 .serverUrl(webSocketServerUrl)
19 // If you didn't set a WsProtocol before, make sure to include this
20 .wsProtocol(SubscriptionWsProtocol())
21 // If you were already using GraphQLWsProtocol, this is now the default
22 //.wsProtocol(GraphQLWsProtocol())
23 .webSocketEngine(myWebSocketEngine)
24 .idleTimeoutMillis(10_000)
25 .build()
26 )
27 .build()
Connection init payload
If you were using connectionPayload
before, you can now pass it as an argument directly. There is no WsProtocol.Factory
anymore:
1// Replace
2GraphQLWsProtocol.Factory(
3 connectionPayload = {
4 mapOf("Authorization" to token)
5 },
6)
7
8// With
9GraphQLWsProtocol(
10 connectionPayload = {
11 mapOf("Authorization" to token)
12 },
13)
Retrying on network errors
Apollo Kotlin 4 also comes with a default retryOnErrorInterceptor
that uses a network monitor or exponential backoff to retry the subscription.
If you want your subscription to be restarted automatically when a network error happens, use retryOnError {}
:
1// Replace
2val apolloClient = ApolloClient.Builder()
3 .serverUrl(serverUrl)
4 .subscriptionNetworkTransport(
5 WebSocketNetworkTransport.Builder()
6 .serverUrl(webSocketServerUrl)
7 .reopenWhen { _, attempt ->
8 // exponential backoff
9 delay(2.0.pow(attempt).seconds)
10 true
11 }
12 .build()
13 )
14 .build()
15
16// With
17val apolloClient = ApolloClient.Builder()
18 .serverUrl(serverUrl)
19 .subscriptionNetworkTransport(/*..*/)
20 .retryOnError {
21 /*
22 * This is called for every GraphQL operation.
23 * Only retry subscriptions.
24 */
25 it.operation is Subscription
26 }
27 .build()
You can also customize the retry logic using retryOnErrorInterceptor
. Read more about it in the network connectivity page.