From befaab8f6320ba13bba1a75b18158aeaf2f1c821 Mon Sep 17 00:00:00 2001 From: Christian Rasmussen Date: Thu, 3 Sep 2026 15:31:31 +0200 Subject: [PATCH 1/3] Propagate cancellation for http4s backend Previously, cancelling a request using the http4s backend did not attempt to cancel the underlying request but instead returned instantly. This PR changes the behavior to cancel the underlying request fiber if the "outer" request is cancelled. Note that this means that where cancellation was essentially instant before, it now depends on however long it takes to cancel the request fiber. I would argue this fits better with cats-effect (cooperative cancellation and all), but it is a behavior change nonetheless. --- .../client4/http4s/Http4sBackendBase.scala | 10 ++++-- .../Http4sBackendCancellationTest.scala | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala 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..ca88ff320a 100644 --- a/http4s-backend/src/main/scala/sttp/client4/http4s/Http4sBackendBase.scala +++ b/http4s-backend/src/main/scala/sttp/client4/http4s/Http4sBackendBase.scala @@ -111,9 +111,13 @@ 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] + sendRequest.start.flatMap { fiber => + responseVar.get + .onCancel(fiber.cancel) + .flatMap { + case Left(t) => implicitly[cats.ApplicativeError[F, Throwable]].raiseError(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..6bdc2084d2 --- /dev/null +++ b/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala @@ -0,0 +1,36 @@ +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 { + cancelled <- Deferred[IO, Unit] + // A client whose request never completes, but records when it is cancelled + client = Client[IO] { _ => + IO.never[Http4sResponse[IO]] + .onCancel(cancelled.complete(()).void) + .toResource + } + backend = Http4sBackend.usingClient[IO](client) + req = basicRequest.get(uri"http://localhost/test").response(asString) + // Send the request, then cancel it after a short delay + _ <- req.send(backend).void.timeoutTo(50.millis, IO.unit) + // If the fiber was properly cancelled, onCancel will have signalled + _ <- cancelled.get.timeout(3.seconds) + } yield succeed + + test.unsafeToFuture() + } +} From 961b9f628b3da8d98d308668c4e767a1d713dc99 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 25 Sep 2026 07:42:42 +0000 Subject: [PATCH 2/3] Wait for the request to start before cancelling in the http4s cancellation test --- .../client4/http4s/Http4sBackendCancellationTest.scala | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala b/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala index 6bdc2084d2..3c4bd196fa 100644 --- a/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala +++ b/http4s-backend/src/test/scalajvm/sttp/client4/http4s/Http4sBackendCancellationTest.scala @@ -16,17 +16,19 @@ class Http4sBackendCancellationTest extends AsyncFlatSpec with Matchers { 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 is cancelled + // A client whose request never completes, but records when it starts and when it is cancelled client = Client[IO] { _ => - IO.never[Http4sResponse[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) - // Send the request, then cancel it after a short delay - _ <- req.send(backend).void.timeoutTo(50.millis, IO.unit) + 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 From d658bc97d600f54d76616f7e6e21dc1d208dbed6 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 25 Sep 2026 07:56:14 +0000 Subject: [PATCH 3/3] Make starting the request fiber and installing the cancel handler uncancelable in the http4s backend --- .../sttp/client4/http4s/Http4sBackendBase.scala | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 ca88ff320a..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,14 +111,15 @@ private[http4s] abstract class Http4sBackendBase[F[_]](implicit protected val as } .recoverWith { case t: Throwable => responseVar.complete(Left(t)).as(()) } - sendRequest.start.flatMap { fiber => - responseVar.get - .onCancel(fiber.cancel) - .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] + } } } }