Netty: respond with 408 when the request timeout fires before the body is fully received - #5466
Conversation
|
Is there a related issue? |
| override def channelRead(ctx: ChannelHandlerContext, msg: Any): Unit = { | ||
| msg match { | ||
| case _: LastHttpContent => ctx.channel().attr(RequestBodyCompletedTracker.BodyComplete).set(true) | ||
| case _: HttpRequest => ctx.channel().attr(RequestBodyCompletedTracker.BodyComplete).set(false) |
There was a problem hiding this comment.
are we using the explicit-false case anywhere? Maybe we should just use a () as a marker? So that we have two states: RequestBodyCompletedTracker.BodyComplete is either set, or not. Now we have three (true, false, unset), with overlapping semantics
There was a problem hiding this comment.
I understand the incentive to use only double, not the triple values.
I would stay with a given boolean version though. If we would change this to Unit type and try to make it work we would be forced to use deprecated Attribute.remove() api
I've tested this with the java doc suggestion of set(null) instead but the fix stopped working at this point.
I personally prefer to pay a fee of additional non set state than to use deprecated api
There was a problem hiding this comment.
But is it at all possible to first observe LastHttpContent and then a HttpRequest? Or would it be a bug?
There was a problem hiding this comment.
It would be a bug since even headers generate HttpRequest
Even option should generate those before LastHttpContent
adamw
left a comment
There was a problem hiding this comment.
Automated Claude code review. 10 inline comments: 3 confirmed correctness issues, 1 likely ordering problem, plus smaller cleanups.
| override def channelRead(ctx: ChannelHandlerContext, msg: Any): Unit = { | ||
| msg match { | ||
| case _: LastHttpContent => ctx.channel().attr(RequestBodyCompletedTracker.BodyComplete).set(true) | ||
| case _: HttpRequest => ctx.channel().attr(RequestBodyCompletedTracker.BodyComplete).set(false) |
There was a problem hiding this comment.
🤖 automated Claude review
The flag is per-channel, but the timeout is per-request. A keep-alive client can fully send request 1 (flag = true), pipeline request 2's headers — this line resets the flag to false, since the tracker sits before HttpStreamsServerHandler and sees them immediately — and then stall. Request 1's reader-idle then reads false and aborts a valid, fully-received request with a 400. With a chunked/streaming response for request 1 this is a new failure: before this PR the exchange completed.
There was a problem hiding this comment.
I'm not sure multiple requests are allowed in http apart from websockets.
2 requests and than response starts?
| def runRoute(req: HttpRequest, releaseReq: () => Any = () => ()): Unit = { | ||
| val requestTimeoutHandler = config.requestTimeout.map { requestTimeout => | ||
| new IdleStateHandler(0, requestTimeout.toMillis.toInt, 0, TimeUnit.MILLISECONDS) | ||
| new IdleStateHandler(requestTimeout.toMillis.toInt, requestTimeout.toMillis.toInt, 0, TimeUnit.MILLISECONDS) |
There was a problem hiding this comment.
🤖 automated Claude review
toMillis.toInt overflows for timeouts above ~24.8 days; a negative value disables IdleStateHandler entirely, silently dropping both the 503 and the new 400. The constructor accepts long, so .toInt can just be dropped. (Pre-existing in the writer slot; this change copies it into the reader slot.)
There was a problem hiding this comment.
I don't think that's relevant for this PR, it also applies for other timeouts
Yes, here #4169 I've adjusted description accordingly |
1eb9e0e to
b89805a
Compare
|
🤖 automated Claude review — re-review after the latest changes. Replies marked as addressed, where the code says otherwise:
Open discussions:
New findings:
|
cdd9a83 to
546d06e
Compare
Adjusted, I've fixed faulty setting in my intellij
I've rephrased the log message
Reintroduced
I've introduced socket closing as a resource code
Those were left open on purpose
This will spawn a new PR
Changed return status
I've removed
I've introduced socket closing as a resource code
I've introduced this approach |
|
🤖 automated Claude review — re-review of Everything else from the previous round looks addressed: scalafmt is clean now (checked with scalafmt 3.11.5 on all 4 files), the unified Main finding: the 408 only happens when the whole partial request arrives in one readI built the branch and probed it with
Suggested fix, which is also simpler than what's in the PR: drop the reader-idle timer (go back to if (e.state() == IdleState.WRITER_IDLE) {
if (wasRequestBodyFullyReceived(ctx)) {
logger.error(...)
writeErrorThenClose(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE)
} else {
logger.debug(...)
writeErrorThenClose(ctx, HttpResponseStatus.REQUEST_TIMEOUT)
}
}I applied exactly this to the branch and re-ran the probe: both stalled-upload shapes give 408, and a complete request with a 2s handler still gives 503. As a bonus, with a single timer the "408 followed by a 503 on the same connection" problem goes away on its own, without having to remove the handler. The test should then write the head and the body fragment separately — that is the case that is currently broken. Other points
|
|
🤖 automated Claude review — final review of The design is now right: one writer-idle timer, with the status picked by the body-completion flag. I checked out the branch and ran it:
Everything actionable from the previous rounds is addressed: Nothing blocking. Four things to decide on before merge. 1. Test duplicationThe two new tests are ~45 near-identical lines each, differing only in whether the body fragment goes out in the same Worth knowing while doing that: the two cases may not actually differ. The decoder emits 2. The flag is per connection, the timeout is per requestKnown and accepted, but worth one sentence in the tracker scaladoc so the next reader doesn't assume otherwise: on a keep-alive connection the headers of request N+1 can be decoded while request N is still being handled, which flips the flag to 3. Writer-idle can fire more than once
4. DocsCorrect, but long for what they say. Concretely:
The same explanation currently appears in five places ( Smaller things
Unrelated, pre-existingRequests with |
3ec5f65 to
54f9722
Compare
|
🤖 automated Claude review — final review of Checked out the branch and ran it: Three things about the tests, then one thing to consider. 1. The second test can flake
Fix: read the first response before writing the second head, and drop the sleep. 2. The pause in the first test does nothingThe comment says the pause "makes the fragment arrive as its own read". There is no aggregator in the pipeline, so the head and the fragment are decoded into separate 3. Neither new test pins the flag resetThis was on the list from the previous round ("nothing pins the per-request reset of the flag"). I deleted To pin it, the second request on the connection has to be a complete request with a slow handler, expecting 503. To consider: install the tracker from
|
…dTracker be placed
… double notification
- document RequestBodyCompletionTracker's absent-tracker fallback and its initial state; move the companion below the class it describes - make both request-timeout log messages name the same timeout and state the status they send - qualify the tracker reference in NettyConfig's scaladoc, and explain the requestTimeout guard and the Web Socket removal - assert on the exact sequence of response status lines, pin the test charset, and widen the timing margins for slow CI - extract the socket-level test data & helpers into TimingOutRequestSpecData Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54f9722 to
fbb3b64
Compare
I've adjusted test to fix those issues |
Partially addresses #4169.
When the request timeout fires, the Netty server now responds with:
408 Request Timeoutif the request body wasn't fully received yet503 Service Unavailable, as before, if the body was received but no response was produced in timeNot fixed (the main problem from #4169): when the connection closes before the whole body arrives (after the timeout, or when the client disconnects), the partial body is still passed to the codec, and the endpoint logic runs with it. Fixing this needs the request body stream to fail with an error instead of completing normally. That will be a separate PR.