Send a failure response when the REST response body cannot be serialized - #22840
Conversation
Signed-off-by: Venkateshwaran Shanmugham <venkateshwaran@cloudera.com> Signed-off-by: Venkateshwaran Shanmugham <venkateshwaracholan@gmail.com>
Signed-off-by: Venkateshwaran Shanmugham <venkateshwaracholan@gmail.com>
Covers the regression reported in opensearch-project#22311 at the HTTP layer: when resolving a response body fails inside the channel, the failure response used to be dropped because ResourceHandlingHttpChannel#close threw IllegalStateException on the second sendResponse, so the client never received a status line and waited until its proxy timed out. The test plugin completes asynchronously through RestResponseListener, matching the reported stack, and its response throws when the channel resolves the body - the last step before anything is written to the wire. Reverting the idempotent close() fix makes both tests fail on the REST client socket timeout, with the node logging "failed to send failure response". The second test asserts the in-flight-requests breaker reservation is released exactly once now that the failure response travels over an already-closed channel. Signed-off-by: Asim Mahmood <asim.seng@gmail.com>
PR Reviewer Guide 🔍(Review updated until commit 943fd78)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 943fd78
Previous suggestionsSuggestions up to commit 14aa92d
|
|
@bowenlan-amzn or @jainankitk Can I ask you to review? |
|
Persistent review updated to latest commit 943fd78 |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #22840 +/- ##
============================================
+ Coverage 71.58% 71.59% +0.01%
- Complexity 77353 77360 +7
============================================
Files 6170 6170
Lines 359700 359707 +7
Branches 52459 52459
============================================
+ Hits 257493 257535 +42
+ Misses 81808 81775 -33
+ Partials 20399 20397 -2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| // attempt to close once atomically | ||
| if (closed.compareAndSet(false, true) == false) { | ||
| throw new IllegalStateException("Channel is already closed"); | ||
| if (closed.compareAndSet(false, true)) { |
There was a problem hiding this comment.
Today one flag answers two different questions. First, did we release the breaker reservation? Second, did we already send a response? After a failed send those two answers differ. That difference is the bug in #22311.
This change keeps the first answer and drops the second. DefaultRestChannel#sendResponse holds no send-once guard of its own. So a handler that sends twice now writes two HTTP responses onto one connection. The new test testResourceHandlingChannelCloseIsIdempotent asserts responseCount == 2. That makes the double write the expected behaviour.
Can we use two flags instead of one? The existing flag guards the release only. A new responseSent flag guards the answer, and we set it after the delegate call returns. Then we allow a second send only when the first send failed. This keeps the fix for #22311. A handler that sends twice also still fails loudly.
| @@ -662,11 +662,9 @@ public void sendResponse(RestResponse response) { | |||
| } | |||
|
|
|||
| private void close() { | |||
There was a problem hiding this comment.
A naming nit, since this PR already rewrites the method. close() closes nothing here. It closes no socket, no stream, and no file. It only subtracts a number from a counter. The old message Channel is already closed was wrong twice over. The channel was not closed, and nothing closed it.
Can you rename the method to releaseRequestBytes() and the field to released? An accurate name would make this bug visible on sight.
| * Reproducer for opensearch-project/OpenSearch#22311 Bug A: {@link BytesRestResponse} maps overflow guard failures to | ||
| * HTTP 413 instead of surfacing a raw {@link ArithmeticException} from Lucene. | ||
| */ | ||
| public void testToBytesArrayMapsOverflowGuardFailureToRequestEntityTooLarge() { |
There was a problem hiding this comment.
This test never calls toBytesArray. It calls the guard and the exception factory separately. Then it asserts the factory returns 413, which is what the factory constructs by definition.
So the test still passes if someone unwires the guard from BytesRestResponse(RestStatus, String, String). That constructor is the line that matters. Can the test assert through the public constructor instead? The same change also covers the four lines codecov reports.
| expectThrows(ArithmeticException.class, () -> new BytesRef(text)); | ||
| } | ||
|
|
||
| public void testStringConstructorAcceptsMaxAllowedUTF16Length() { |
There was a problem hiding this comment.
This has the same shape as the BytesRestResponseTests case. The name says testStringConstructor, but the body calls the static guard. It never calls new BytesArray(String). So nothing here fails if the constructor stops calling the guard.
Please call the constructor with a string of the maximum allowed length. Or rename the test to match what it checks.
| assertTrue(guarded.getMessage().contains("UTF16 string length")); | ||
| assertTrue(guarded.getMessage().contains(String.valueOf(overflowingLength))); | ||
|
|
||
| expectThrows(ArithmeticException.class, () -> new BytesRef(text)); |
There was a problem hiding this comment.
This line asserts Lucene's internal behaviour, not ours. It already broke once on Java 25, in 354a74e4. Please drop it. The assertion on our own guard is the contract we control.
| * | ||
| * @param utf16Length UTF-16 length of the string to encode | ||
| */ | ||
| public static void ensureUTF16LengthIsValidForUTF8Encoding(int utf16Length) { |
There was a problem hiding this comment.
This method is public on a libs/core class. No caller exists outside BytesArray and its own tests. Private keeps the core API surface unchanged.
Summary
Adds HTTP-level integration test coverage for the dropped-failure-response regression in #22311, and carries the fix from #22356 so the tests are green.
Commits 1-3 are @venkateshwaracholan's fix from #22356, unchanged (authorship preserved). Commit 4 is the new integration test. Raised as a separate PR because the test needs the fix to pass; happy to have it folded into #22356 instead if maintainers prefer.
Fixes #22311.
Why an integration test
The unit tests in #22356 verify the new guard helper and that
close()no longer throws, but none of them exercise the customer-visible symptom: zero bytes flushed, no status line, connection held open until the load balancer times out. That symptom only appears once a real HTTP channel is involved:RestController.ResourceHandlingHttpChannel#sendResponsecloses the channel, then delegates.DefaultRestChannel#sendResponseresolvesRestResponse#content()as its last step before writing to the wire. If that throws, nothing is written.RestActionListener#onFailurethen tries to send the error over the same channel.close()threwIllegalStateException("Channel is already closed"), the error was swallowed and logged asfailed to send failure response, and the client got nothing.Step 3 only reaches that state when the failure happens in an asynchronous listener callback, after
prepareRequesthas returned —RestController.dispatchRequest's catch-all is no longer on the stack, soRestActionListeneris the only thing left that can answer the client. A unit test with a stubbed channel cannot observe this.What was added
qa/smoke-test-http, following the existingTestResponseHeaderPluginpattern:TestResponseFailurePlugin/TestResponseFailureRestAction— a handler that completes throughRestResponseListener(the same listener the search path uses, and the frame present in the reported stack) and returns a response whosecontent()throwsArithmeticException, mirroring what Lucene'sUnicodeUtil#maxUTF8Lengththrows on overflow. This reproduces the failure shape without allocating an oversized response, so the test is cheap and needs no special heap.RestResponseFailureIT#testFailureResponseIsSentWhenResponseBodyCannotBeSerialized— asserts the client receives HTTP 500 with anarithmetic_exceptionbody rather than a connection that never answers.RestResponseFailureIT#testInFlightRequestsBreakerIsReleasedWhenResponseBodyCannotBeSerialized— asserts the request's in-flight-requests breaker reservation is released exactly once, now that the failure response travels over an already-closed channel. Compared against a baseline reading rather than zero, because the transport layer shares that breaker and reserves bytes for the node stats request used to read it.Verification
Both tests pass on this branch:
Reverting only the
close()change inRestController(bothResourceHandlingHttpChannelandStreamHandlingHttpChannel) makes both tests fail, and the node logs the exact line from the field report:./gradlew :qa:smoke-test-http:precommitalso passes.Notes for reviewers
BytesArray(String), so it protects the plain-text/Stringresponse path. Responses built throughBytesRestResponse(RestStatus, XContentBuilder)never go through it, so a large JSON search response in a stock OpenSearch build does not hit this guard. That is why the integration test drives theStringpath through a plugin handler rather than through_search. A configurable maximum response size covering every path is the more complete fix and is worth a separate issue.close()is now a full no-op on repeat calls, which means a handler that sends twice will now write two responses instead of failing loudly. A narrower alternative is to trackresponseSentseparately fromclosedand only permit the second send when the first one failed. Happy to change it if that is preferred.Check List
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.