Skip to content

Commit

Permalink
[📚docs] documentation for next version (#6106)
Browse files Browse the repository at this point in the history
* websocket docs

* More documentation on experimental websockets

---------

Co-authored-by: Benoit 'BoD' Lubek <[email protected]>
  • Loading branch information
martinbonnin and BoD authored Oct 1, 2024
1 parent 92bb03c commit 92dee72
Show file tree
Hide file tree
Showing 2 changed files with 160 additions and 57 deletions.
63 changes: 45 additions & 18 deletions docs/source/advanced/experimental-websockets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ After a feedback phase, the current `.ws` APIs will become deprecated and the `.

## 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:

```kotlin
Expand Down Expand Up @@ -80,35 +82,60 @@ ApolloClient.Builder()
.build()
```

To account for non-websocket transports, like [multipart subscriptions](https://www.apollographql.com/docs/router/executing-operations/subscription-multipart-protocol/), the retry is now handled on the `ApolloClient` instead of the `NetworkTransport`.
### Connection init payload

If you were using `connectionPayload` before, you can now pass it as an argument directly. There is no `WsProtocol.Factory` anymore:

```kotlin
// Replace
GraphQLWsProtocol.Factory(
connectionPayload = {
mapOf("Authorization" to token)
},
)

// With
GraphQLWsProtocol(
connectionPayload = {
mapOf("Authorization" to token)
},
)
```

### 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 {}`:

```kotlin
// Replace
val apolloClient = ApolloClient.Builder()
.serverUrl(serverUrl)
.subscriptionNetworkTransport(
WebSocketNetworkTransport.Builder()
.serverUrl(url)
.reopenWhen { _, _ ->
delay(1000)
.serverUrl(webSocketServerUrl)
.reopenWhen { _, attempt ->
// exponential backoff
delay(2.0.pow(attempt).seconds) // highlight-line
true
}
.build()
)
.build()

// With
// With
val apolloClient = ApolloClient.Builder()
.subscriptionNetworkTransport(
WebSocketNetworkTransport.Builder()
.serverUrl(url)
.build()
)
// Only retry subscriptions
.retryOnError { it.operation is Subscription }
.serverUrl(serverUrl)
.subscriptionNetworkTransport(/*..*/)
.retryOnError {
/*
* This is called for every GraphQL operation.
* Only retry subscriptions.
*/
it.operation is Subscription // highlight-line
}
.build()
```

The above uses the default retry algorithm:

* Wait until the network is available if you configured a [NetworkMonitor](network-connectivity).
* Or use exponential backoff else.

To customize the retry logic more, see the [network monitoring page](../advanced/network-connectivity#customizing-the-retry-algorithm).
You can also customize the retry logic using `retryOnErrorInterceptor`. Read more about it in the [network connectivity page](network-connectivity).
154 changes: 115 additions & 39 deletions libraries/apollo-runtime/src/jvmTest/kotlin/RetryWebSocketsTest.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@

import app.cash.turbine.test
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.ApolloRequest
import com.apollographql.apollo.api.ApolloResponse
import com.apollographql.apollo.api.Operation
import com.apollographql.apollo.api.Subscription
import com.apollographql.apollo.exception.ApolloHttpException
import com.apollographql.apollo.exception.ApolloNetworkException
import com.apollographql.apollo.interceptor.ApolloInterceptor
import com.apollographql.apollo.interceptor.ApolloInterceptorChain
import com.apollographql.apollo.network.websocket.WebSocketNetworkTransport
import com.apollographql.apollo.testing.connectionAckMessage
import com.apollographql.apollo.testing.internal.runTest
import com.apollographql.mockserver.MockResponse
import com.apollographql.mockserver.MockServer
import com.apollographql.mockserver.assertNoRequest
import com.apollographql.mockserver.awaitWebSocketRequest
import com.apollographql.mockserver.enqueueWebSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
Expand All @@ -28,6 +38,7 @@ import test.network.mockServerTest
import test.network.retryWhen
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.time.Duration.Companion.seconds
Expand All @@ -37,56 +48,56 @@ class RetryWebSocketsTest {
fun retryIsWorking() = runTest {
MockServer().use { mockServer ->

ApolloClient.Builder()
.serverUrl(mockServer.url())
.retryOnError { it.operation is Subscription }
.subscriptionNetworkTransport(
WebSocketNetworkTransport.Builder()
.serverUrl(mockServer.url())
.build()
)
.build().use { apolloClient ->
apolloClient.subscription(FooSubscription())
.toFlow()
.test {
val serverWriter = mockServer.enqueueWebSocket(keepAlive = false)
var serverReader = mockServer.awaitWebSocketRequest()
ApolloClient.Builder()
.serverUrl(mockServer.url())
.retryOnError { it.operation is Subscription }
.subscriptionNetworkTransport(
WebSocketNetworkTransport.Builder()
.serverUrl(mockServer.url())
.build()
)
.build().use { apolloClient ->
apolloClient.subscription(FooSubscription())
.toFlow()
.test {
val serverWriter = mockServer.enqueueWebSocket(keepAlive = false)
var serverReader = mockServer.awaitWebSocketRequest()

serverReader.awaitMessage() // connection_init
serverWriter.enqueueMessage(connectionAckMessage())
serverReader.awaitMessage() // connection_init
serverWriter.enqueueMessage(connectionAckMessage())

val operationId1 = serverReader.awaitSubscribe()
serverWriter.enqueueMessage(nextMessage(operationId1, 1))
val operationId1 = serverReader.awaitSubscribe()
serverWriter.enqueueMessage(nextMessage(operationId1, 1))

val item1 = awaitItem()
assertEquals(1, item1.data?.foo)
val item1 = awaitItem()
assertEquals(1, item1.data?.foo)

val serverWriter2 = mockServer.enqueueWebSocket()
val serverWriter2 = mockServer.enqueueWebSocket()

/**
* Close the response body and the TCP socket
*/
serverWriter.close()
/**
* Close the response body and the TCP socket
*/
serverWriter.close()

serverReader = mockServer.awaitWebSocketRequest()
serverReader = mockServer.awaitWebSocketRequest()

serverReader.awaitMessage() // connection_init
serverWriter2.enqueueMessage(connectionAckMessage())
serverReader.awaitMessage() // connection_init
serverWriter2.enqueueMessage(connectionAckMessage())

val operationId2 = serverReader.awaitSubscribe()
serverWriter2.enqueueMessage(nextMessage(operationId2, 2))
val operationId2 = serverReader.awaitSubscribe()
serverWriter2.enqueueMessage(nextMessage(operationId2, 2))

val item2 = awaitItem()
assertEquals(2, item2.data?.foo)
val item2 = awaitItem()
assertEquals(2, item2.data?.foo)

// The subscriptions MUST use different operationIds
assertNotEquals(operationId1, operationId2)
// The subscriptions MUST use different operationIds
assertNotEquals(operationId1, operationId2)

serverWriter2.enqueueMessage(completeMessage(operationId2))
serverWriter2.enqueueMessage(completeMessage(operationId2))

awaitComplete()
}
}
awaitComplete()
}
}
}
}

Expand Down Expand Up @@ -151,6 +162,71 @@ class RetryWebSocketsTest {
}
}

class MyRetryOnErrorInterceptor : ApolloInterceptor {
data object RetryException : Exception()

override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
return chain.proceed(request).onEach {
if (request.retryOnError == true && it.exception != null && it.exception is ApolloNetworkException) {
throw RetryException
}
}.retryWhen { cause, attempt ->
cause is RetryException && attempt < 2
}.catch {
if (it !is RetryException) throw it
}
}
}

@Test
fun customRetryOnErrorInterceptor() = runTest {
val mockServer = MockServer()
ApolloClient.Builder()
.serverUrl(mockServer.url())
.subscriptionNetworkTransport(
WebSocketNetworkTransport.Builder()
.serverUrl(mockServer.url())
.build()
)
.retryOnError {
it.operation is Subscription
}
.retryOnErrorInterceptor(MyRetryOnErrorInterceptor())
.build().use { apolloClient ->
var serverWriter = mockServer.enqueueWebSocket(keepAlive = false)

apolloClient.subscription(FooSubscription())
.toFlow()
.test {
/*
* We retry 2 times, meaning we expect 3 collections
*/
repeat(3) {
val serverReader = mockServer.awaitWebSocketRequest()

serverReader.awaitMessage() // connection_init
serverWriter.enqueueMessage(connectionAckMessage())

val operationId = serverReader.awaitSubscribe()
serverWriter.enqueueMessage(nextMessage(operationId, it))

val item1 = awaitItem()
assertEquals(it, item1.data?.foo)

val lastServerWriter = serverWriter
serverWriter = mockServer.enqueueWebSocket(keepAlive = false)
lastServerWriter.close()
}
assertFails {
// Make sure that no retry is done
mockServer.awaitAnyRequest(timeout = 1.seconds)
}
serverWriter.close()
awaitComplete()
}
}
}

@Test
fun retryCanBeDisabled() = runTest {
val mockServer = MockServer()
Expand Down

0 comments on commit 92dee72

Please sign in to comment.