-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
Changes from 2 commits
ddfffc9
b291e46
f9087ab
37a41f7
865e018
057ed65
1e8497e
2dfc051
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
* | ||
|
@@ -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 = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
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:
WDYT? |
||
if (rep.getContent() != ChannelBuffers.EMPTY_BUFFER) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
rep.clearContent() | ||
logger.error( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 => | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay, I think you mean we need to validate |
||
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.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has the same 1xx problem as above. private def mustNotHaveLengthHeader(status: Status) =
status != NotModified && mostNotIncludeMessageBody(status) Maybe that is getting too convoluted, so use discretion with that suggestion. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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() | ||
|
@@ -916,4 +918,138 @@ abstract class AbstractEndToEndTest extends FunSuite | |
await(server.close()) | ||
await(client.close()) | ||
} | ||
|
||
testIfImplemented(NoBodyMessage)( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" + | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please de-comment SwitchingProtocols |
||
check(_) | ||
} | ||
} | ||
|
||
|
||
testIfImplemented(NoBodyMessage)( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like I misunderstood the concept of |
||
"response with status code 304 must not have a message body *BUT* Content-Length header field " + | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
|
@@ -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} | ||
|
@@ -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") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cols <= 100 please |
||
def validate(status: Status) = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could be |
||
assert(!response.isChunked) | ||
assert(response.headers().get(Fields.ContentLength) == null) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Try to use |
||
} | ||
|
||
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") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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") { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -25,7 +25,8 @@ class ClientFailUpgradeTest extends AbstractHttp1EndToEndTest { | |
TooLongStream, | ||
Streaming, | ||
CloseStream, | ||
StreamFixed | ||
StreamFixed, | ||
NoBodyMessage | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Yup, I think it works without 101 test. Currently, 101 test (finagle-http) also fails for @bryce-anderson What do you think about this?
Could you show me some sample code of how to use There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
) | ||
|
||
def featureImplemented(feature: Feature): Boolean = !unsupported.contains(feature) | ||
|
There was a problem hiding this comment.
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.