From befaab8f6320ba13bba1a75b18158aeaf2f1c821 Mon Sep 17 00:00:00 2001 From: Christian Rasmussen Date: Thu, 3 Sep 2026 15:31:31 +0200 Subject: [PATCH] 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() + } +}