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

Support empty buffers on Gzip decompression #139

Merged
merged 10 commits into from
Nov 8, 2023

Conversation

erawhctim
Copy link
Contributor

Fixes #138

Gzipped responses that return an empty message Buffer fail with an EOFException, halting the request chain.

The fix is relatively simple, but I'm not sure if this is actually a valid state connect-kotlin should support

@CLAassistant
Copy link

CLAassistant commented Oct 31, 2023

CLA assistant check
All committers have signed the CLA.

@erawhctim erawhctim force-pushed the mw/gzip-decompress-EOF-error branch 2 times, most recently from 3532c77 to c6e1c36 Compare October 31, 2023 21:38
setBody(Buffer())
setResponseCode(401)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This isn't quite representative of the original scenario we were seeing: the server was returning a non-empty response, but by the time it got here, the response message buffer was already empty.

Our hypothesis after stepping through the code a few times, was that parseConnectUnaryException() was consuming the message buffer before the decompress call had a change to consume it: the message was always non-empty when parseConnectUnaryException was called. We thought the root cause might be to decompress the response first, then attempt to parse it into an error message. I haven't been able to reproduce that exact scenario in this test, though; the actual root cause may still be TBD.

testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlin.coroutines.test)
testImplementation(project(":extensions:google-java"))
testImplementation(project(":conformance:google-java"))
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we want the okhttp module to depend on the conformance module - perhaps we just need a simple Eliza codegen in okhttp for tests so we have a service we can use.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, that makes sense. Would that be set up similarly to how the :examples:generated-google-java and :examples:generated-google-javalite modules are configured?

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 depending on :examples:google-java wouldn't be too bad and that way we wouldn't have to generate those files again.


@Test
fun `compressed empty failure response is parsed correctly`() = runTest {
val mockWebServer = MockWebServer()
Copy link
Contributor

Choose a reason for hiding this comment

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

We should wire up https://github.com/square/okhttp/tree/master/mockwebserver-junit4 (or even better migrate these tests to junit jupiter and use https://github.com/square/okhttp/tree/master/mockwebserver-junit5. This will make it easier over time to add more tests here without worrying about construction/start/shutdown being called in each.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea. Do you have a preference over JUnit 4 vs. 5?

Copy link
Contributor

Choose a reason for hiding this comment

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

My preference would be to use JUnit 5 for new tests but if that complicates things we can do a migration of all the tests in the future in a separate PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ended up using JUnit4. I think it makes sense to migrate everything over to JUnit Jupiter in a future PR.

)

val request = simpleRequest {}
TestServiceClient(protocolClient).unaryCall(request)
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't we want to assert something here about the returned call (that it has an appropriate connect status code)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yep, I can add some more detailed assertions here

@@ -9,6 +9,7 @@ kotlinpoet = "1.14.2"
mavenplugin = "0.25.3"
moshi = "1.15.0"
okhttp = "4.12.0"
okhttp-junit = "5.0.0-alpha.11"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

lemme know if you'd rather keep the okhttp versions the same (and depend on the older mockwebserver artifact)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah let's keep them in sync.

@erawhctim erawhctim force-pushed the mw/gzip-decompress-EOF-error branch 2 times, most recently from affef99 to 5e28851 Compare November 7, 2023 21:20
@@ -38,6 +38,8 @@ object GzipCompressionPool : CompressionPool {

override fun decompress(buffer: Buffer): Buffer {
val result = Buffer()
if (buffer.size == 0L) return result
Copy link
Contributor

Choose a reason for hiding this comment

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

This makes sense and is a good change (and we've amended the spec to account for it):

Servers must not attempt to decompress zero-length HTTP request content.

I think in this specific error case though, there was another issue. In

val (code, exception) = if (response.code != Code.OK) {
val error = parseConnectUnaryException(code = response.code, response.headers, response.message.buffer)
error.code to error
} else {
response.code to null
}
val message = compressionPool?.decompress(response.message.buffer) ?: response.message.buffer
, we're consuming the message body (if valid json) in parseConnectUnaryException, and then on line 104 we're again trying to consume the same message body a second time. If the code is not OK, we shouldn't be attempting to consume a response message and instead should just set message to an empty buffer.

assertThat(path).isEqualTo("/connectrpc.eliza.v1.ElizaService/Say")
}

assertThat(response.code).isEqualTo(Code.UNKNOWN)
Copy link
Contributor

Choose a reason for hiding this comment

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

Wrote a small connect-go example and verified that it gets the same result here. Thanks for adding these tests - they'll make it much easier to test different edge conditions outside of the conformance tests in the future.

@pkwarren pkwarren merged commit 8378c54 into connectrpc:main Nov 8, 2023
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

GzipCompressionPool decompress fails with EOFException on empty Buffer
3 participants