Repository: apollographql/apollo-kotlin
Pull request #5725 (2024-03-13 22:33:43)

[runtime] Add `failFastIfOffline`

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *apollo-android-docs* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 6a29e96b536455d9ec086f83964d85646214f96b |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/apollo-android-docs/deploys/65f2d2778f66cf0008f278a7 |

Patch:
diff --git a/libraries/apollo-api/api/apollo-api.api b/libraries/apollo-api/api/apollo-api.api
index 92eb014dc5..c8388028d5 100644
--- a/libraries/apollo-api/api/apollo-api.api
+++ b/libraries/apollo-api/api/apollo-api.api
@@ -46,7 +46,7 @@ public final class com/apollographql/apollo3/api/ApolloOptionalAdapter : com/apo
 }
 
 public final class com/apollographql/apollo3/api/ApolloRequest : com/apollographql/apollo3/api/ExecutionOptions {
-	public synthetic fun <init> (Lcom/apollographql/apollo3/api/Operation;Ljava/util/UUID;Lcom/apollographql/apollo3/api/ExecutionContext;Lcom/apollographql/apollo3/api/http/HttpMethod;Ljava/util/List;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public synthetic fun <init> (Lcom/apollographql/apollo3/api/Operation;Ljava/util/UUID;Lcom/apollographql/apollo3/api/ExecutionContext;Lcom/apollographql/apollo3/api/http/HttpMethod;Ljava/util/List;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Ljava/lang/Boolean;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public fun getCanBeBatched ()Ljava/lang/Boolean;
 	public fun getEnableAutoPersistedQueries ()Ljava/lang/Boolean;
 	public fun getExecutionContext ()Lcom/apollographql/apollo3/api/ExecutionContext;
diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/ApolloRequest.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/ApolloRequest.kt
index b30839423e..9560402696 100644
--- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/ApolloRequest.kt
+++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/ApolloRequest.kt
@@ -22,6 +22,8 @@ private constructor(
     override val canBeBatched: Boolean?,
     @ApolloExperimental
     val retryOnError: Boolean?,
+    @ApolloExperimental
+    val failFastIfOffline: Boolean?,
 ) : ExecutionOptions {
 
   fun newBuilder(): Builder<D> = newBuilder(operation)
@@ -38,6 +40,7 @@ private constructor(
         .enableAutoPersistedQueries(enableAutoPersistedQueries)
         .canBeBatched(canBeBatched)
         .retryOnError(retryOnError)
+        .failFastIfOffline(failFastIfOffline)
   }
 
   class Builder<D : Operation.Data>(
@@ -62,6 +65,14 @@ private constructor(
     @ApolloExperimental
     var retryOnError: Boolean? = null
       private set
+    @ApolloExperimental
+    var failFastIfOffline: Boolean? = null
+      private set
+
+    @ApolloExperimental
+    fun failFastIfOffline(failFastIfOffline: Boolean?): Builder<D> = apply {
+      this.failFastIfOffline = failFastIfOffline
+    }
 
     override fun httpMethod(httpMethod: HttpMethod?): Builder<D> = apply {
       this.httpMethod = httpMethod
@@ -119,7 +130,8 @@ private constructor(
           sendDocument = sendDocument,
           enableAutoPersistedQueries = enableAutoPersistedQueries,
           canBeBatched = canBeBatched,
-          retryOnError = retryOnError
+          retryOnError = retryOnError,
+          failFastIfOffline = failFastIfOffline,
       )
     }
   }
diff --git a/libraries/apollo-runtime/api/android/apollo-runtime.api b/libraries/apollo-runtime/api/android/apollo-runtime.api
index 999bbea858..938e0d5b42 100644
--- a/libraries/apollo-runtime/api/android/apollo-runtime.api
+++ b/libraries/apollo-runtime/api/android/apollo-runtime.api
@@ -10,6 +10,7 @@ public final class com/apollographql/apollo3/ApolloCall : com/apollographql/apol
 	public synthetic fun enableAutoPersistedQueries (Ljava/lang/Boolean;)Ljava/lang/Object;
 	public final fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun executeV3 (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun failFastIfOffline (Ljava/lang/Boolean;)Lcom/apollographql/apollo3/ApolloCall;
 	public fun getCanBeBatched ()Ljava/lang/Boolean;
 	public fun getEnableAutoPersistedQueries ()Ljava/lang/Boolean;
 	public fun getExecutionContext ()Lcom/apollographql/apollo3/api/ExecutionContext;
diff --git a/libraries/apollo-runtime/api/jvm/apollo-runtime.api b/libraries/apollo-runtime/api/jvm/apollo-runtime.api
index 2bd25c5847..3307c344a2 100644
--- a/libraries/apollo-runtime/api/jvm/apollo-runtime.api
+++ b/libraries/apollo-runtime/api/jvm/apollo-runtime.api
@@ -10,6 +10,7 @@ public final class com/apollographql/apollo3/ApolloCall : com/apollographql/apol
 	public synthetic fun enableAutoPersistedQueries (Ljava/lang/Boolean;)Ljava/lang/Object;
 	public final fun execute (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public final fun executeV3 (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	public final fun failFastIfOffline (Ljava/lang/Boolean;)Lcom/apollographql/apollo3/ApolloCall;
 	public fun getCanBeBatched ()Ljava/lang/Boolean;
 	public fun getEnableAutoPersistedQueries ()Ljava/lang/Boolean;
 	public fun getExecutionContext ()Lcom/apollographql/apollo3/api/ExecutionContext;
diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloCall.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloCall.kt
index 0f307ebaa3..89cf53ee18 100644
--- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloCall.kt
+++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloCall.kt
@@ -40,6 +40,9 @@ class ApolloCall<D : Operation.Data> internal constructor(
   @ApolloExperimental
   var retryOnError: Boolean? = null
     private set
+  @ApolloExperimental
+  var failFastIfOffline: Boolean? = null
+    private set
 
   /**
    * The HTTP headers to be sent with the request.
@@ -52,6 +55,10 @@ class ApolloCall<D : Operation.Data> internal constructor(
   var ignoreApolloClientHttpHeaders: Boolean? = null
     private set
 
+  fun failFastIfOffline(failFastIfOffline: Boolean?) = apply {
+    this.failFastIfOffline = failFastIfOffline
+  }
+
   override fun addExecutionContext(executionContext: ExecutionContext) = apply {
     this.executionContext = this.executionContext + executionContext
   }
@@ -119,6 +126,7 @@ class ApolloCall<D : Operation.Data> internal constructor(
         .enableAutoPersistedQueries(enableAutoPersistedQueries)
         .canBeBatched(canBeBatched)
         .retryOnError(retryOnError)
+        .failFastIfOffline(failFastIfOffline)
   }
 
   /**
@@ -166,6 +174,7 @@ class ApolloCall<D : Operation.Data> internal constructor(
         .enableAutoPersistedQueries(enableAutoPersistedQueries)
         .canBeBatched(canBeBatched)
         .retryOnError(retryOnError)
+        .failFastIfOffline(failFastIfOffline)
         .build()
   }
 
diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloClient.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloClient.kt
index 661a0e504e..bbcc51c2c2 100644
--- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloClient.kt
+++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/ApolloClient.kt
@@ -78,6 +78,7 @@ private constructor(
   private val networkMonitor: NetworkMonitor?
   private val retryOnError: ((ApolloRequest<*>) -> Boolean)? = builder.retryOnError
   private val retryOnErrorInterceptor: ApolloInterceptor
+  private val failFastIfOffline = builder.failFastIfOffline
 
   override val executionContext: ExecutionContext = builder.executionContext
   override val httpMethod: HttpMethod? = builder.httpMethod
@@ -287,6 +288,12 @@ private constructor(
             retryOnError = this@ApolloClient.retryOnError?.invoke(apolloRequest) ?: false
           }
           retryOnError(retryOnError)
+
+          var failFastIfOffline = apolloRequest.failFastIfOffline
+          if (failFastIfOffline == null) {
+            failFastIfOffline = this@ApolloClient.failFastIfOffline ?: false
+          }
+          failFastIfOffline(failFastIfOffline)
         }
         .build()
 
@@ -377,6 +384,21 @@ private constructor(
     @ApolloExperimental
     var retryOnError: ((ApolloRequest<*>) -> Boolean)? = null
       private set
+    @ApolloExperimental
+    var failFastIfOffline: Boolean? = null
+      private set
+
+    /**
+     * Whether to fail fast if the device is offline.
+     *
+     * In that case, the returned [ApolloResponse.exception] is an instance of [com.apollographql.apollo3.exception.ApolloNetworkException]
+     *
+     * @see NetworkMonitor
+     */
+    @ApolloExperimental
+    fun failFastIfOffline(failFastIfOffline: Boolean?): Builder = apply {
+      this.failFastIfOffline = failFastIfOffline
+    }
 
     /**
      * Configures the [NetworkMonitor] for this [ApolloClient]
@@ -848,6 +870,7 @@ private constructor(
           .retryOnError(retryOnError)
           .retryOnErrorInterceptor(retryOnErrorInterceptor)
           .networkMonitor(networkMonitor)
+          .failFastIfOffline(failFastIfOffline)
     }
   }
 
diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/interceptor/RetryOnErrorInterceptor.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/interceptor/RetryOnErrorInterceptor.kt
index 549f95caf2..7144a409c7 100644
--- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/interceptor/RetryOnErrorInterceptor.kt
+++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/interceptor/RetryOnErrorInterceptor.kt
@@ -4,11 +4,13 @@ import com.apollographql.apollo3.api.ApolloRequest
 import com.apollographql.apollo3.api.ApolloResponse
 import com.apollographql.apollo3.api.Operation
 import com.apollographql.apollo3.exception.ApolloException
+import com.apollographql.apollo3.exception.ApolloNetworkException
 import com.apollographql.apollo3.exception.SubscriptionOperationException
 import com.apollographql.apollo3.network.NetworkMonitor
 import com.benasher44.uuid.uuid4
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.emitAll
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.onEach
@@ -18,10 +20,6 @@ import kotlin.time.Duration.Companion.seconds
 
 internal class RetryOnErrorInterceptor(private val networkMonitor: NetworkMonitor?): ApolloInterceptor {
   override fun <D : Operation.Data> intercept(request: ApolloRequest<D>, chain: ApolloInterceptorChain): Flow<ApolloResponse<D>> {
-    if (request.retryOnError != true) {
-      return chain.proceed(request)
-    }
-
     var first = true
     var attempt = 0
     return flow {
@@ -31,9 +29,20 @@ internal class RetryOnErrorInterceptor(private val networkMonitor: NetworkMonito
       } else {
         request.newBuilder().requestUuid(uuid4()).build()
       }
+
+      if (request.failFastIfOffline == true && networkMonitor?.isOnline == false) {
+        throw OfflineException
+      }
+
       emitAll(chain.proceed(actualRequest))
+    }.catch {
+      if (it == OfflineException) {
+        emit(ApolloResponse.Builder(request.operation, request.requestUuid).exception(OfflineException).build())
+      } else {
+        throw it
+      }
     }.onEach {
-      if (it.exception != null && it.exception!!.isTerminalAndRecoverable()) {
+      if (request.retryOnError == true && it.exception != null && it.exception!!.isTerminalAndRecoverable()) {
         throw RetryException
       } else {
         attempt = 0
@@ -65,4 +74,5 @@ private fun ApolloException.isTerminalAndRecoverable(): Boolean {
   }
 }
 
-private object RetryException: Exception()
\ No newline at end of file
+private object RetryException: Exception()
+private val OfflineException = ApolloNetworkException("The device is offline")
\ No newline at end of file


Test patch:
diff --git a/libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorInterceptorTest.kt b/libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorTest.kt
similarity index 76%
rename from libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorInterceptorTest.kt
rename to libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorTest.kt
index a027d0af61..8bf81b4fad 100644
--- a/libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorInterceptorTest.kt
+++ b/libraries/apollo-runtime/src/commonTest/kotlin/test/network/NetworkMonitorTest.kt
@@ -4,6 +4,7 @@ import app.cash.turbine.test
 import com.apollographql.apollo3.api.ApolloRequest
 import com.apollographql.apollo3.api.ApolloResponse
 import com.apollographql.apollo3.api.Operation
+import com.apollographql.apollo3.exception.ApolloNetworkException
 import com.apollographql.apollo3.interceptor.ApolloInterceptor
 import com.apollographql.apollo3.interceptor.ApolloInterceptorChain
 import com.apollographql.apollo3.mockserver.assertNoRequest
@@ -21,8 +22,34 @@ import kotlinx.coroutines.flow.takeWhile
 import kotlin.test.Test
 import kotlin.test.assertEquals
 import kotlin.test.assertFails
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+class NetworkMonitorTest {
+  @Test
+  fun failFastIfOfflineTest(): ApolloTestResult {
+    val fakeNetworkMonitor = FakeNetworkMonitor()
+
+    return mockServerTest(clientBuilder = {
+      networkMonitor(fakeNetworkMonitor)
+      failFastIfOffline(true)
+    }) {
+
+      fakeNetworkMonitor._isOnline.value = false
+
+      apolloClient.query(FooQuery()).toFlow()
+          .test {
+            awaitItem().apply {
+              assertNull(data)
+              assertIs<ApolloNetworkException>(exception)
+              assertEquals("The device is offline", exception?.message)
+            }
+            mockServer.assertNoRequest()
+            awaitComplete()
+          }
+    }
+  }
 
-class NetworkMonitorInterceptorTest {
   @Test
   fun networkMonitorInterceptorTest(): ApolloTestResult {
     val fakeNetworkMonitor = FakeNetworkMonitor()