Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

finagle-http: Remove body (and Content-Length) from 1xx, 204 and 304 responses #521

5 changes: 5 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ Bug Fixes
had a bug when toggled on. That toggle is no longer used and is superseded by
`com.twitter.finagle.http.serverErrorsAsFailuresV2`. ``RB_ID=882151``

* finagle-http: To conform to the RFC, a message body is NO LONGER sent when 1xx, 204
and 304 responses are returned. Additionally, a Content-Length header field is NOT
sent for 1xx and 204 responses. Both rules are enforced even though users intentionally
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reads a little strange to me. Maybe ".. Both rules are enforced even if users intentionally add body data or the header field for these responses." Maybe add something about logging violations as an error.

add body data or the header field for these responses.

Deprecations
~~~~~~~~~~~~

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.twitter.finagle.http.codec

import com.twitter.finagle.{Service, SimpleFilter}
import com.twitter.finagle.http._
import com.twitter.finagle.http.{Fields, Method, Request, Response, Status}
import com.twitter.finagle.http.Status._
import com.twitter.logging.Logger
import com.twitter.util.Future

import org.jboss.netty.buffer.ChannelBuffers

/**
* Ensure that the `Response` is a legal response for the request that generated it
*
Expand Down Expand Up @@ -36,13 +39,57 @@ private[codec] object ResponseConformanceFilter extends SimpleFilter[Request, Re
private[this] def validate(req: Request, rep: Response): Unit = {
if (req.method == Method.Head) {
handleHeadResponse(req, rep)
} else if (mustNotIncludeMessageBody(rep.status)) {
handleNoMessageResponse(rep)
} else if (rep.isChunked) {
handleChunkedResponse(rep)
} else {
handleFullyBufferedResponse(rep)
}
}

/**
* 1. To conform to the RFC, a message body is removed if a status code is either 1xx, 204 or 304.
*
* RFC7230 section-3.3: (https://tools.ietf.org/html/rfc7230#section-3.3)
* "All 1xx (Informational), 204 (No Content), and 304 (Not Modified) responses do not include a message body."
*
* 2. Additionally, a Content-Length header field is dropped for 1xx and 204 responses as described in RFC7230
* section-3.3.2. It, however, is allowed to send a Content-Length header field in a 304 response. To follow
* the section, we don't remove the header field from a 304 response but its value is not checked nor corrected.
*
* RFC7230 section-3.3.2: (https://tools.ietf.org/html/rfc7230#section-3.3.2)
* "A server MUST NOT send a Content-Length header field in any response with a status code of 1xx (Informational)
* or 204 (No Content)."
*
* "A server MAY send a Content-Length header field in a 304 (Not Modified) response to a conditional GET request
* (Section 4.1 of [RFC7232]); a server MUST NOT send Content-Length in such a response unless its field-value equals
* the decimal number of octets that would have been sent in the payload body of a 200 (OK) response to the same
* request."
*/
private[this] def handleNoMessageResponse(rep: Response): Unit = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need a plan for dealing with chunked responses as well.

Copy link
Contributor Author

@monkey-mas monkey-mas Nov 1, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean we need a plan to conform to RFC7230#section-3.3.1 probably?
It says:

   A server MUST NOT send a Transfer-Encoding header field in any
   response with a status code of 1xx (Informational) or 204 (No
   Content).  A server MUST NOT send a Transfer-Encoding header field in
   any 2xx (Successful) response to a CONNECT request (Section 4.3.6 of
   [RFC7231]).

Do you think it's better for us to handle the issue in this PR or another PR?

Well, my first thought to solve the problem is like this:

private[this] def handleNoMessageResponse(rep: Response): Unit = {
  // remove message body if necessary

  // remove Content-Length and Transfer-Encoding if necessary
  if (rep.status != 304 && mustNotIncludeMessageBody(rep.status)) {
    if (rep.contentLength.isDefined) {
      rep.headerMap.remove(Fields.ContentLength)
      log.error(...)
    }
    if (rep.isChunked) {
      rep.headerMap.remove(Fields.TransferEncoding)
      log.error(...)
    }
  }
}

WDYT?

if (rep.getContent() != ChannelBuffers.EMPTY_BUFFER) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are trying to phase out usage of netty types in our HTTP model so could we do a length check instead? Something like if (rep.length > 0) { would be good. That has the added benefit of avoiding the ambiguity of equality in Java (eg, does != mean reference equality here? I don't know...).

rep.clearContent()
logger.error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ffti: might be useful to say how many bytes were in the body. Then again, maybe not. 😉

"Response with a status code of %d must not have a body-message thus the content has been removed.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols < 100 please

rep.statusCode)
}

rep.status match {
case Continue | SwitchingProtocols | Processing | NoContent if rep.contentLength.isDefined =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't cover the case of 1xx status codes that are not 100-Continue. Could we be inclusive of the whole range?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I think you mean we need to validate 100 <= status code < 200 for 1xx condition check, correct? I thought it'd be enough to check Continue, SwitchingProtocols and Processing but (if you meant it) yes I think it's reasonable :)

rep.headerMap.remove(Fields.ContentLength)
logger.error(
"Response with a status code of %d must not have a Content-Length header field thus the field has been removed.",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols < 100 please

rep.statusCode)
case _ =>
}
}

private def mustNotIncludeMessageBody(status: Status): Boolean = status match {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has the same 1xx problem as above.
Maybe this is worth it, maybe its not, but it seems to me that this pattern match could be reused with a special case for NotModified. Eg

private def mustNotHaveLengthHeader(status: Status) = 
  status != NotModified && mostNotIncludeMessageBody(status)

Maybe that is getting too convoluted, so use discretion with that suggestion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, thanks! I fixed the condition to check 1xx codes as well. WDYT?

case Continue | SwitchingProtocols | Processing | NoContent | NotModified => true
case _ => false
}

private[this] def handleFullyBufferedResponse(rep: Response): Unit = {
// Set the Content-Length header to the length of the body
// if it is not already defined. Examples of reasons that a service might
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.twitter.io.{Buf, Reader, Writer}
import com.twitter.util._
import java.io.{PrintWriter, StringWriter}
import java.net.InetSocketAddress
import org.jboss.netty.buffer.ChannelBuffers
import org.scalatest.{BeforeAndAfter, FunSuite}
import org.scalatest.concurrent.{Eventually, IntegrationPatience}
import scala.language.reflectiveCalls
Expand All @@ -36,6 +37,7 @@ abstract class AbstractEndToEndTest extends FunSuite
object Streaming extends Feature
object StreamFixed extends Feature
object TooLongStream extends Feature
object NoBodyMessage extends Feature

var saveBase: Dtab = Dtab.empty
val statsRecv: InMemoryStatsReceiver = new InMemoryStatsReceiver()
Expand Down Expand Up @@ -916,4 +918,138 @@ abstract class AbstractEndToEndTest extends FunSuite
await(server.close())
await(client.close())
}

testIfImplemented(NoBodyMessage)(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a preference for small tests, do you mind structuring this differently so that the test clause is inside of the loop? We can get around the duplicate names by string interpolating into the name of the test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you're right. It's nice to have small tests! Hope my modification is what you suggested :)

"response with status code {1xx, 204 and 304} must not have a message body nor Content-Length header field"
) {
def check(resStatus: Status): Unit = {
val svc = new Service[Request, Response] {
def apply(request: Request) = {
val response = Response(Version.Http11, resStatus)

Future.value(response)
}
}
val server = serverImpl()
.serve("localhost:*", svc)

val addr = server.boundAddress.asInstanceOf[InetSocketAddress]
val client = clientImpl()
.newService(s"${addr.getHostName}:${addr.getPort}", "client")

val res = await(client(Request(Method.Get, "/")))
assert(res.status == resStatus)
assert(!res.httpMessage.isChunked)
assert(res.httpMessage.getContent == ChannelBuffers.EMPTY_BUFFER)
assert(res.contentLength.isEmpty)
await(client.close())
await(server.close())
}

List(Status.Continue, /*Status.SwitchingProtocols,*/ Status.Processing, Status.NoContent, Status.NotModified).foreach {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please de-comment switching protocols

check(_)
}
}

testIfImplemented(NoBodyMessage)(
"response with status code {1xx, 204 and 304} must not have a message body nor Content-Length header field" +
"when non-empty body is returned"
) {
def check(resStatus: Status): Unit = {
val svc = new Service[Request, Response] {
def apply(request: Request) = {
val body = Buf.Utf8("some data")
val response = Response(Version.Http11, resStatus)
response.content = body

Future.value(response)
}
}
val server = serverImpl()
.serve("localhost:*", svc)

val addr = server.boundAddress.asInstanceOf[InetSocketAddress]
val client = clientImpl()
.newService(s"${addr.getHostName}:${addr.getPort}", "client")

val res = await(client(Request(Method.Get, "/")))
assert(res.status == resStatus)
assert(!res.httpMessage.isChunked)
assert(res.httpMessage.getContent == ChannelBuffers.EMPTY_BUFFER)
assert(res.contentLength.isEmpty)
await(client.close())
await(server.close())
}

List(Status.Continue, /*Status.SwitchingProtocols,*/ Status.Processing, Status.NoContent, Status.NotModified).foreach {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please de-comment SwitchingProtocols

check(_)
}
}

testIfImplemented(NoBodyMessage)(
"response with status code {1xx and 204} must not have a message body nor Content-Length header field" +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols <= 100 please

" when non-empty body with explicit Content-Length is returned"
) {
def check(resStatus: Status): Unit = {
val svc = new Service[Request, Response] {
def apply(request: Request) = {
val body = Buf.Utf8("some data")
val response = Response(Version.Http11, resStatus)
response.content = body
response.headerMap.set(Fields.ContentLength, body.length.toString)

Future.value(response)
}
}
val server = serverImpl()
.serve("localhost:*", svc)

val addr = server.boundAddress.asInstanceOf[InetSocketAddress]
val client = clientImpl()
.newService(s"${addr.getHostName}:${addr.getPort}", "client")

val res = await(client(Request(Method.Get, "/")))
assert(res.status == resStatus)
assert(!res.httpMessage.isChunked)
assert(res.httpMessage.getContent == ChannelBuffers.EMPTY_BUFFER)
assert(res.contentLength.isEmpty)
await(client.close())
await(server.close())
}

List(Status.Continue, /*Status.SwitchingProtocols,*/ Status.Processing, Status.NoContent).foreach {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please de-comment SwitchingProtocols

check(_)
}
}


testIfImplemented(NoBodyMessage)(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need the NoBodyMessage feature? or can we make this a regular test?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like I misunderstood the concept of Feature. I defined NoBodyMessage to pass some tests in finagle-http2 for example (by ignoring test cases?). What do you think I should've done here? 👀

"response with status code 304 must not have a message body *BUT* Content-Length header field " +
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols <= 100 please

"when non-empty body with explicit Content-Length is returned"
) {
val body = Buf.Utf8("some data")
val svc = new Service[Request, Response] {
def apply(request: Request) = {
val response = Response(Version.Http11, Status.NotModified)
response.content = body
response.headerMap.set(Fields.ContentLength, body.length.toString)

Future.value(response)
}
}
val server = serverImpl()
.serve("localhost:*", svc)

val addr = server.boundAddress.asInstanceOf[InetSocketAddress]
val client = clientImpl()
.newService(s"${addr.getHostName}:${addr.getPort}", "client")

val res = await(client(Request(Method.Get, "/")))
assert(res.status == Status.NotModified)
assert(!res.httpMessage.isChunked)
assert(res.httpMessage.getContent == ChannelBuffers.EMPTY_BUFFER)
assert(res.contentLength.contains(body.length.toLong))
await(client.close())
await(server.close())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ package com.twitter.finagle.http.codec

import com.twitter.conversions.time._
import com.twitter.finagle.Service
import com.twitter.finagle.http.{Fields, Method, Request, Response}
import com.twitter.finagle.http.{Fields, Method, Request, Response, Status, Version}
import com.twitter.finagle.http.Status._
import com.twitter.io.Buf
import com.twitter.io.Reader.ReaderDiscarded
import com.twitter.util.{Await, Future}
Expand Down Expand Up @@ -78,6 +79,69 @@ class ResponseConformanceFilterTest extends FunSuite {
intercept[ReaderDiscarded] { Await.result(res.writer.write(Buf.Empty), 5.seconds) }
}

test("response with status code {1xx, 204 and 304} must not have a message body nor Content-Length header field") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols <= 100 please

def validate(status: Status) = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have the same feedback as before about the structure of these tests.

val res = Response(Version.Http11, status)
val response = fetchResponse(res)

assert(response.status == status)
assert(response.getContent.readableBytes() == 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be response.length which is shorter and avoids netty types. A few more instances of this below.

assert(!response.isChunked)
assert(response.headers().get(Fields.ContentLength) == null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try to use .headerMap to remove netty types from the testing. It's also nice not to look for null in scala code. There are a few more instances of this below as well.

}

List(Continue, SwitchingProtocols, Processing, NoContent, NotModified).foreach(validate(_))
}

test("response with status code {1xx, 204 and 304} must not have a message body nor Content-Length header field when non-empty body is returned") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols <= 100 please

def validate(status: Status) = {
val body = Buf.Utf8("some data")
val res = Response(Version.Http11, status)
res.content = body

val response = fetchResponse(res)

assert(response.status == status)
assert(response.getContent().readableBytes() == 0)
assert(!response.isChunked)
assert(response.headers().get(Fields.ContentLength) == null)
}

List(Continue, SwitchingProtocols, Processing, NoContent, NotModified).foreach(validate(_))
}

test("response with status code {1xx and 204} must not have a message body nor Content-Length header field when non-empty body with explicit Content-Length is returned") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols <= 100 please

def validate(status: Status) = {
val body = Buf.Utf8("some data")
val res = Response(Version.Http11, status)
res.content = body
res.contentLength = body.length.toLong

val response = fetchResponse(res)

assert(response.status == status)
assert(response.getContent().readableBytes() == 0)
assert(!response.isChunked)
assert(response.headers().get(Fields.ContentLength) == null)
}

List(Continue, SwitchingProtocols, Processing, NoContent).foreach(validate(_))
}

test("response with status code 304 must not have a message body *BUT* Content-Length header field when non-empty body with explicit Content-Length is returned") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cols <= 100 please

val body = Buf.Utf8("some data")
val res = Response(Version.Http11, Status.NotModified)
res.content = body
res.contentLength = body.length.toLong

val response = fetchResponse(res)

assert(response.status == Status.NotModified)
assert(response.getContent().readableBytes() == 0)
assert(!response.isChunked)
assert(response.headers().get(Fields.ContentLength) == body.length.toString)
}

def fetchResponse(res: Response): Response = {
val request = Request(uri = "/")
runFilter(request, res)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class ClientFailUpgradeTest extends AbstractHttp1EndToEndTest {
TooLongStream,
Streaming,
CloseStream,
StreamFixed
StreamFixed,
NoBodyMessage
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my suspicion is that this fails for ClientFailUpgradeTest because the client starts having issued an upgrade message, so it's expecting a 101 in response. When it gets a 101, it expects upgrade headers, and netty will throw an exception if you don't have them. I don't know how the finagle client behaves in that scenario. Do the tests work if you remove the 101 test from ClientFailUpgradeTest? Could we restructure the tests to use "featureImplemented" to decide whether to include 101 or not instead of just turning them off for CFUT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your suspicion sounds reasonable to me. In that case, we might have timeout error or exception?

Do the tests work if you remove the 101 test from ClientFailUpgradeTest?

Yup, I think it works without 101 test. Currently, 101 test (finagle-http) also fails for EndToEndTest with the following error:
Some(0) was not empty (AbstractEndToEndTest.scala:949)
AbstractEndToEndTest.scala:949
It looks like Content-Length has the value of 0, which shouldn't happen like we can see from ResponseConformanceFilterTest...

@bryce-anderson What do you think about this?

Could we restructure the tests to use "featureImplemented" to decide whether to include 101 or not instead of just turning them off for CFUT?

Could you show me some sample code of how to use featureImplemented please? It seems like there's no code using it in finagle? 👀

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@monkey-mas: you make tests conditional like this:

testIfImplemented(FeatureName)("name"){ assert(true) }

and you set what tests are implemented using the functions like this one: https://github.com/twitter/finagle/blob/develop/finagle-http2/src/test/scala/com/twitter/finagle/http2/EndToEndTest.scala#L46.
I think you already know how to define a feature flag.

)

def featureImplemented(feature: Feature): Boolean = !unsupported.contains(feature)
Expand Down