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

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


The following commit(s) were added to refs/heads/1.4.x by this push:
     new 0965fd0b1 fix: render Content-Length for HEAD responses with a 
declared length (#1237) (#1244)
0965fd0b1 is described below

commit 0965fd0b181b6a9fcd93419caf1fc9dcb3938b88
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 1 11:34:39 2026 +0100

    fix: render Content-Length for HEAD responses with a declared length 
(#1237) (#1244)
    
    Motivation:
    PR #962 corrected Content-Length rendering for 205 and CONNECT, but it also
    made HEAD responses drop the header unconditionally. RFC 9110 section 8.6
    allows a server to send Content-Length in a HEAD response, and RFC 9112
    section 6.3 rule 1 exempts HEAD responses from body framing, so the header 
is
    metadata there and cannot desync a connection. Suppressing it breaks the
    common case of using HEAD to learn the size of a resource without fetching 
it,
    and it is asymmetric: our own client parser honours a HEAD Content-Length, 
and
    the HTTP/2 renderer never consulted contentLengthAllowed at all.
    
    Modification:
    Restore the pre-1.4 predicate for HEAD (contentLengthAllowedForHead now
    defers to StatusCode.allowsEntity) and add the length-based policy at render
    time: a HEAD response renders Content-Length only when the entity declares a
    length greater than zero. A zero length nearly always means the application
    had no body to hand over rather than that the resource is empty, and the
    client parser already only honours a HEAD Content-Length when it is greater
    than zero. Chunked and CloseDelimited entities have no known length and are
    unchanged. 205, 204, 304 and CONNECT behaviour from #962 is untouched.
    
    Result:
    A HEAD response completed with HttpEntity.Strict or
    HttpEntity.Default(contentType, length, Source.empty) renders the declared
    Content-Length again, while an empty entity renders none.
    
    Tests:
    - sbt "http-core / Test / testOnly 
org.apache.pekko.http.impl.engine.rendering.ResponseRendererSpec 
org.apache.pekko.http.impl.engine.server.HttpServerSpec 
org.apache.pekko.http.scaladsl.model.HttpMethodsSpec" - 110 passed
    - sbt "http-core / Test / testOnly 
org.apache.pekko.http.impl.engine.client.HostConnectionPoolSpec" - 66 passed
    - sbt +mimaReportBinaryIssues - success
    - scalafmt --mode diff-ref=upstream/main - clean
    - git diff --check - clean
    - sbt "http-core / test" and sbt "docs / paradox" - not run to completion 
locally, left to CI
    
    References:
    Fixes #1236, Refs #962
---
 .../routing-dsl/directives/method-directives/head.md   |  8 ++++++++
 .../engine/rendering/HttpResponseRendererFactory.scala | 10 +++++++++-
 .../apache/pekko/http/scaladsl/model/HttpMethod.scala  |  6 ++++--
 .../impl/engine/client/HostConnectionPoolSpec.scala    |  4 ++--
 .../impl/engine/rendering/ResponseRendererSpec.scala   | 18 +++++++++++++++++-
 .../pekko/http/impl/engine/server/HttpServerSpec.scala |  2 ++
 6 files changed, 42 insertions(+), 6 deletions(-)

diff --git 
a/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md 
b/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md
index b0ef2b549..b900a5548 100644
--- a/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md
+++ b/docs/src/main/paradox/routing-dsl/directives/method-directives/head.md
@@ -23,6 +23,14 @@ stripping off the result body. See the 
`pekko.http.server.transparent-head-reque
 this behavior.
 @@@
 
+@@@ note
+The response body is stripped off, but the `Content-Length` header is still 
rendered when the entity declares a
+non-zero length, so that clients can learn the size of the resource without 
fetching it. Entities without a known
+length (`Chunked`, `CloseDelimited`) and empty entities render no 
`Content-Length`; if you want to answer a HEAD
+request with the size of the hypothetical GET response without producing the 
bytes, complete with
+`HttpEntity.Default(contentType, length, Source.empty)`.
+@@@
+
 ## Example
 
 Scala
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
index 42ff4bfe7..058276d49 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/rendering/HttpResponseRendererFactory.scala
@@ -254,8 +254,16 @@ private[http] class HttpResponseRendererFactory(
               r ~~ `Transfer-Encoding` ~~ ChunkedBytes ~~ CrLf
           }
 
+          // RFC 9112 section 6.3 rule 1 exempts responses to HEAD requests 
from body framing, so a Content-Length
+          // is pure metadata there and cannot desync the connection. Only 
render a length the application actually
+          // declared: a zero length nearly always means that there was no 
body to hand over rather than that the
+          // resource is empty, and our own client only honours a HEAD 
Content-Length when it is greater than zero
+          // (see HttpResponseParser).
           def renderContentLengthHeader(contentLength: Long) =
-            if (ctx.requestMethod.contentLengthAllowed(status)) r ~~ 
ContentLengthBytes ~~ contentLength ~~ CrLf else r
+            if (ctx.requestMethod.contentLengthAllowed(status) &&
+              (contentLength > 0 || ctx.requestMethod != HttpMethods.HEAD))
+              r ~~ ContentLengthBytes ~~ contentLength ~~ CrLf
+            else r
 
           def headersAndEntity(entityBytes: => Source[ByteString, Any]): 
StrictOrStreamed =
             if (noEntity) {
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
index a00caae9b..3be65ac9b 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/scaladsl/model/HttpMethod.scala
@@ -95,8 +95,10 @@ object HttpMethods extends ObjectRegistry[String, 
HttpMethod] {
   // for CONNECT it is explicitly not allowed in the 2xx (Successful) range
   private def contentLengthAllowedForConnect(forStatus: StatusCode): Boolean = 
forStatus.intValue < 200 ||
     forStatus.intValue >= 300
-  // for HEAD it is technically allowed, but must match the content-length of 
hypothetical GET request, so can not be anticipated
-  private def contentLengthAllowedForHead(forStatus: StatusCode): Boolean = 
false
+  // for HEAD it is allowed (RFC 9110 section 8.6) and should match the 
content-length of the hypothetical GET
+  // request; the renderer additionally suppresses a zero length, which 
usually means that the application had no
+  // body to hand over rather than that the resource is empty
+  private def contentLengthAllowedForHead(forStatus: StatusCode): Boolean = 
forStatus.allowsEntity
   // for other methods there are common rules:
   // - for 1xx (Informational) or 204 (No Content) it is explicitly not allowed
   // - for 304 (Not Modified) it must match the content-length of hypothetical 
200-accepted request, so can not be anticipated
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 61a2f4f05..9266b28cd 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
@@ -223,7 +223,7 @@ class HostConnectionPoolSpec extends 
PekkoSpecWithMaterializer(
             conn1.pushResponse(HttpResponse(entity = 
HttpEntity.Default(ContentTypes.`application/octet-stream`, 100,
               Source.empty)))
             val res = expectResponse()
-            res.entity.contentLengthOption.get shouldEqual 0
+            res.entity.contentLengthOption.get shouldEqual 100
 
             // HEAD requests do not require to consume entity
 
@@ -242,7 +242,7 @@ class HostConnectionPoolSpec extends 
PekkoSpecWithMaterializer(
             conn1.pushResponse(HttpResponse(entity = 
HttpEntity.Default(ContentTypes.`application/octet-stream`, 100,
               Source.empty)))
             val res = expectResponse()
-            res.entity.contentLengthOption.get shouldEqual 0
+            res.entity.contentLengthOption.get shouldEqual 100
 
             // HEAD requests do not require consumption of entity but users 
might do anyway
             res.entity.discardBytes()
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
index 1d70de1c2..c3f4e83c5 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/rendering/ResponseRendererSpec.scala
@@ -155,6 +155,21 @@ class ResponseRendererSpec extends AnyFreeSpec with 
Matchers with BeforeAndAfter
               |Server: pekko-http/1.0.0
               |Date: Thu, 25 Aug 2011 09:10:29 GMT
               |Content-Type: text/plain; charset=UTF-8
+              |Content-Length: 23
+              |
+              |""", close = false)
+      }
+
+      "to a transparent HEAD request (empty Strict response entity)" in new 
TestSetup() {
+        ResponseRenderingContext(
+          requestMethod = HttpMethods.HEAD,
+          response = HttpResponse(
+            headers = List(Age(30), Connection("Keep-Alive")),
+            entity = HttpEntity.Empty)) should renderTo(
+          """HTTP/1.1 200 OK
+              |Age: 30
+              |Server: pekko-http/1.0.0
+              |Date: Thu, 25 Aug 2011 09:10:29 GMT
               |
               |""", close = false)
       }
@@ -205,6 +220,7 @@ class ResponseRendererSpec extends AnyFreeSpec with 
Matchers with BeforeAndAfter
               |Server: pekko-http/1.0.0
               |Date: Thu, 25 Aug 2011 09:10:29 GMT
               |Content-Type: text/plain; charset=UTF-8
+              |Content-Length: 100
               |
               |""", close = false)
       }
@@ -713,7 +729,7 @@ class ResponseRendererSpec extends AnyFreeSpec with 
Matchers with BeforeAndAfter
                  |Server: pekko-http/1.0.0
                  |Date: Thu, 25 Aug 2011 09:10:29 GMT
                  |${renCH.fold("")(_.toString + "\n")}Content-Type: 
text/plain; charset=UTF-8
-                 |${if (headReq || resCD) "" else "Content-Length: 6\n"}
+                 |${if (resCD) "" else "Content-Length: 6\n"}
                  |${if (headReq) "" else "ENTITY"}""", close))
     }
   }
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
index 22fd3c2ea..62417f021 100644
--- 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/server/HttpServerSpec.scala
@@ -517,6 +517,7 @@ class HttpServerSpec extends PekkoSpec(
                |Server: pekko-http/test
                |Date: XXXX
                |Content-Type: text/plain; charset=UTF-8
+               |Content-Length: 4
                |
                |""")
         }
@@ -544,6 +545,7 @@ class HttpServerSpec extends PekkoSpec(
                |Server: pekko-http/test
                |Date: XXXX
                |Content-Type: text/plain; charset=UTF-8
+               |Content-Length: 4
                |
                |""")
         }


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

Reply via email to