[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