Repository: apollographql/apollo-kotlin
Pull request #4807 (2023-03-28 11:09:08)

Multipart subscriptions: send an error on critical errors
If we get an error payload, return an exception to the caller:

```
--graphql
content-type: application/json

{"errors":[{"message":"cannot read message from websocket","extensions":{"code":"WEBSOCKET_MESSAGE_ERROR"}}],"done":true}
```

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


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

Patch:
diff --git a/gradle/libraries.toml b/gradle/libraries.toml
index 9383182298..599e57660c 100644
--- a/gradle/libraries.toml
+++ b/gradle/libraries.toml
@@ -132,6 +132,7 @@ sqldelight-android = { group = "com.squareup.sqldelight", name = "android-driver
 sqldelight-jvm = { group = "com.squareup.sqldelight", name = "sqlite-driver", version.ref = "sqldelight" }
 sqldelight-native = { group = "com.squareup.sqldelight", name = "native-driver", version.ref = "sqldelight" }
 sqldelight-plugin = { group = "com.squareup.sqldelight", name = "gradle-plugin", version.ref = "sqldelight" }
+sqldelight-runtime = { group = "com.squareup.sqldelight", name = "runtime", version.ref = "sqldelight" }
 truth = { group = "com.google.truth", name = "truth", version.ref = "truth" }
 turbine = { group = "app.cash.turbine", name = "turbine", version = "0.7.0" }
 uuid = { group = "com.benasher44", name = "uuid", version = "0.3.1" }
diff --git a/libraries/apollo-api/build.gradle.kts b/libraries/apollo-api/build.gradle.kts
index 19feea8444..726354941d 100644
--- a/libraries/apollo-api/build.gradle.kts
+++ b/libraries/apollo-api/build.gradle.kts
@@ -17,6 +17,13 @@ kotlin {
         api(project(":apollo-annotations"))
       }
     }
+
+    findByName("commonTest")?.apply {
+      dependencies {
+        //implementation(golatac.lib("kotlin.test.junit"))
+        implementation(golatac.lib("kotlin.test"))
+      }
+    }
   }
 }
 
diff --git a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/http/internal/UrlEncode.kt b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/http/internal/UrlEncode.kt
index 7e43d2ad12..031d32e622 100644
--- a/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/http/internal/UrlEncode.kt
+++ b/libraries/apollo-api/src/commonMain/kotlin/com/apollographql/apollo3/api/http/internal/UrlEncode.kt
@@ -3,7 +3,7 @@ package com.apollographql.apollo3.api.http.internal
 import kotlin.native.concurrent.SharedImmutable
 
 @SharedImmutable
-private val RESERVED_CHARS = "!#\$&'\"()*+,/:;=?@[]{} "
+private val RESERVED_CHARS = "!#\$&'\"()*+,/:;=?@[]{}% "
 
 /**
  * A very simple urlEncode.
diff --git a/libraries/apollo-normalized-cache-sqlite-incubating/build.gradle.kts b/libraries/apollo-normalized-cache-sqlite-incubating/build.gradle.kts
index 15dbd2bcc3..196ab84702 100644
--- a/libraries/apollo-normalized-cache-sqlite-incubating/build.gradle.kts
+++ b/libraries/apollo-normalized-cache-sqlite-incubating/build.gradle.kts
@@ -44,6 +44,7 @@ kotlin {
         api(project(":apollo-api"))
         api(project(":apollo-normalized-cache-api-incubating"))
         api(project(":apollo-normalized-cache-incubating"))
+        api(golatac.lib("sqldelight.runtime"))
       }
     }
 
@@ -88,7 +89,7 @@ kotlin {
 
 configure<com.android.build.gradle.LibraryExtension> {
   compileSdk = golatac.version("android.sdkversion.compile").toInt()
-  namespace ="com.apollographql.apollo3.cache.normalized.sql"
+  namespace = "com.apollographql.apollo3.cache.normalized.sql"
 
   defaultConfig {
     minSdk = golatac.version("android.sdkversion.min").toInt()
diff --git a/libraries/apollo-normalized-cache-sqlite-incubating/src/androidMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt b/libraries/apollo-normalized-cache-sqlite-incubating/src/androidMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
index 419d3962cf..a271edbfae 100644
--- a/libraries/apollo-normalized-cache-sqlite-incubating/src/androidMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
+++ b/libraries/apollo-normalized-cache-sqlite-incubating/src/androidMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
@@ -10,7 +10,7 @@ import com.apollographql.apollo3.cache.normalized.sql.internal.getSchema
 import com.squareup.sqldelight.android.AndroidSqliteDriver
 import com.squareup.sqldelight.db.SqlDriver
 
-actual class SqlNormalizedCacheFactory internal constructor(
+actual class SqlNormalizedCacheFactory actual constructor(
     private val driver: SqlDriver,
     private val withDates: Boolean,
 ) : NormalizedCacheFactory() {
diff --git a/libraries/apollo-normalized-cache-sqlite-incubating/src/appleMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt b/libraries/apollo-normalized-cache-sqlite-incubating/src/appleMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
index 9b644b4a0c..4ef43abe61 100644
--- a/libraries/apollo-normalized-cache-sqlite-incubating/src/appleMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
+++ b/libraries/apollo-normalized-cache-sqlite-incubating/src/appleMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
@@ -7,7 +7,7 @@ import com.apollographql.apollo3.cache.normalized.sql.internal.getSchema
 import com.squareup.sqldelight.db.SqlDriver
 import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
 
-actual class SqlNormalizedCacheFactory internal constructor(
+actual class SqlNormalizedCacheFactory actual constructor(
     private val driver: SqlDriver,
     private val withDates: Boolean,
 ) : NormalizedCacheFactory() {
diff --git a/libraries/apollo-normalized-cache-sqlite-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt b/libraries/apollo-normalized-cache-sqlite-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
index a7c534f881..c02899cf30 100644
--- a/libraries/apollo-normalized-cache-sqlite-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
+++ b/libraries/apollo-normalized-cache-sqlite-incubating/src/commonMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactory.kt
@@ -1,6 +1,7 @@
 package com.apollographql.apollo3.cache.normalized.sql
 
 import com.apollographql.apollo3.cache.normalized.api.NormalizedCacheFactory
+import com.squareup.sqldelight.db.SqlDriver
 
 /**
  * Creates a new [NormalizedCacheFactory] that uses a persistent cache based on Sqlite
@@ -16,5 +17,7 @@ import com.apollographql.apollo3.cache.normalized.api.NormalizedCacheFactory
  * Once a database is created, this parameter cannot change
  * Default: false
  */
-expect class SqlNormalizedCacheFactory(name: String? = "apollo.db", withDates: Boolean = false) : NormalizedCacheFactory
+expect class SqlNormalizedCacheFactory(name: String? = "apollo.db", withDates: Boolean = false) : NormalizedCacheFactory {
+  constructor(driver: SqlDriver, withDates: Boolean = false)
+}
 
diff --git a/libraries/apollo-normalized-cache-sqlite-incubating/src/jvmMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactoryJvm.kt b/libraries/apollo-normalized-cache-sqlite-incubating/src/jvmMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactoryJvm.kt
index 4180cb23f2..0640a860f6 100644
--- a/libraries/apollo-normalized-cache-sqlite-incubating/src/jvmMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactoryJvm.kt
+++ b/libraries/apollo-normalized-cache-sqlite-incubating/src/jvmMain/kotlin/com/apollographql/apollo3/cache/normalized/sql/SqlNormalizedCacheFactoryJvm.kt
@@ -8,7 +8,7 @@ import com.squareup.sqldelight.db.SqlDriver
 import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
 import java.util.Properties
 
-actual class SqlNormalizedCacheFactory internal constructor(
+actual class SqlNormalizedCacheFactory actual constructor(
     private val driver: SqlDriver,
     private val withDates: Boolean,
 ) : NormalizedCacheFactory() {
diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/HttpNetworkTransport.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/HttpNetworkTransport.kt
index e33cc35e9e..ddf5e2adf0 100644
--- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/HttpNetworkTransport.kt
+++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/HttpNetworkTransport.kt
@@ -4,17 +4,20 @@ import com.apollographql.apollo3.api.ApolloRequest
 import com.apollographql.apollo3.api.ApolloResponse
 import com.apollographql.apollo3.api.CustomScalarAdapters
 import com.apollographql.apollo3.api.Operation
+import com.apollographql.apollo3.api.Subscription
 import com.apollographql.apollo3.api.http.DefaultHttpRequestComposer
 import com.apollographql.apollo3.api.http.HttpHeader
 import com.apollographql.apollo3.api.http.HttpRequest
 import com.apollographql.apollo3.api.http.HttpRequestComposer
 import com.apollographql.apollo3.api.http.HttpResponse
 import com.apollographql.apollo3.api.json.jsonReader
+import com.apollographql.apollo3.api.json.readAny
 import com.apollographql.apollo3.api.parseJsonResponse
 import com.apollographql.apollo3.api.withDeferredFragmentIds
 import com.apollographql.apollo3.exception.ApolloException
 import com.apollographql.apollo3.exception.ApolloHttpException
 import com.apollographql.apollo3.exception.ApolloParseException
+import com.apollographql.apollo3.exception.SubscriptionOperationException
 import com.apollographql.apollo3.internal.DeferredJsonMerger
 import com.apollographql.apollo3.internal.isMultipart
 import com.apollographql.apollo3.internal.multipartBodyFlow
@@ -23,6 +26,7 @@ import com.apollographql.apollo3.network.NetworkTransport
 import com.benasher44.uuid.Uuid
 import com.benasher44.uuid.uuid4
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.emitAll
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOf
@@ -156,63 +160,66 @@ private constructor(
 
     return multipartBodyFlow(httpResponse)
         .mapNotNull { part ->
-          try {
-            val what = part.peek().jsonReader().use { reader ->
+          if (operation is Subscription) {
+            val kind = part.peek().jsonReader().use { reader ->
               // detect what kind of payload we have
               reader.beginObject()
               if (!reader.hasNext()) {
-                return@use EMPTY
+                return@use Kind.EMPTY
               }
               val name = reader.nextName()
 
-              if (name == "payload") {
-                return@use PAYLOAD
-              } else {
-                return@use OTHER
+              return@use when (name) {
+                "payload" -> Kind.PAYLOAD
+                else -> Kind.OTHER
               }
             }
-
-            when (what) {
-              EMPTY -> {
-                // nothing to do
+            when (kind) {
+              Kind.EMPTY -> {
+                // nothing to do, maybe it's a ping
                 null
               }
 
-              PAYLOAD -> {
+              Kind.PAYLOAD -> {
                 val reader = part.jsonReader()
                 // advance the reader
                 reader.beginObject()
                 reader.nextName()
+
+                // Do nothing with "done", we just rely on the multipart closing boundary to close the stream gracefully
+                // TODO: make parseJsonResponse not close the jsonReader
                 operation.parseJsonResponse(
                     jsonReader = reader,
                     customScalarAdapters = customScalarAdapters
-                ).newBuilder().build()
+                )
               }
 
-              else -> {
-                if (jsonMerger == null) {
-                  jsonMerger = DeferredJsonMerger()
-                }
-                val merged = jsonMerger!!.merge(part)
-                val deferredFragmentIds = jsonMerger!!.mergedFragmentIds
-                val isLast = !jsonMerger!!.hasNext
-
-                if (jsonMerger!!.isEmptyPayload) {
-                  null
-                } else {
-                  operation.parseJsonResponse(
-                      jsonReader = merged.jsonReader(),
-                      customScalarAdapters = customScalarAdapters.withDeferredFragmentIds(deferredFragmentIds)
-                  ).newBuilder().isLast(isLast).build()
-                }
+              Kind.OTHER -> {
+                // We assume if there's something that is not a payload, it is an error
+                val response = part.jsonReader().readAny()
+                errorResponse(operation, SubscriptionOperationException(operation.name(), response))
               }
             }
-
-          } catch (e: Exception) {
-            errorResponse(operation, e)
+          } else {
+            if (jsonMerger == null) {
+              jsonMerger = DeferredJsonMerger()
+            }
+            val merged = jsonMerger!!.merge(part)
+            val deferredFragmentIds = jsonMerger!!.mergedFragmentIds
+            val isLast = !jsonMerger!!.hasNext
+
+            if (jsonMerger!!.isEmptyPayload) {
+              null
+            } else {
+              operation.parseJsonResponse(
+                  jsonReader = merged.jsonReader(),
+                  customScalarAdapters = customScalarAdapters.withDeferredFragmentIds(deferredFragmentIds)
+              ).newBuilder().isLast(isLast).build()
+            }
           }
+        }.catch {
+          emit(errorResponse(operation, it))
         }
-
   }
 
   private fun <D : Operation.Data> ApolloResponse<D>.withHttpInfo(
@@ -350,8 +357,10 @@ private constructor(
 
 
   private companion object {
-    private const val EMPTY = 0
-    private const val PAYLOAD = 1
-    private const val OTHER = 2
+    enum class Kind {
+      EMPTY,
+      PAYLOAD,
+      OTHER,
+    }
   }
 }
diff --git a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/LoggingInterceptor.kt b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/LoggingInterceptor.kt
index 0284842831..8a7fa19af1 100644
--- a/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/LoggingInterceptor.kt
+++ b/libraries/apollo-runtime/src/commonMain/kotlin/com/apollographql/apollo3/network/http/LoggingInterceptor.kt
@@ -1,17 +1,29 @@
 package com.apollographql.apollo3.network.http
 
 import com.apollographql.apollo3.api.http.ByteStringHttpBody
+import com.apollographql.apollo3.api.http.HttpBody
 import com.apollographql.apollo3.api.http.HttpRequest
 import com.apollographql.apollo3.api.http.HttpResponse
+import com.apollographql.apollo3.api.http.valueOf
 import com.apollographql.apollo3.network.http.LoggingInterceptor.Level
 import okio.Buffer
+import okio.BufferedSink
+import okio.BufferedSource
+import okio.Sink
+import okio.Source
+import okio.Timeout
+import okio.buffer
+import okio.use
 import kotlin.jvm.JvmOverloads
 
 /**
  * An interceptor that logs requests and responses.
  *
- * @param [level] the level of logging. Caution: when uploading files, setting this to [Level.BODY] will cause the files to
+ * @param level the level of logging. Caution: when uploading files, setting this to [Level.BODY] will cause the files to
  * be fully loaded into memory, which may cause OutOfMemoryErrors.
+ *
+ * @param log a callback that gets called with a line of log. Contains headers and/or body. No attempt is made at detecting
+ * binary bodies. Do use [Level.BODY] with binary content
  */
 class LoggingInterceptor(
     private val level: Level,
@@ -74,6 +86,35 @@ class LoggingInterceptor(
     BODY,
   }
 
+  private fun BufferedSource.intercept(): Source {
+    return object: Source {
+      private val buffer = Buffer()
+
+      override fun close() {
+        this@intercept.close()
+      }
+
+      override fun read(sink: Buffer, byteCount: Long): Long {
+        val tmp = Buffer()
+        val read = this@intercept.read(tmp, byteCount)
+        buffer.writeAll(tmp.peek())
+        while (true) {
+          val next = buffer.indexOf('\n'.code.toByte())
+          if (next == -1L) {
+            break
+          }
+          log(buffer.readUtf8Line()!!)
+        }
+        sink.writeAll(tmp)
+        return read
+      }
+
+      override fun timeout(): Timeout {
+        return Timeout.NONE
+      }
+    }
+  }
+
   override suspend fun intercept(
       request: HttpRequest,
       chain: HttpInterceptorChain,
@@ -122,11 +163,8 @@ class LoggingInterceptor(
     return if (!logBody || responseBody == null) {
       httpResponse
     } else {
-      val bodyByteString = responseBody.readByteString()
-      log(bodyByteString.utf8())
-      @Suppress("DEPRECATION")
       HttpResponse.Builder(statusCode = httpResponse.statusCode)
-          .body(bodyByteString)
+          .body(responseBody.intercept().buffer())
           .addHeaders(httpResponse.headers)
           .build()
     }


Test patch:
diff --git a/libraries/apollo-api/src/jvmTest/kotlin/UrlEncodeTest.kt b/libraries/apollo-api/src/jvmTest/kotlin/UrlEncodeTest.kt
new file mode 100644
index 0000000000..933b36b46e
--- /dev/null
+++ b/libraries/apollo-api/src/jvmTest/kotlin/UrlEncodeTest.kt
@@ -0,0 +1,18 @@
+import com.apollographql.apollo3.api.http.internal.urlEncode
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class UrlEncodeTest {
+
+  @Test
+  fun test() {
+    listOf(
+        "%" to "%25",
+        " " to "%20",
+        "{}" to "%7B%7D",
+        "e30=" to "e30%3D", // {} base64 encoded
+    ).forEach {
+      assertEquals(it.second, it.first.urlEncode())
+    }
+  }
+}
\ No newline at end of file
diff --git a/libraries/apollo-normalized-cache-sqlite-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/sql/OpenDbTest.kt b/libraries/apollo-normalized-cache-sqlite-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/sql/OpenDbTest.kt
index d3b82ab82d..188476a5b6 100644
--- a/libraries/apollo-normalized-cache-sqlite-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/sql/OpenDbTest.kt
+++ b/libraries/apollo-normalized-cache-sqlite-incubating/src/commonTest/kotlin/com/apollographql/apollo3/cache/normalized/sql/OpenDbTest.kt
@@ -1,8 +1,6 @@
 package com.apollographql.apollo3.cache.normalized.sql
 
-import com.apollographql.apollo3.mpp.Platform
 import com.apollographql.apollo3.testing.HostFileSystem
-import okio.FileSystem
 import okio.Path.Companion.toPath
 import kotlin.test.Test
 import kotlin.test.assertTrue
diff --git a/libraries/apollo-runtime/src/commonTest/kotlin/com/apollographql/apollo3/network/MultipartReaderTest.kt b/libraries/apollo-runtime/src/commonTest/kotlin/com/apollographql/apollo3/network/MultipartReaderTest.kt
new file mode 100644
index 0000000000..db468df496
--- /dev/null
+++ b/libraries/apollo-runtime/src/commonTest/kotlin/com/apollographql/apollo3/network/MultipartReaderTest.kt
@@ -0,0 +1,44 @@
+package com.apollographql.apollo3.network
+
+import com.apollographql.apollo3.internal.MultipartReader
+import okio.Buffer
+import okio.use
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class MultipartReaderTest {
+  private val response = buildString {
+    append("--graphql\r\n")
+    append("content-type: application/json\r\n")
+    append("\r\n")
+    append("{\"payload\":{\"data\":{\"aReviewWasAdded\":{\"id\":3,\"body\":\"A new review for Apollo Studio\"}}},\"done\":false}")
+
+    append("\r\n--graphql\r\n")
+    append("content-type: application/json\r\n")
+    append("\r\n")
+    append("{\"payload\":{\"data\":{\"aReviewWasAdded\":{\"id\":23,\"body\":\"A new review for Apollo Studio\"}}},\"done\":false}")
+
+    append("\r\n--graphql\r\n")
+    append("content-type: application/json\r\n")
+    append("\r\n")
+    append("{\"errors\":[{\"message\":\"cannot read message from websocket\",\"extensions\":{\"code\":\"WEBSOCKET_MESSAGE_ERROR\"}}],\"done\":true}")
+
+    append("\r\n--graphql--\r\n")
+  }
+
+  @Test
+  fun finishesGraceFully() {
+    var partCount = 0
+    MultipartReader(Buffer().writeUtf8(response), "graphql").use {
+      while (true) {
+        val part = it.nextPart() ?: break
+        //assertTrue(part.body.readUtf8().isNotEmpty())
+        println(part.body.readUtf8())
+        partCount++
+      }
+    }
+
+    assertEquals(3, partCount)
+  }
+}
\ No newline at end of file
diff --git a/tests/integration-tests/src/commonTest/kotlin/test/HttpGetTest.kt b/tests/integration-tests/src/commonTest/kotlin/test/HttpGetTest.kt
index ca2841f939..2c85537eac 100644
--- a/tests/integration-tests/src/commonTest/kotlin/test/HttpGetTest.kt
+++ b/tests/integration-tests/src/commonTest/kotlin/test/HttpGetTest.kt
@@ -2,15 +2,17 @@ package test
 
 import com.apollographql.apollo3.api.http.HttpMethod
 import com.apollographql.apollo3.integration.normalizer.HeroAndFriendsNamesQuery
+import com.apollographql.apollo3.integration.normalizer.SearchHeroQuery
 import com.apollographql.apollo3.integration.normalizer.type.Episode
 import com.apollographql.apollo3.mockserver.enqueue
+import com.apollographql.apollo3.testing.enqueueData
 import com.apollographql.apollo3.testing.mockServerTest
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
 class HttpGetTest {
   @Test
-  fun gzipTest() = mockServerTest {
+  fun getTest() = mockServerTest {
     mockServer.enqueue("""
       {
         "data": {
@@ -36,4 +38,16 @@ class HttpGetTest {
         mockServer.takeRequest().path
     )
   }
+  @Test
+  fun encodeReservedCharactersTest() = mockServerTest {
+    // Response not needed, just testing generated url
+    mockServer.enqueueData(data = emptyMap())
+    apolloClient.query(SearchHeroQuery("!#$&'()*+,/:;=?@[]{}% "))
+            .httpMethod(HttpMethod.Get)
+            .execute()
+    assertEquals(
+            "/?operationName=SearchHero&variables=%7B%22text%22%3A%22%21%23%24%26%27%28%29%2A%2B%2C%2F%3A%3B%3D%3F%40%5B%5D%7B%7D%25%20%22%7D&query=query%20SearchHero%28%24text%3A%20String%29%20%7B%20search%28text%3A%20%24text%29%20%7B%20__typename%20...%20on%20Character%20%7B%20__typename%20name%20...%20on%20Human%20%7B%20homePlanet%20%7D%20...%20on%20Droid%20%7B%20primaryFunction%20%7D%20%7D%20%7D%20%7D",
+            mockServer.takeRequest().path
+    )
+  }
 }
diff --git a/tests/integration-tests/src/main/graphql/com/apollographql/apollo3/integration/normalizer/SearchHero.graphql b/tests/integration-tests/src/main/graphql/com/apollographql/apollo3/integration/normalizer/SearchHero.graphql
new file mode 100644
index 0000000000..4106b95066
--- /dev/null
+++ b/tests/integration-tests/src/main/graphql/com/apollographql/apollo3/integration/normalizer/SearchHero.graphql
@@ -0,0 +1,15 @@
+query SearchHero($text: String) {
+    search(text: $text) {
+        __typename
+        ... on Character {
+            __typename
+            name
+            ... on Human {
+                homePlanet
+            }
+            ... on Droid {
+                primaryFunction
+            }
+        }
+    }
+}
diff --git a/tests/multipart/src/commonMain/graphql/router/operations.graphql b/tests/multipart/src/commonMain/graphql/router/operations.graphql
index c2099a2d8d..4c2bad6b89 100644
--- a/tests/multipart/src/commonMain/graphql/router/operations.graphql
+++ b/tests/multipart/src/commonMain/graphql/router/operations.graphql
@@ -6,4 +6,11 @@ subscription Die {
       color
     }
   }
+}
+
+subscription Review {
+  aReviewWasAdded {
+    id
+    body
+  }
 }
\ No newline at end of file
diff --git a/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsFakeTest.kt b/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsFakeTest.kt
index dcdc03e28f..770c900ef7 100644
--- a/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsFakeTest.kt
+++ b/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsFakeTest.kt
@@ -7,9 +7,15 @@ import com.apollographql.apollo3.network.http.HttpNetworkTransport
 import com.apollographql.apollo3.testing.internal.runTest
 import kotlinx.coroutines.flow.toList
 import multipart.CounterSubscription
+import kotlin.test.Ignore
 import kotlin.test.Test
 import kotlin.test.assertContentEquals
 
+/**
+ * This is a test from the early days of multipart subscriptions. It's not valid anymore but kept around
+ * as reference. If you come here and wonder what this is, now is probably a good time to remove it.
+ */
+@Ignore
 class MultipartSubscriptionsFakeTest {
   private lateinit var mockServer: MockServer
   private lateinit var apolloClient: ApolloClient
diff --git a/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsRouterTest.kt b/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsRouterTest.kt
index 916b474640..dc568abe45 100644
--- a/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsRouterTest.kt
+++ b/tests/multipart/src/commonTest/kotlin/test/MultipartSubscriptionsRouterTest.kt
@@ -1,47 +1,82 @@
 package test
 
 import com.apollographql.apollo3.ApolloClient
-import com.apollographql.apollo3.api.http.DefaultHttpRequestComposer
+import com.apollographql.apollo3.exception.ApolloException
+import com.apollographql.apollo3.exception.SubscriptionOperationException
 import com.apollographql.apollo3.network.http.HttpNetworkTransport
+import com.apollographql.apollo3.network.http.LoggingInterceptor
 import com.apollographql.apollo3.testing.internal.runTest
+import okio.use
 import router.DieSubscription
+import router.ReviewSubscription
 import kotlin.test.Ignore
 import kotlin.test.Test
+import kotlin.test.assertIs
 
 /**
  * A test that runs against https://github.com/apollographql/federated-subscriptions-poc
  */
 @Ignore
 class MultipartSubscriptionsRouterTest {
+  private fun client(log: Boolean) = ApolloClient.Builder()
+      .serverUrl("http://localhost:4040/")
+      .subscriptionNetworkTransport(
+          HttpNetworkTransport.Builder()
+              .apply {
+                if (log) {
+                  addInterceptor(
+                      LoggingInterceptor(LoggingInterceptor.Level.BODY) {
+                        println(it.replace("\r", "\\r").replace("\n", "\\n"))
+                      },
+                  )
+                }
+              }
+              .serverUrl("http://localhost:4040/")
+              .build()
+      )
+      .build()
+
   @Test
-  fun routerTest() = runTest {
-    val apolloClient = ApolloClient.Builder()
-        .serverUrl("http://localhost:4040/")
-        .subscriptionNetworkTransport(
-            HttpNetworkTransport.Builder()
-                .serverUrl("http://localhost:4040/")
-                .build()
-        )
-        .build()
+  fun dieTest() = runTest {
+    client(log = true).use { apolloClient ->
+      /**
+       * Should display something like this
+       *
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=3, sides=30, color=red)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=5, sides=31, color=blue)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=11, sides=32, color=red)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=12, sides=33, color=blue)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=21, sides=34, color=red)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=16, sides=35, color=blue)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=22, sides=36, color=red)))
+       * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=11, sides=37, color=blue)))
+       */
+      apolloClient.subscription(DieSubscription()).toFlow().collect {
+        if (it.data != null) {
+          println("${it.data}")
+        } else {
+          it.exception!!.printStackTrace()
+        }
+      }
+    }
+  }
 
-    /**
-     * Should display something like this
-     *
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=3, sides=30, color=red)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=5, sides=31, color=blue)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=11, sides=32, color=red)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=12, sides=33, color=blue)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=21, sides=34, color=red)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=16, sides=35, color=blue)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=22, sides=36, color=red)))
-     * Got Data(aNewDieWasCreated=ANewDieWasCreated(die=Die(roll=11, sides=37, color=blue)))
-     */
-    apolloClient.subscription(DieSubscription()).toFlow().collect {
-      if (it.data != null) {
-        println("${it.data}")
-      } else {
-        it.exception!!.printStackTrace()
+  /**
+   * Run docker compose kill reviews to trigger an exception
+   */
+  @Test
+  fun reviewTest() = runTest {
+    var exception: ApolloException? = null
+    client(log = true).use { apolloClient ->
+      apolloClient.subscription(ReviewSubscription()).toFlow().collect {
+        if (it.data != null) {
+          println("${it.data} isLast=${it.isLast}")
+        } else {
+          check(exception == null)
+          exception = it.exception
+        }
       }
     }
+    assertIs<SubscriptionOperationException>(exception)
   }
 }