fix: render Content-Length for HEAD responses with a declared length (#1237)
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
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 b0ef2b5..b900a55 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 @@
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 d869da4..bf9a8dd 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
@@ -252,8 +252,16 @@
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 d407986..128fbdd 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 @@
// 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 d39a108..9b71936 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 @@
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 @@
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 d2fa564..7009473 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 @@
|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)
}
@@ -206,6 +221,7 @@
|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)
}
@@ -714,7 +730,7 @@
|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 85e5e14..6d73f29 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
@@ -523,6 +523,7 @@
|Server: pekko-http/test
|Date: XXXX
|Content-Type: text/plain; charset=UTF-8
+ |Content-Length: 4
|
|""")
}
@@ -551,6 +552,7 @@
|Server: pekko-http/test
|Date: XXXX
|Content-Type: text/plain; charset=UTF-8
+ |Content-Length: 4
|
|""")
}