Kotlin / kotlinx.serialization

Kotlin multiplatform / multi-format serialization
Apache License 2.0
5.44k stars 624 forks source link

Serialization hangs on trying to deserialize a sealed class family member #2363

Closed Nek-12 closed 1 year ago

Nek-12 commented 1 year ago

Describe the bug I'm trying to deserialize the response of the backend on 4xx code. I'm using this function to do the trick:

suspend inline fun <reified T> ApiError.parseOrNull() = ApiResult {
    // The type MUST be nullable https://youtrack.jetbrains.com/issue/KTOR-5577
    (cause as ClientRequestException).response.body<T?>()
}

And it tries to deserialize the following class:

@file:UseSerializers(UUIDSerializer::class)

@Serializable
sealed class FeatureError : RuntimeException() {

    @Serializable
    @SerialName("AlreadyJoined")
    data class AlreadyJoined(val id: UUID) : FeatureError() // <- this one fails, others work

    @Serializable
    @SerialName("NoPlacesLeft")
    data object NoPlacesLeft : FeatureError()

    @Serializable
    @SerialName("Ended")
    data object Ended : FeatureError()

    @Serializable
    @SerialName("HasNoAuthor")
    data object HasNoAuthor : FeatureError()

    @Serializable
    @SerialName("NotAnAuthor")
    data object NotAnAuthor : FeatureError()

    @Serializable
    @SerialName("NotAMember")
    data object NotAMember : FeatureError()

    @Serializable
    @SerialName("NotSynced")
    data object NotSynced : FeatureError()
}

When I'm using the function above, the body() function returns null for unknown reasons. The body isn't empty. This is what I'm getting in the json:

{
  "type": "AlreadyJoined",
  "message": "Bad Request",
  "status": 400,
  "id": "79e31a35-d55a-4662-bed1-5ffbc0a85b8b"
}

I tried replacing the code with the following:

val json = Json {
    ignoreUnknownKeys = true
}

suspend inline fun <reified T> ApiError.parseOrNull() = ApiResult {
    (cause as ClientRequestException).response.bodyAsText().let { json.decodeFromString<T>(it) }
}

In this case, the function never returns. It simply stays hang forever until the scope is cancelled. This happens only for that particular subclass of the sealed family.

Expected behavior Class deserializes correctly

Environment

sandwwraith commented 1 year ago

Does bodyAsText prints correct result with decodeFromString? There are no loops or blocking IO in serialization, so it is unlikely that it can just hang. Given that you're saying AlreadyJoined subclass is the one causing problems, I also need to know what UUIDSerializer is to reproduce the problem.

Nek-12 commented 1 year ago

Hello, I"m sorry for not providing the answer earlier. Forgot about this issue.

object UUIDSerializer : KSerializer<Uuid> { // typealias for java.util.UUID

    override val descriptor = PrimitiveSerialDescriptor("Uuid", PrimitiveKind.STRING)

    override fun deserialize(decoder: Decoder): Uuid = Uuid.fromString(decoder.decodeString())

    override fun serialize(encoder: Encoder, value: Uuid) = encoder.encodeString(value.toString())
}

It seems that calling bodyAsText hangs. I did some investigation, and it seems that

  1. The request is sent correctly
  2. The request is received and the body payload is valid
okhttp.OkHttpClient   I  <-- 400 https://<URL> (1678ms)
                      I  date: Mon, 14 Aug 2023 07:41:53 GMT
                      I  content-type: application/json; charset=UTF-8
                      I  content-length: 121
                      I  content-encoding: gzip
                      I  vary: Accept-Encoding
                      I  alt-svc: h3=":443"; ma=86400
                      I  {"type":"AlreadyJoined","message":"Bad Request","status":400,"id":"2d99621e-fce4-4442-a315-7609d7407db5"}
                      I  <-- END HTTP (105-byte, 121-gzipped-byte body)

// error is thrown and caught correctly

ApiResult             E  ApiResult error
                         io.ktor.client.plugins.ClientRequestException: Client request(GET <URL>) invalid: 400 . Text: "{"type":"AlreadyJoined","message":"Bad Request","status":400,"id":"2d99621e-fce4-4442-a315-7609d7407db5"}"

// bodyAsText entered

// never returns

Seems like this could be an issue with Ktor, but I'm not sure since if I remove the bodyAsText call the call does not hang but rather fails with TimeoutException

pdvrieze commented 1 year ago

@Nek-12 It appears that the connection/response is never closed so the parsing of the response waits for the input stream to end (or provide more data). You'll probably find that if you kill the server the connection will close (or time out) and the client "work"

Nek-12 commented 1 year ago

So this indeed looks like a bug in Ktor Client. I will create a similar issue in the Ktor Issue Tracker. Testing the endpoint with curl yields a normal response, so the issue is on the client side. Maybe this is related to trying to read the body twice, first with DefaultResponseValidation and then with my code.

https://youtrack.jetbrains.com/issues?q=project:%20Ktor&preview=EXPOSED-141

This issue may be closed if desired.

sandwwraith commented 1 year ago

So it's a Ktor issue with bodyAsText.