softwaremill / tapir

Rapid development of self-documenting APIs

Home Page:https://tapir.softwaremill.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[BUG] ClosedChannelException when consuming stream response body with Fs2Streams

2m opened this issue · comments

Tapir version: 1.10.4

Scala version: 3.4.1

When consuming fs2 response stream produced by Http4sClientInterpreter, especially on larger streams, java.nio.channels.ClosedChannelException exception is thrown.

The same response is parsed without errors when using ember client directly.

Reproducer:

//> using dep org.http4s::http4s-ember-client::0.23.26
//> using dep com.softwaremill.sttp.tapir::tapir-http4s-client::1.10.4
//> using dep com.softwaremill.sttp.shared::fs2::1.3.17
//> using option -Wunused:imports

import java.nio.charset.StandardCharsets
import scala.util.Failure
import scala.util.Success

import cats.effect.ExitCode
import cats.effect.IO
import cats.effect.IOApp
import cats.syntax.all.*
import fs2.Stream
import fs2.text
import org.http4s.Method
import org.http4s.Request
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.implicits.{path as _, *}
import sttp.capabilities.fs2.Fs2Streams
import sttp.tapir.*
import sttp.tapir.client.http4s.Http4sClientInterpreter

val HttpBin = uri"https://httpbin.org"
val Lines = 20

def streamEndpoint =
  endpoint
    .in("stream")
    .in(path[Int])
    .out(streamTextBody(Fs2Streams[IO])(CodecFormat.Json(), Some(StandardCharsets.UTF_8)))

def streamRequest =
  Http4sClientInterpreter[IO]()
    .toRequestThrowDecodeFailures(streamEndpoint, Some(HttpBin))(Lines)

def tapirStream =
  for
    client <- Stream.resource(EmberClientBuilder.default[IO].build)
    (request, parseResponse) = streamRequest
    resp <- Stream.eval(client.run(request).use(parseResponse(_)))
    stream <- resp.right.get
  yield stream

def emberStream =
  for
    client <- Stream.resource(EmberClientBuilder.default[IO].build)
    request = Request[IO](Method.GET, HttpBin / "stream" / Lines)
    response <- client.stream(request)
    stream <- response.body
  yield stream

def runStream(name: String, stream: Stream[IO, Byte]) =
  stream
    .through(text.utf8.decode)
    .through(text.lines)
    .map(Success.apply)
    .handleErrorWith(t => Stream(Failure(t)))
    .compile
    .toList
    .map: result =>
      result.last match
        case Success(value) =>
          println(s"$name stream completed successfully with ${result.size} lines")
        case Failure(exception) =>
          println(s"$name stream parsed ${result.size - 1} lines and stopped with $exception")
          exception.printStackTrace()

object TestApp extends IOApp:
  def run(args: List[String]) =
    runStream("ember", emberStream) >> runStream("tapir", tapirStream) >>
      ExitCode.Success.pure[IO]

Reproducer output:

ember stream completed successfully with 21 lines
tapir stream parsed 2 lines and stopped with java.nio.channels.ClosedChannelException
java.nio.channels.ClosedChannelException
        at java.base/sun.nio.ch.AsynchronousSocketChannelImpl.read(AsynchronousSocketChannelImpl.java:234)
        at java.base/sun.nio.ch.AsynchronousSocketChannelImpl.read(AsynchronousSocketChannelImpl.java:298)
        at java.base/java.nio.channels.AsynchronousSocketChannel.read(AsynchronousSocketChannel.java:425)
        at fs2.io.net.SocketCompanionPlatform$AsyncSocket.readChunk$$anonfun$1(SocketPlatform.scala:118)
        at delay @ fs2.io.net.SocketCompanionPlatform$AsyncSocket.readChunk$$anonfun$1(SocketPlatform.scala:120)
        at async @ fs2.io.net.SocketCompanionPlatform$AsyncSocket.readChunk(SocketPlatform.scala:120)
        at flatMap @ fs2.io.net.SocketCompanionPlatform$BufferedReads.read$$anonfun$1(SocketPlatform.scala:84)
        at delay @ fs2.io.net.SocketCompanionPlatform$BufferedReads.withReadBuffer(SocketPlatform.scala:58)
        at flatMap @ fs2.Compiler$Target.flatMap(Compiler.scala:163)
        at getAndSet @ org.typelevel.keypool.KeyPool$.destroy(KeyPool.scala:120)
        at deferred @ fs2.internal.InterruptContext$.apply$$anonfun$1(InterruptContext.scala:114)

Looking into tapir code I realized that it does not do anything too different. Then I changed from client.run to client.stream (same as I do in my ember example) and the exception seems to be gone now. In hindsight, it makes sense now, as the depending on these two methods, the response lifecycle is controlled differently.

def tapirStream =
  for
    client <- Stream.resource(EmberClientBuilder.default[IO].build)
    (request, parseResponse) = streamRequest
    response <- client.stream(request)
    resp <- Stream.eval(parseResponse(response))
    stream <- resp.right.get
  yield stream