Skip to content

Bound an exchange by one request timeout - #2314

Open
pavel-ptashyts wants to merge 1 commit into
AsyncHttpClient:mainfrom
maygemdev:feature/absolute-request-deadline-standalone
Open

Bound an exchange by one request timeout#2314
pavel-ptashyts wants to merge 1 commit into
AsyncHttpClient:mainfrom
maygemdev:feature/absolute-request-deadline-standalone

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Problem

TimeoutsHolder anchors the request deadline on its own construction:

requestTimeoutMillisTime = unpreciseMillisTime() + requestTimeoutInMs;

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 the
budget again, so with maxRedirects=5 a chain can legitimately run for six times the
configured requestTimeout. Nothing carries an absolute deadline across hops:
NettyResponseFuture#getStart() exists but is only read for a diagnostic age in a log line.

The getRequestTimeout() javadoc says it is "the maximum time an AsyncHttpClient waits until
the response is completed", which is not what happens.

Change

AsyncHttpClientConfig#isUseAbsoluteRequestDeadline(), off by default, 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, 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, so
it stops being wrong either way.

Settable per request as well as per client, following the existing followRedirect
pattern: a nullable Boolean on Request that overrides the config value.

client.prepareGet(url).setUseAbsoluteRequestDeadline(true).execute(handler);

Where the flag lives, and why not on the request

It is resolved once, in newNettyResponseFuture, and kept on the NettyResponseFuture.

The first attempt kept it only on Request and the two override tests failed in opposite
directions. Redirect30xInterceptor rebuilds the request for the next hop from a hand-picked
set 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

revapi passes. Everything added is additive, and one place needed care:
DefaultRequest keeps its existing public constructor, which now delegates to a new one
taking the flag as a trailing argument. Inserting the parameter beside followRedirect
instead 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 a default method returning null, so existing
implementations are unaffected.

Tests

AbsoluteRequestDeadlineTest runs two hops of 400 ms against a 600 ms budget, so each hop fits
on its own and the pair does not. Five cases:

  • default: both hops run, no timeout (per-attempt behaviour preserved)
  • config on: the chain times out
  • config off + request override on: times out
  • config on + request override off: does not time out
  • config on, single hop: does not time out, so the first hop is not handed a shortened budget

Timing-based, so @RepeatedIfExceptionsTest, matching the neighbouring timeout tests.

Verification

mvnw clean verify — BUILD SUCCESS, 1474 tests, 0 failures, 0 errors, 26 skipped. Error
Prone, NullAway and Revapi all clean.

Caveat on the testing gate: AGENTS.md requires the build to run on JDK 11 and no JDK 11 is
installed 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 TimeoutsHolder constructor — #2313 replaces
newTimeout(...) with an arm(...) that can target an event loop, this one changes the
anchor 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 the checkRelease tests beside them. This
    failed one cell of thirteen on Arm request timeouts on an event loop #2313's CI (macOS, JDK 21) at 420 ms.
  • NettyRequestThrottleTimeoutTest.testRequestTimeout takes ~31 s against a 30 s latch even on
    an idle machine, and releases its throttle permit only from onThrowable, so a single request
    completing 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

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>
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.

1 participant