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 b0ef2b549f..b900a55480 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 42ff4bfe70..058276d492 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 a00caae9b8..3be65ac9be 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 61a2f4f05b..9266b28cd8 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 1d70de1c2c..c3f4e83c5f 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 22fd3c2ead..62417f0215 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 | |""") }