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]