diff --git a/http4s-backend/src/main/scala/sttp/client4/http4s/Http4sBackendBase.scala b/http4s-backend/src/main/scala/sttp/client4/http4s/Http4sBackendBase.scala index 5d302da95b..6c37dcb3da 100644 --- a/http4s-backend/src/main/scala/sttp/client4/http4s/Http4sBackendBase.scala +++ b/http4s-backend/src/main/scala/sttp/client4/http4s/Http4sBackendBase.scala @@ -111,10 +111,15 @@ private[http4s] abstract class Http4sBackendBase[F[_]](implicit protected val as } .recoverWith { case t: Throwable => responseVar.complete(Left(t)).as(()) } - sendRequest.start >> responseVar.get.flatMap { - case Left(t) => implicitly[cats.ApplicativeError[F, Throwable]].raiseError(t) - case Right(r) => r.pure[F] - } + // uncancelable, so that a cancel between starting the fiber and installing onCancel doesn't leak the fiber + asyncF + .uncancelable { poll => + sendRequest.start.flatMap(fiber => poll(responseVar.get).onCancel(fiber.cancel)) + } + .flatMap { + case Left(t) => asyncF.raiseError[Response[T]](t) + case Right(r) => r.pure[F] + } } } } diff --git a/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala b/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala new file mode 100644 index 0000000000..3c4bd196fa --- /dev/null +++ b/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala @@ -0,0 +1,38 @@ +package sttp.client4.http4s + +import cats.effect.{Deferred, IO} +import cats.effect.unsafe.IORuntime +import org.http4s.{Response => Http4sResponse} +import org.http4s.client.Client +import org.scalatest.flatspec.AsyncFlatSpec +import org.scalatest.matchers.should.Matchers +import sttp.client4._ + +import scala.concurrent.duration._ + +class Http4sBackendCancellationTest extends AsyncFlatSpec with Matchers { + + implicit val ioRuntime: IORuntime = IORuntime.global + + it should "cancel the underlying request fiber when the caller cancels" in { + val test = for { + started <- Deferred[IO, Unit] + cancelled <- Deferred[IO, Unit] + // A client whose request never completes, but records when it starts and when it is cancelled + client = Client[IO] { _ => + (started.complete(()) >> IO.never[Http4sResponse[IO]]) + .onCancel(cancelled.complete(()).void) + .toResource + } + backend = Http4sBackend.usingClient[IO](client) + req = basicRequest.get(uri"http://localhost/test").response(asString) + fiber <- req.send(backend).start + _ <- started.get.timeout(3.seconds) + _ <- fiber.cancel + // If the fiber was properly cancelled, onCancel will have signalled + _ <- cancelled.get.timeout(3.seconds) + } yield succeed + + test.unsafeToFuture() + } +}