Skip to content

Commit c31180c

Browse files
authored
add ApolloClient.Builder.retryOnErrorInterceptor (#5989)
1 parent 721495b commit c31180c

File tree

7 files changed

+117
-58
lines changed

7 files changed

+117
-58
lines changed

libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/ApolloRequest.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import com.benasher44.uuid.uuid4
1717
* val newRequest = apolloRequest.newBuilder().addHttpHeader("Authorization", "Bearer $token").build()
1818
* ```
1919
*
20+
* @property operation the GraphQL operation for this request
21+
* @property requestUuid a unique id for this request. For queries and mutations, this is only used for debug.
22+
* For subscriptions, it is used as subscription id when multiplexing several subscription over a WebSocket.
23+
*
2024
* @see [com.apollographql.apollo3.ApolloCall]
2125
*/
2226
class ApolloRequest<D : Operation.Data>

libraries/apollo-runtime/src/androidInstrumentedTest/kotlin/instrumented/NetworkMonitorTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.apollographql.apollo3.api.http.HttpResponse
66
import com.apollographql.apollo3.exception.ApolloNetworkException
77
import com.apollographql.mockserver.assertNoRequest
88
import com.apollographql.mockserver.enqueueString
9+
import com.apollographql.apollo3.interceptor.RetryOnErrorInterceptor
910
import com.apollographql.apollo3.network.NetworkMonitor
1011
import com.apollographql.apollo3.network.http.DefaultHttpEngine
1112
import com.apollographql.apollo3.network.http.HttpEngine
@@ -37,7 +38,9 @@ class NetworkMonitorTest {
3738
@Test
3839
fun test() = mockServerTest(
3940
clientBuilder = {
40-
networkMonitor(NetworkMonitor(InstrumentationRegistry.getInstrumentation().context))
41+
retryOnErrorInterceptor(
42+
RetryOnErrorInterceptor(NetworkMonitor(InstrumentationRegistry.getInstrumentation().context))
43+
)
4144
retryOnError { true }
4245
httpEngine(FaultyHttpEngine())
4346
}

libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloClient.kt

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,9 @@ import com.apollographql.apollo3.interceptor.ApolloInterceptor
2222
import com.apollographql.apollo3.interceptor.AutoPersistedQueryInterceptor
2323
import com.apollographql.apollo3.interceptor.DefaultInterceptorChain
2424
import com.apollographql.apollo3.interceptor.NetworkInterceptor
25-
import com.apollographql.apollo3.interceptor.RetryOnNetworkErrorInterceptor
25+
import com.apollographql.apollo3.interceptor.RetryOnErrorInterceptor
2626
import com.apollographql.apollo3.internal.ApolloClientListener
2727
import com.apollographql.apollo3.internal.defaultDispatcher
28-
import com.apollographql.apollo3.network.NetworkMonitor
2928
import com.apollographql.apollo3.network.NetworkTransport
3029
import com.apollographql.apollo3.network.http.BatchingHttpInterceptor
3130
import com.apollographql.apollo3.network.http.HttpEngine
@@ -83,8 +82,8 @@ private constructor(
8382
val subscriptionNetworkTransport: NetworkTransport
8483
val interceptors: List<ApolloInterceptor> = builder.interceptors
8584
val customScalarAdapters: CustomScalarAdapters = builder.customScalarAdapters
86-
private val networkMonitor: NetworkMonitor?
8785
private val retryOnError: ((ApolloRequest<*>) -> Boolean)? = builder.retryOnError
86+
private val retryOnErrorInterceptor: ApolloInterceptor? = builder.retryOnErrorInterceptor
8887
private val failFastIfOffline = builder.failFastIfOffline
8988
private val listeners = builder.listeners
9089

@@ -97,8 +96,6 @@ private constructor(
9796
override val canBeBatched: Boolean? = builder.canBeBatched
9897

9998
init {
100-
networkMonitor = builder.networkMonitor
101-
10299
networkTransport = if (builder.networkTransport != null) {
103100
check(builder.httpServerUrl == null) {
104101
"Apollo: 'httpServerUrl' has no effect if 'networkTransport' is set"
@@ -318,7 +315,7 @@ private constructor(
318315

319316
val allInterceptors = buildList {
320317
addAll(interceptors)
321-
add(RetryOnNetworkErrorInterceptor(networkMonitor))
318+
add(retryOnErrorInterceptor ?: RetryOnErrorInterceptor())
322319
add(networkInterceptor)
323320
}
324321
return DefaultInterceptorChain(allInterceptors, 0)
@@ -399,11 +396,11 @@ private constructor(
399396
private set
400397

401398
@ApolloExperimental
402-
var networkMonitor: NetworkMonitor? = null
399+
var retryOnError: ((ApolloRequest<*>) -> Boolean)? = null
403400
private set
404401

405402
@ApolloExperimental
406-
var retryOnError: ((ApolloRequest<*>) -> Boolean)? = null
403+
var retryOnErrorInterceptor: ApolloInterceptor? = null
407404
private set
408405

409406
@ApolloExperimental
@@ -412,26 +409,16 @@ private constructor(
412409

413410
/**
414411
* Whether to fail fast if the device is offline.
412+
* Requires setting an interceptor that is aware of the network state with [retryOnErrorInterceptor].
415413
*
416-
* In that case, the returned [ApolloResponse.exception] is an instance of [com.apollographql.apollo3.exception.ApolloNetworkException]
417-
*
418-
* @see NetworkMonitor
414+
* @see [retryOnErrorInterceptor]
415+
* @see [com.apollographql.apollo3.network.NetworkMonitor]
419416
*/
420417
@ApolloExperimental
421418
fun failFastIfOffline(failFastIfOffline: Boolean?): Builder = apply {
422419
this.failFastIfOffline = failFastIfOffline
423420
}
424421

425-
/**
426-
* Configures the [NetworkMonitor] for this [ApolloClient]
427-
*
428-
* @param networkMonitor or `null` to use the default [NetworkMonitor]
429-
*/
430-
@ApolloExperimental
431-
fun networkMonitor(networkMonitor: NetworkMonitor?): Builder = apply {
432-
this.networkMonitor = networkMonitor
433-
}
434-
435422
/**
436423
* Configures the [retryOnError] default if [ApolloRequest.retryOnError] is not set.
437424
*
@@ -451,6 +438,34 @@ private constructor(
451438
this.retryOnError = retryOnError
452439
}
453440

441+
/**
442+
* Sets the [ApolloInterceptor] used to retry or fail fast a request. The interceptor may use [ApolloRequest.retryOnError]
443+
* and [ApolloRequest.failFastIfOffline].
444+
* The interceptor is also responsible for allocating a new [ApolloRequest.requestUuid] on retries if needed.
445+
*
446+
* By default [ApolloClient] uses a best effort interceptor that is not aware about network state, uses exponential backoff
447+
* and ignores [ApolloRequest.failFastIfOffline].
448+
*
449+
* Use [RetryOnErrorInterceptor] to add network state awareness:
450+
*
451+
* ```
452+
* apolloClient = ApolloClient.Builder()
453+
* .serverUrl("https://...")
454+
* .retryOnErrorInterceptor(RetryOnErrorInterceptor(NetworkMonitor(context)))
455+
* .build()
456+
* ```
457+
*
458+
* @param retryOnErrorInterceptor the [ApolloInterceptor] to use for retrying or `null` to use the default interceptor.
459+
*
460+
* @see [RetryOnErrorInterceptor]
461+
* @see [ApolloRequest.retryOnError]
462+
* @see [ApolloRequest.failFastIfOffline]
463+
*/
464+
@ApolloExperimental
465+
fun retryOnErrorInterceptor(retryOnErrorInterceptor: ApolloInterceptor?) = apply {
466+
this.retryOnErrorInterceptor = retryOnErrorInterceptor
467+
}
468+
454469
/**
455470
* Configures the [HttpMethod] to use.
456471
*
@@ -930,7 +945,7 @@ private constructor(
930945
.webSocketIdleTimeoutMillis(webSocketIdleTimeoutMillis)
931946
.wsProtocol(wsProtocolFactory)
932947
.retryOnError(retryOnError)
933-
.networkMonitor(networkMonitor)
948+
.retryOnErrorInterceptor(retryOnErrorInterceptor)
934949
.failFastIfOffline(failFastIfOffline)
935950
.listeners(listeners)
936951
}
Lines changed: 24 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.apollographql.apollo3.interceptor
22

3-
import com.apollographql.apollo3.ApolloClient
43
import com.apollographql.apollo3.annotations.ApolloExperimental
54
import com.apollographql.apollo3.api.ApolloRequest
65
import com.apollographql.apollo3.api.ApolloResponse
@@ -22,15 +21,34 @@ import kotlin.time.Duration.Companion.seconds
2221

2322

2423
/**
25-
* An [ApolloInterceptor] that monitors network errors and possibly retries the [Flow] when an [ApolloNetworkException] happens.
24+
* Returns an [ApolloInterceptor] that monitors network errors and possibly retries the [Flow] when an [ApolloNetworkException] happens.
2625
*
27-
* Some other types of error might be recoverable as well (rate limit, ...) but are out of scope for this interceptor.
26+
* The returned [RetryOnErrorInterceptor]:
27+
* - allocates a new [ApolloRequest.requestUuid] for each retry.
28+
* - if [ApolloRequest.retryOnError] is `true`, waits until network is available and retries the request.
29+
* - if [ApolloRequest.failFastIfOffline] is `true` and [NetworkMonitor.isOnline] is `false`, returns early with [ApolloNetworkException].
2830
*
29-
* If no network monitor is available, the retry algorithm uses exponential backoff
31+
* Use with [com.apollographql.apollo3.ApolloClient.Builder.retryOnErrorInterceptor]:
3032
*
31-
* @param networkMonitor a network monitor or `null` if none available.
33+
* ```
34+
* apolloClient = ApolloClient.Builder()
35+
* .serverUrl("https://...")
36+
* .retryOnErrorInterceptor(RetryOnErrorInterceptor(NetworkMonitor(context)))
37+
* .build()
38+
* ```
39+
*
40+
* Some other types of error than [ApolloNetworkException] might be recoverable as well (rate limit, ...) but are out of scope for this interceptor.
41+
*
42+
* @see [com.apollographql.apollo3.ApolloClient.Builder.retryOnErrorInterceptor]
43+
* @see [ApolloRequest.retryOnError]
44+
* @see [ApolloRequest.failFastIfOffline]
3245
*/
33-
internal class RetryOnNetworkErrorInterceptor(private val networkMonitor: NetworkMonitor?) : ApolloInterceptor {
46+
@ApolloExperimental
47+
fun RetryOnErrorInterceptor(networkMonitor: NetworkMonitor): ApolloInterceptor = DefaultRetryOnErrorInterceptorImpl(networkMonitor)
48+
49+
internal fun RetryOnErrorInterceptor(): ApolloInterceptor = DefaultRetryOnErrorInterceptorImpl(null)
50+
51+
private class DefaultRetryOnErrorInterceptorImpl(private val networkMonitor: NetworkMonitor?) : ApolloInterceptor {
3452
override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
3553
val failFastIfOffline = request.failFastIfOffline ?: false
3654
val retryOnError = request.retryOnError ?: false
@@ -106,25 +124,3 @@ internal fun <T> Flow<Flow<T>>.flattenConcatPolyfill(): Flow<T> = flow {
106124
collect { value -> emitAll(value) }
107125
}
108126

109-
private fun <D : Operation.Data> Flow<ApolloResponse<D>>.retryOnError(block: suspend (ApolloException, Int) -> Boolean): Flow<ApolloResponse<D>> {
110-
var attempt = 0
111-
return onEach {
112-
if (it.exception != null && block(it.exception!!, attempt)) {
113-
attempt++
114-
throw RetryException
115-
}
116-
}.retryWhen { cause, _ ->
117-
cause is RetryException
118-
}
119-
}
120-
121-
internal class RetryOnErrorInterceptor(private val retryWhen: suspend (ApolloException, Int) -> Boolean) : ApolloInterceptor {
122-
override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
123-
return chain.proceed(request).retryOnError(retryWhen)
124-
}
125-
}
126-
127-
@ApolloExperimental
128-
fun ApolloClient.Builder.addRetryOnErrorInterceptor(retryWhen: suspend (ApolloException, Int) -> Boolean) = apply {
129-
addInterceptor(RetryOnErrorInterceptor(retryWhen))
130-
}

libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.apollographql.apollo3.api.Operation
88
import com.apollographql.apollo3.exception.ApolloNetworkException
99
import com.apollographql.apollo3.interceptor.ApolloInterceptor
1010
import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
11+
import com.apollographql.apollo3.interceptor.RetryOnErrorInterceptor
1112
import com.apollographql.mockserver.MockServer
1213
import com.apollographql.mockserver.assertNoRequest
1314
import com.apollographql.mockserver.enqueueString
@@ -37,7 +38,7 @@ class NetworkMonitorTest {
3738
val fakeNetworkMonitor = FakeNetworkMonitor()
3839

3940
return mockServerTest(clientBuilder = {
40-
networkMonitor(fakeNetworkMonitor)
41+
retryOnErrorInterceptor(RetryOnErrorInterceptor(fakeNetworkMonitor))
4142
failFastIfOffline(true)
4243
}) {
4344

libraries/apollo-runtime/src/commonTest/kotlin/test/network/WebSocketNetworkTransportTest.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ package test.network
22

33
import app.cash.turbine.test
44
import com.apollographql.apollo3.ApolloClient
5+
import com.apollographql.apollo3.annotations.ApolloExperimental
6+
import com.apollographql.apollo3.api.ApolloRequest
7+
import com.apollographql.apollo3.api.ApolloResponse
8+
import com.apollographql.apollo3.api.Operation
59
import com.apollographql.apollo3.exception.ApolloException
610
import com.apollographql.apollo3.exception.ApolloNetworkException
711
import com.apollographql.apollo3.exception.ApolloWebSocketClosedException
812
import com.apollographql.apollo3.exception.DefaultApolloException
913
import com.apollographql.apollo3.exception.SubscriptionOperationException
10-
import com.apollographql.apollo3.interceptor.addRetryOnErrorInterceptor
14+
import com.apollographql.apollo3.interceptor.ApolloInterceptor
15+
import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
1116
import com.apollographql.apollo3.network.websocket.WebSocketNetworkTransport
1217
import com.apollographql.apollo3.network.websocket.closeConnection
1318
import com.apollographql.apollo3.testing.FooSubscription
@@ -28,7 +33,10 @@ import com.apollographql.mockserver.enqueueWebSocket
2833
import com.apollographql.mockserver.headerValueOf
2934
import kotlinx.coroutines.CoroutineScope
3035
import kotlinx.coroutines.delay
36+
import kotlinx.coroutines.flow.Flow
3137
import kotlinx.coroutines.flow.merge
38+
import kotlinx.coroutines.flow.onEach
39+
import kotlinx.coroutines.flow.retryWhen
3240
import okio.use
3341
import kotlin.test.Test
3442
import kotlin.test.assertEquals
@@ -304,7 +312,7 @@ class WebSocketNetworkTransportTest {
304312
.serverUrl(mockServer.url())
305313
.build()
306314
)
307-
.addRetryOnErrorInterceptor { e, _ ->
315+
.retryWhen { e, _ ->
308316
check(exception == null)
309317
exception = e
310318
true
@@ -410,3 +418,28 @@ fun mockServerWebSocketTest(customizeTransport: WebSocketNetworkTransport.Builde
410418
}
411419
}
412420
}
421+
422+
private object RetryException : Exception()
423+
424+
private fun <D : Operation.Data> Flow<ApolloResponse<D>>.retryOnError(block: suspend (ApolloException, Int) -> Boolean): Flow<ApolloResponse<D>> {
425+
var attempt = 0
426+
return onEach {
427+
if (it.exception != null && block(it.exception!!, attempt)) {
428+
attempt++
429+
throw RetryException
430+
}
431+
}.retryWhen { cause, _ ->
432+
cause is RetryException
433+
}
434+
}
435+
436+
internal class RetryOnErrorInterceptor(private val retryWhen: suspend (ApolloException, Int) -> Boolean) : ApolloInterceptor {
437+
override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
438+
return chain.proceed(request).retryOnError(retryWhen)
439+
}
440+
}
441+
442+
@ApolloExperimental
443+
internal fun ApolloClient.Builder.retryWhen(retryWhen: suspend (ApolloException, Int) -> Boolean) = apply {
444+
retryOnErrorInterceptor(RetryOnErrorInterceptor(retryWhen))
445+
}

libraries/apollo-runtime/src/jvmTest/kotlin/RetryWebSocketsTest.kt

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11

22
import app.cash.turbine.test
33
import com.apollographql.apollo3.ApolloClient
4+
import com.apollographql.apollo3.annotations.ApolloExperimental
5+
import com.apollographql.apollo3.api.ApolloRequest
6+
import com.apollographql.apollo3.api.ApolloResponse
7+
import com.apollographql.apollo3.api.Operation
48
import com.apollographql.apollo3.api.Subscription
9+
import com.apollographql.apollo3.exception.ApolloException
510
import com.apollographql.apollo3.exception.ApolloHttpException
611
import com.apollographql.apollo3.exception.ApolloNetworkException
7-
import com.apollographql.apollo3.interceptor.addRetryOnErrorInterceptor
12+
import com.apollographql.apollo3.interceptor.ApolloInterceptor
13+
import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
814
import com.apollographql.mockserver.MockResponse
915
import com.apollographql.mockserver.MockServer
1016
import com.apollographql.mockserver.awaitWebSocketRequest
@@ -31,6 +37,7 @@ import kotlin.test.assertIs
3137
import kotlin.test.assertNotEquals
3238
import kotlin.time.Duration.Companion.seconds
3339
import test.network.awaitSubscribe
40+
import test.network.retryWhen
3441

3542
class RetryWebSocketsTest {
3643
@Test
@@ -101,7 +108,7 @@ class RetryWebSocketsTest {
101108
.serverUrl(mockServer.url())
102109
.build()
103110
)
104-
.addRetryOnErrorInterceptor { _, _ ->
111+
.retryWhen { _, _ ->
105112
true
106113
}
107114
.build()
@@ -258,7 +265,7 @@ class RetryWebSocketsTest {
258265
.build()
259266
)
260267
.serverUrl("https://unused.com/")
261-
.addRetryOnErrorInterceptor { _, _ ->
268+
.retryWhen { _, _ ->
262269
reopenCount++
263270
delay(500)
264271
true
@@ -325,4 +332,4 @@ class RetryWebSocketsTest {
325332
}
326333
}
327334
}
328-
}
335+
}

0 commit comments

Comments
 (0)