This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/main by this push:
     new 69aa0303e fix: close pool connection after an early response, abort 
the upload only if rejected (#1350)
69aa0303e is described below

commit 69aa0303eca72d7ac589e6557a7a8fe2c68304be
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Oct 23 17:58:18 2026 +0100

    fix: close pool connection after an early response, abort the upload only 
if rejected (#1350)
    
    * fix: close pool connection after an early response
    
    Motivation:
    When the response completed before the streamed request entity was fully
    sent, the pool slot went back to Idle once the request entity completed and
    reused the connection. The client cannot tell whether the server read the
    rest of the request entity, and a server that did not could misinterpret the
    remaining data as the next request. The Pekko HTTP server already closes the
    connection after an early response for the same reason.
    
    Modification:
    WaitingForEndOfRequestEntity now moves to ToBeClosed when the request entity
    completes or fails, instead of returning the slot to Idle.
    
    Result:
    A connection that saw an early response is not reused; the next request goes
    over a new connection.
    
    Tests:
    - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.client.*" - 
passed (179 succeeded, 0 failed)
    - New and updated SlotStateSpec and HostConnectionPoolSpec tests fail 
without the fix
    
    References:
    None - client pool hardening for early responses
    
    * fix: abort request entity when the pool sees an early response
    
    Motivation:
    After an early response (the response completed before the streamed request
    entity was fully sent), the pool kept sending the rest of the request entity
    and only closed the connection once it was done. A server that does not read
    the rest of the request entity could still misinterpret that data as a
    further request. HTTP/1.1 suggests that a client stop sending the body when
    the server indicates it does not want it.
    
    Modification:
    WaitingForEndOfResponseEntity moves straight to ToBeClosed when the response
    entity completes while the request entity is still being sent. The pool now
    wraps streamed request entities with a kill switch and aborts it when the
    connection is closed, so the rest of the request entity is not sent and the
    request is not framed as complete.
    
    Result:
    After an early response, the pool stops sending the request entity and 
closes
    the connection immediately instead of after the upload finishes.
    
    Tests:
    - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.client.*" - 
passed (178 succeeded, 0 failed)
    - Updated SlotStateSpec and HostConnectionPoolSpec tests fail without the 
change
    - sbt "http-core/mimaReportBinaryIssues" - passed
    
    References:
    Refs #1349
    
    * fix: only abort the request entity after an early rejecting response
    
    Motivation:
    Aborting the request entity after every early response truncates uploads to
    servers that answer early (e.g. 200 or 202) but keep reading the request
    entity. RFC 9112, section 9.3 only asks a client to stop sending when the
    response indicates that the server does not want the body.
    
    Modification:
    After an early response, the pool closes the connection right away and 
aborts
    the request entity only when the response is an error (4xx/5xx) or carries
    Connection: close. Otherwise, it finishes sending the request entity and 
then
    closes the connection without reusing it.
    
    Result:
    Uploads to servers that answer early with a success response complete as
    before; uploads rejected early are cancelled.
    
    Tests:
    - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.client.*" - 
passed (182 succeeded, 0 failed)
    - New success-response tests in SlotStateSpec and HostConnectionPoolSpec 
fail without the change
    - sbt "http-core/mimaReportBinaryIssues" - passed
    
    References:
    Refs #1350
---
 .../engine/client/pool/NewHostConnectionPool.scala | 21 ++++++--
 .../http/impl/engine/client/pool/SlotState.scala   | 38 ++++++++++----
 .../engine/client/HostConnectionPoolSpec.scala     | 60 +++++++++++++++++++++
 .../impl/engine/client/pool/SlotStateSpec.scala    | 61 ++++++++++++++--------
 4 files changed, 145 insertions(+), 35 deletions(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
index eda9dff81..0eb3266aa 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
@@ -457,6 +457,7 @@ private[client] object NewHostConnectionPool {
             responseIn: SubSinkInlet[HttpResponse]) extends InHandler with 
OutHandler { connection =>
           var ongoingResponseEntity: Option[HttpEntity] = None
           var ongoingResponseEntityKillSwitch: Option[KillSwitch] = None
+          var ongoingRequestEntityKillSwitch: Option[KillSwitch] = None
           var connectionEstablished: Boolean = false
 
           /** Will only be executed if this connection is still the current 
connection for its slot */
@@ -468,10 +469,16 @@ private[client] object NewHostConnectionPool {
               request.entity match {
                 case _: HttpEntity.Strict => request
                 case e                    =>
-                  val (newEntity, entityComplete) = 
HttpEntity.captureTermination(request.entity)
-                  entityComplete.onComplete(safely {
-                    case Success(_)     => 
withSlot(_.onRequestEntityCompleted())
-                    case Failure(cause) => 
withSlot(_.onRequestEntityFailed(cause))
+                  val (newEntity, StreamUtils.StreamControl(_, entityComplete, 
entityKillSwitch)) =
+                    StreamUtils.transformEntityStream(request.entity,
+                      StreamUtils.CaptureMaterializationAndTerminationOp)
+                  ongoingRequestEntityKillSwitch = entityKillSwitch
+                  entityComplete.onComplete(safely { (res: Try[Unit]) =>
+                    ongoingRequestEntityKillSwitch = None
+                    res match {
+                      case Success(_)     => 
withSlot(_.onRequestEntityCompleted())
+                      case Failure(cause) => 
withSlot(_.onRequestEntityFailed(cause))
+                    }
                   })(ExecutionContext.parasitic)
                   request.withEntity(newEntity)
               }
@@ -497,6 +504,12 @@ private[client] object NewHostConnectionPool {
               failure.getOrElse(new IllegalStateException("Connection was 
closed while response was still in-flight"))
             
ongoingResponseEntity.foreach(_.dataBytes.runWith(Sink.cancelled)(subFusingMaterializer))
             ongoingResponseEntityKillSwitch.foreach(_.abort(exception))
+            // Abort rather than complete a request entity that is still being 
sent, so that the remaining data is
+            // never sent and the request is not framed as complete.
+            ongoingRequestEntityKillSwitch.foreach(_.abort(failure.getOrElse(
+              new IllegalStateException("Connection was closed while request 
entity was still being sent")
+                with NoStackTrace)))
+            ongoingRequestEntityKillSwitch = None
           }
           def isClosed: Boolean = requestOut.isClosed || responseIn.isClosed
 
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/SlotState.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/SlotState.scala
index 0f45cec2c..0af14351d 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/SlotState.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/SlotState.scala
@@ -21,7 +21,7 @@ import pekko.annotation.InternalApi
 import pekko.http.impl.engine.client.PoolFlow.RequestContext
 import pekko.http.impl.util._
 import pekko.http.scaladsl.Http
-import pekko.http.scaladsl.model.HttpResponse
+import pekko.http.scaladsl.model.{ headers, HttpResponse }
 import pekko.http.scaladsl.settings.ConnectionPoolSettings
 import pekko.macros.LogHelper
 
@@ -108,6 +108,15 @@ private[pool] sealed abstract class SlotState extends 
Product {
  */
 @InternalApi
 private[pool] object SlotState {
+
+  /**
+   * Whether an early response indicates that the server does not want the 
rest of the request entity: an error
+   * response or one that closes the connection. Not 
`SlotContext.willCloseAfter`, which also covers the client-side
+   * max-connection-lifetime.
+   */
+  private def serverRejectsRequestEntity(response: HttpResponse): Boolean =
+    response.status.isFailure || 
response.header[headers.Connection].exists(_.hasClose)
+
   sealed private[pool] /* to avoid warnings */ abstract class ConnectedState 
extends SlotState {
     def isConnected: Boolean = true
 
@@ -346,9 +355,16 @@ private[pool] object SlotState {
       waitingForEndOfRequestEntity: Boolean) extends ConnectedState with 
BusyWithResultAlreadyDetermined {
 
     override def onResponseEntityCompleted(ctx: SlotContext): SlotState =
-      if (waitingForEndOfRequestEntity)
-        WaitingForEndOfRequestEntity
-      else if (ctx.willCloseAfter(ongoingResponse) || ctx.isConnectionClosed)
+      if (waitingForEndOfRequestEntity) {
+        // The server sent an "early" response before the request entity was 
fully sent. We cannot know whether the
+        // server will read the rest of the request entity, and a server that 
does not could misinterpret the remaining
+        // data as the next request, so the connection is never reused.
+        // If the response indicates that the server does not want the rest of 
the request entity, close the
+        // connection right away, which also aborts sending the rest of the 
request entity (RFC 9112, section 9.3).
+        // Otherwise, the server may still be reading the request entity, so 
finish sending it before closing.
+        if (serverRejectsRequestEntity(ongoingResponse) || 
ctx.isConnectionClosed) ToBeClosed
+        else WaitingForEndOfRequestEntity
+      } else if (ctx.willCloseAfter(ongoingResponse) || ctx.isConnectionClosed)
         ToBeClosed // when would ctx.isConnectionClose be true? what that mean 
that the connection has already failed before? do we need that state at all?
       else
         idle(ctx)
@@ -358,15 +374,17 @@ private[pool] object SlotState {
       WaitingForEndOfResponseEntity(ongoingRequest, ongoingResponse, 
waitingForEndOfRequestEntity = false)
     }
   }
+
+  /**
+   * An early response was received while the request entity was still being 
sent, or the connection failed while the
+   * request entity was still being sent and the request has already been 
dispatched for retry. The connection is not
+   * reused after the request entity completes.
+   */
   case object WaitingForEndOfRequestEntity extends ConnectedState {
     final override def isIdle = false
 
-    override def onRequestEntityCompleted(ctx: SlotContext): SlotState =
-      if (ctx.isConnectionClosed) ToBeClosed
-      else idle(ctx)
-    override def onRequestEntityFailed(ctx: SlotContext, cause: Throwable): 
SlotState =
-      if (ctx.isConnectionClosed) ToBeClosed // ignore error here
-      else idle(ctx)
+    override def onRequestEntityCompleted(ctx: SlotContext): SlotState = 
ToBeClosed
+    override def onRequestEntityFailed(ctx: SlotContext, cause: Throwable): 
SlotState = ToBeClosed // ignore error here
     override def onConnectionCompleted(ctx: SlotContext): SlotState = 
ToBeClosed
     override def onConnectionFailed(ctx: SlotContext, cause: Throwable): 
SlotState = Failed(cause)
   }
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
index 9b71936aa..3ba91081b 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/HostConnectionPoolSpec.scala
@@ -172,6 +172,66 @@ class HostConnectionPoolSpec extends 
PekkoSpecWithMaterializer(
           conn2.pushResponse()
           expectResponse()
         })
+      if (clientServerImplementation == PassThrough) {
+        // the Pekko HTTP server engines close the connection themselves after 
an early response,
+        // so only PassThrough shows whether the pool would reuse the 
connection
+        "finish the request entity and not reuse the connection after an early 
successful response".inWithShutdown(
+          new SetupWithServerProbes {
+            val reqEntityProbe = pushChunkedRequest()
+            responseOut.request(1)
+
+            val conn1 = expectNextConnection()
+            val reqInProbe = conn1.expectChunkedRequestBytesAsProbe()
+
+            reqEntityProbe.sendNext(ByteString("test"))
+            reqInProbe.expectUtf8EncodedString("test")
+
+            // the server responds before the request entity has been sent 
completely
+            conn1.pushResponse(HttpResponse(entity = "early"))
+            expectResponseEntityAsString() shouldEqual "early"
+
+            // the server may still be reading, so the rest of the request 
entity is sent
+            reqEntityProbe.sendNext(ByteString("test2"))
+            reqInProbe.expectUtf8EncodedString("test2")
+            reqEntityProbe.sendComplete()
+            reqInProbe.request(1)
+            reqInProbe.expectComplete()
+
+            // the next request must not be sent over the connection that saw 
the early response
+            pushRequest(HttpRequest(uri = "/next"))
+            val conn2 = expectNextConnection()
+            conn2.expectRequestToPath("/next")
+            conn2.pushResponse(HttpResponse())
+            expectResponse()
+          })
+        "abort the request entity and not reuse the connection after an early 
error response".inWithShutdown(
+          new SetupWithServerProbes {
+            val reqEntityProbe = pushChunkedRequest()
+            responseOut.request(1)
+
+            val conn1 = expectNextConnection()
+            val reqInProbe = conn1.expectChunkedRequestBytesAsProbe()
+
+            reqEntityProbe.sendNext(ByteString("test"))
+            reqInProbe.expectUtf8EncodedString("test")
+
+            // the server rejects the request before the request entity has 
been sent completely
+            conn1.pushResponse(HttpResponse(StatusCodes.PayloadTooLarge, 
entity = "too large"))
+            expectResponseEntityAsString() shouldEqual "too large"
+
+            // the rest of the request entity is not sent
+            reqEntityProbe.expectCancellation()
+            reqInProbe.request(1)
+            reqInProbe.expectError()
+
+            // the next request must not be sent over the connection that saw 
the early response
+            pushRequest(HttpRequest(uri = "/next"))
+            val conn2 = expectNextConnection()
+            conn2.expectRequestToPath("/next")
+            conn2.pushResponse(HttpResponse())
+            expectResponse()
+          })
+      }
       "open up to max-connections when enough requests are 
pending".inWithShutdown(
         new SetupWithServerProbes(_.withMaxConnections(2)) {
           pushRequest(HttpRequest(uri = "/1"))
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/SlotStateSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/SlotStateSpec.scala
index 65c250c80..ae9f76569 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/SlotStateSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/SlotStateSpec.scala
@@ -22,7 +22,7 @@ import pekko.http.impl.engine.client.pool.SlotState._
 import pekko.http.scaladsl.Http
 import pekko.http.scaladsl.model.ContentTypes
 import pekko.http.scaladsl.model.HttpEntity
-import pekko.http.scaladsl.model.{ headers, HttpRequest, HttpResponse }
+import pekko.http.scaladsl.model.{ headers, HttpRequest, HttpResponse, 
StatusCodes }
 import pekko.http.scaladsl.settings.ConnectionPoolSettings
 import pekko.stream.scaladsl.Source
 import pekko.testkit.PekkoSpec
@@ -137,32 +137,51 @@ class SlotStateSpec extends PekkoSpec {
       state should be(Idle(Duration.Inf))
     }
 
-    "consider a slot 'idle' only when the request has been successfully sent" 
in {
-      var state: SlotState = Unconnected
+    "finish sending the request and then close the connection after an early 
successful response" in {
       val context = new MockSlotContext(system.log)
-      state = context.expectOpenConnection {
-        state.onPreConnect(context)
-      }
-      state = state.onNewRequest(context, TheRequestContext)
+      var state = earlyResponseCompleted(context, HttpResponse())
+      // The response was completed before the request was sent completely (an 
"early" response). The server may
+      // still be reading the request entity, so it is sent completely, but 
the connection is not reused.
+      state should be(WaitingForEndOfRequestEntity)
 
-      state = state.onConnectionAttemptSucceeded(context, outgoingConnection)
+      state = state.onRequestEntityCompleted(context)
+      state should be(ToBeClosed)
+    }
 
-      state should be(PushingRequestToConnection(TheRequestContext))
-      state = state.onRequestDispatched(context)
+    "close the connection when the request entity fails after an early 
successful response" in {
+      val context = new MockSlotContext(system.log)
+      var state = earlyResponseCompleted(context, HttpResponse())
+      state should be(WaitingForEndOfRequestEntity)
 
-      state = state.onResponseReceived(context, HttpResponse())
-      state = state.onResponseDispatchable(context)
-      state = state.onResponseEntitySubscribed(context)
-      state = state.onResponseEntityCompleted(context)
-      // We don't want this slot to become idle yet, because consuming the 
request might still take
-      // a while, in which case putting this request on another connection 
would be lower latency.
-      // In theory we could get higher throughput by allowing requests on busy 
connections, but
-      // there are also situations where it would have an adverse effect, so 
better to be safe.
-      state.isIdle should be(false)
+      state = state.onRequestEntityFailed(context, new 
RuntimeException("request entity failed"))
+      state should be(ToBeClosed)
+    }
 
-      state = state.onRequestEntityCompleted(context)
-      state.isIdle should be(true)
+    "close the connection right away after an early error response" in {
+      earlyResponseCompleted(new MockSlotContext(system.log), 
HttpResponse(StatusCodes.PayloadTooLarge)) should
+      be(ToBeClosed)
+    }
+
+    "close the connection right away after an early response that closes the 
connection" in {
+      earlyResponseCompleted(new MockSlotContext(system.log),
+        HttpResponse(headers = headers.Connection("close") :: Nil)) should 
be(ToBeClosed)
+    }
+  }
+
+  /** Runs a slot through a streamed request whose response entity completes 
before the request entity */
+  private def earlyResponseCompleted(context: MockSlotContext, response: 
HttpResponse): SlotState = {
+    var state: SlotState = Unconnected
+    state = context.expectOpenConnection {
+      state.onPreConnect(context)
     }
+    state = state.onNewRequest(context, TheRequestContext)
+    state = state.onConnectionAttemptSucceeded(context, outgoingConnection)
+    state = state.onRequestDispatched(context)
+
+    state = state.onResponseReceived(context, response)
+    state = state.onResponseDispatchable(context)
+    state = state.onResponseEntitySubscribed(context)
+    state.onResponseEntityCompleted(context)
   }
 
   class MockSlotContext(


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to