Bound an exchange by one request timeout - #2314
Open
pavel-ptashyts wants to merge 1 commit into
Open
Conversation
TimeoutsHolder anchors the request deadline on its own construction, and a redirect, a retry and an auth replay each build a new one for the same future. Every hop therefore starts the budget again: with maxRedirects=5 a chain can legitimately run for six times the configured requestTimeout. The javadoc claims requestTimeout is the maximum time until the response is completed, which is not what happens. Add isUseAbsoluteRequestDeadline(), off by default, which anchors the deadline on when the exchange was submitted instead, so a later hop gets whatever is left of the budget rather than a fresh one. Off by default because turning it on shortens exchanges that rely on the per-attempt behaviour; the getRequestTimeout() javadoc now describes what actually happens and points at the flag either way. Settable per request as well as per client, following the followRedirect pattern: a nullable Boolean on Request that overrides the config value. Resolved once, in newNettyResponseFuture, and kept on the NettyResponseFuture rather than read from the request per hop. The first attempt put it on Request alone, and the two override tests failed in opposite directions because Redirect30xInterceptor rebuilds the request for the next hop from a hand-picked set of fields: the override was dropped mid-exchange and the config value took over. Anything carried only on the request has that problem, so the flag lives on the exchange, which is also what it describes. A redirect target cannot change it, which is right - the budget belongs to the caller. DefaultRequest keeps its existing public constructor, delegating to a new one that takes the flag as a trailing argument. Inserting the parameter beside followRedirect instead was a binary-incompatible change to a public constructor, which revapi correctly rejected. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
TimeoutsHolderanchors the request deadline on its own construction:A redirect, a retry and an auth replay all continue the same exchange on the same
NettyResponseFuture, but each builds a new holder for it. Every hop therefore starts thebudget again, so with
maxRedirects=5a chain can legitimately run for six times theconfigured
requestTimeout. Nothing carries an absolute deadline across hops:NettyResponseFuture#getStart()exists but is only read for a diagnosticagein a log line.The
getRequestTimeout()javadoc says it is "the maximum time an AsyncHttpClient waits untilthe response is completed", which is not what happens.
Change
AsyncHttpClientConfig#isUseAbsoluteRequestDeadline(), off by default, anchors thedeadline on when the exchange was submitted instead, so a later hop gets whatever is left of
the budget rather than a fresh one.
Off by default because turning it on shortens exchanges that rely on the per-attempt
behaviour, which is a behaviour change even if the current one contradicts the docs. The
getRequestTimeout()javadoc now describes what actually happens and points at the flag, soit stops being wrong either way.
Settable per request as well as per client, following the existing
followRedirectpattern: a nullable
BooleanonRequestthat overrides the config value.Where the flag lives, and why not on the request
It is resolved once, in
newNettyResponseFuture, and kept on theNettyResponseFuture.The first attempt kept it only on
Requestand the two override tests failed in oppositedirections.
Redirect30xInterceptorrebuilds the request for the next hop from a hand-pickedset of fields, so the override was silently dropped mid-exchange and the config value took
over — precisely in the case the setting exists for. Anything carried only on the request has
that problem, and every future site that rebuilds a request would have to remember it.
Keeping it on the exchange also says the right thing: the deadline describes the exchange, and
a redirect target cannot change it, because the budget belongs to the caller.
API compatibility
revapipasses. Everything added is additive, and one place needed care:DefaultRequestkeeps its existing public constructor, which now delegates to a new onetaking the flag as a trailing argument. Inserting the parameter beside
followRedirectinstead was a binary-incompatible change to a public constructor, and revapi correctly
rejected it — a 27-parameter overload is not pretty, but it is what keeps direct callers
working.
Request#getUseAbsoluteRequestDeadline()is adefaultmethod returning null, so existingimplementations are unaffected.
Tests
AbsoluteRequestDeadlineTestruns two hops of 400 ms against a 600 ms budget, so each hop fitson its own and the pair does not. Five cases:
Timing-based, so
@RepeatedIfExceptionsTest, matching the neighbouring timeout tests.Verification
mvnw clean verify— BUILD SUCCESS, 1474 tests, 0 failures, 0 errors, 26 skipped. ErrorProne, NullAway and Revapi all clean.
Caveat on the testing gate:
AGENTS.mdrequires the build to run on JDK 11 and no JDK 11 isinstalled on this machine, so it was run on JDK 17 (also in the CI matrix). The JDK 11 leg
of CI on this PR is the real gate.
Relationship to #2313
Both touch the same three or four lines of the
TimeoutsHolderconstructor — #2313 replacesnewTimeout(...)with anarm(...)that can target an event loop, this one changes theanchor and the delay. They are otherwise independent and can be reviewed independently.
Whichever merges second will conflict there; happy to rebase this one on top of #2313, or the
other way round, whichever you prefer to take first.
Noticed while running the suite
Two timing tests are flaky under load and unrelated to this change, mentioned only so a red
run is not mistaken for this PR:
SemaphoreTest.checkAcquireTime(three methods) allow 400 ms for a 100 ms timeout and use@RepeatedTest(10), which does not retry, unlike thecheckReleasetests beside them. Thisfailed one cell of thirteen on Arm request timeouts on an event loop #2313's CI (macOS, JDK 21) at 420 ms.
NettyRequestThrottleTimeoutTest.testRequestTimeouttakes ~31 s against a 30 s latch even onan idle machine, and releases its throttle permit only from
onThrowable, so a single requestcompleting instead of timing out deadlocks the remaining threads.
Happy to send a separate PR for both.
Claude Code on behalf of @pavel-ptashyts
🤖 Generated with Claude Code