Skip to content

Commit d7ca828

Browse files
Ask the deadline before arming, not the holder
Review round two on the absolute deadline. The check that refuses a hop with nothing left to spend was in sendNextRequest, which only continuations pass through. A first attempt goes straight to sendRequest, and under ROUND_ROBIN the up-front resolve asks for no timeout at all, so a slow resolver could eat the whole budget before any holder existed and a pooled hit would then arm at zero and write anyway. The check has moved into scheduleRequestTimeout, which every attempt passes through, first or otherwise, and which is the last point before the request is written. It returns whether the attempt may go ahead; the four call sites stop when it may not. Asking the holder was the wrong question anyway: the first attempt has no holder to ask. The budget is now a static on TimeoutsHolder, taken off the future, which every caller has in hand before an attempt of its own exists. Long.MAX_VALUE stands for an exchange that is not bounded as a whole, so a per-attempt timeout needs no special case at the call site. Resolving the configured timeout moved there with it, so the constructor and the budget no longer resolve it separately. Three comments described the change rather than the code, which AGENTS.md asks us not to and which will not read well in a year: the two on the fields Redirect30xInterceptor was dropping, and the one on DefaultRequest's package-private constructor. The comment on requestTimeoutMillisTime claimed isDeadlinePassed depended on it staying negative; only startReadTimeout does. And the javadoc for the budget had been left sitting on the test accessor by an earlier edit. One test asserted nothing. isDeadlinePassed answered on its first conjunct for a per-attempt exchange, so the arithmetic it was meant to cover never ran. It asserts on the deadline the holder computes instead: that a hop is handed the configured timeout of its own however long the exchange has already run. Carrying the read timeout across a redirect changes behaviour outside this flag - a short per-request read timeout was reverting to the config default on every hop after the first and now does not - and is called out in the pull request for the release notes. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fd9d2f3 commit d7ca828

6 files changed

Lines changed: 102 additions & 64 deletions

File tree

‎client/src/main/java/org/asynchttpclient/DefaultRequest.java‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,10 @@ public DefaultRequest(String method,
107107
}
108108

109109
/**
110-
* Not public, and the parameter is trailing rather than beside {@code followRedirect}: the constructor above
111-
* keeps the signature outside callers compile against, while this one stays free to grow. A public
112-
* twenty-seven argument constructor would be pinned by revapi, and its parameter list would have to be kept
113-
* in step with the one above by hand, with the compiler unable to help once the tail is all reference types.
114-
* {@link RequestBuilderBase#build()} is the only caller.
110+
* The full set of fields a request carries, called only by {@link RequestBuilderBase#build()}. Not public and
111+
* not part of the API: the constructor above is what outside callers compile against, so this one is free to
112+
* take another field without pinning a signature or asking the next reader to keep two parameter lists of
113+
* reference types in step by eye.
115114
*
116115
* @param useAbsoluteRequestDeadline whether {@code requestTimeout} bounds the whole exchange rather than
117116
* each attempt within it, or null to defer to the client config

‎client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,12 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture<?>
140140
.setProxyServer(request.getProxyServer())
141141
.setRealm(stripAuth ? null : request.getRealm())
142142
.setRequestTimeout(request.getRequestTimeout())
143-
// Dropped here until now, so a per-request read timeout reverted to the config default
144-
// on every hop after the first.
145143
.setReadTimeout(request.getReadTimeout());
146144

147-
// Also dropped, which left the request disagreeing with the deadline the exchange was being
148-
// held to: the future carries the flag, so a filter or a signature calculator reading the
149-
// request saw per-attempt timeouts while the exchange was bounded as a whole. Only when it was
150-
// set, since the setter takes a primitive and null means defer to the client config.
145+
// The exchange holds the deadline flag on its future, so a hop that does not carry it forward
146+
// leaves the request saying something the exchange is not doing, which is what a filter or a
147+
// signature calculator reads. Set only when the request has one: the setter takes a primitive,
148+
// and null is how a request defers to the client config.
151149
Boolean useAbsoluteRequestDeadline = request.getUseAbsoluteRequestDeadline();
152150
if (useAbsoluteRequestDeadline != null) {
153151
requestBuilder.setUseAbsoluteRequestDeadline(useAbsoluteRequestDeadline);

‎client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java‎

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,10 @@ private <T> ListenableFuture<T> sendRequestWithOpenChannel(NettyResponseFuture<T
412412
future.attachChannel(channel, false);
413413

414414
SocketAddress channelRemoteAddress = channel.remoteAddress();
415-
if (channelRemoteAddress != null) {
415+
if (channelRemoteAddress != null
416+
&& !scheduleRequestTimeout(future, (InetSocketAddress) channelRemoteAddress, channel)) {
416417
// otherwise, bad luck, the channel was closed, see bellow
417-
scheduleRequestTimeout(future, (InetSocketAddress) channelRemoteAddress, channel);
418+
return future;
418419
}
419420

420421
if (LOGGER.isDebugEnabled()) {
@@ -525,7 +526,9 @@ private <T> ListenableFuture<T> sendRequestWithNewChannel(Request request, Proxy
525526
abort(null, future, new UnknownHostException("No addresses resolved for " + request.getUri().getHost()));
526527
return future;
527528
}
528-
scheduleRequestTimeout(future, roundRobinAddresses.get(0));
529+
if (!scheduleRequestTimeout(future, roundRobinAddresses.get(0))) {
530+
return future;
531+
}
529532
connectWithAddresses(request, proxy, future, asyncHandler, roundRobinAddresses);
530533
return future;
531534
}
@@ -595,16 +598,16 @@ private <T> Future<List<InetSocketAddress>> resolveAddresses(Request request, Pr
595598
if (proxy != null && !proxy.isIgnoredForHost(uri.getHost()) && proxy.getProxyType().isHttp()) {
596599
int port = ProxyType.HTTPS.equals(proxy.getProxyType()) || uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort();
597600
InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(proxy.getHost(), port);
598-
if (scheduleTimeout) {
599-
scheduleRequestTimeout(future, unresolvedRemoteAddress);
601+
if (scheduleTimeout && !scheduleRequestTimeout(future, unresolvedRemoteAddress)) {
602+
return abortedResolution(future);
600603
}
601604
return resolveHostname(request, unresolvedRemoteAddress, asyncHandler);
602605
} else {
603606
int port = uri.getExplicitPort();
604607

605608
InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(uri.getHost(), port);
606-
if (scheduleTimeout) {
607-
scheduleRequestTimeout(future, unresolvedRemoteAddress);
609+
if (scheduleTimeout && !scheduleRequestTimeout(future, unresolvedRemoteAddress)) {
610+
return abortedResolution(future);
608611
}
609612

610613
if (request.getAddress() != null) {
@@ -1087,26 +1090,40 @@ private static void configureTransferAdapter(AsyncHandler<?> handler, HttpReques
10871090
((TransferCompletionHandler) handler).headers(h);
10881091
}
10891092

1090-
private void scheduleRequestTimeout(NettyResponseFuture<?> nettyResponseFuture,
1091-
InetSocketAddress originalRemoteAddress) {
1092-
scheduleRequestTimeout(nettyResponseFuture, originalRemoteAddress, null);
1093+
private boolean scheduleRequestTimeout(NettyResponseFuture<?> nettyResponseFuture,
1094+
InetSocketAddress originalRemoteAddress) {
1095+
return scheduleRequestTimeout(nettyResponseFuture, originalRemoteAddress, null);
10931096
}
10941097

10951098
/**
1099+
* Arms the timeouts for the attempt about to be made, unless the exchange has no time left to make it in.
1100+
* Every attempt passes through here, whether it is the first or a redirect, an auth replay or a retry, and
1101+
* it is the last point before the request is written -- so it is where a deadline is worth one more look.
1102+
* Arming at zero instead would abort the attempt, but only after a connection permit had been taken, a
1103+
* connection taken and the request written: a 307 would put its body on the redirect target and then hand
1104+
* the caller a TimeoutException that reads as though nothing had been sent.
1105+
*
10961106
* @param channel the channel the exchange will run on when it is already known, so the timeout can be armed
10971107
* on the loop that owns it. Null on the connect path: the timeout is armed before the channel
10981108
* exists, deliberately, so that it also bounds address resolution and the connect itself, and
10991109
* {@code TimeoutsHolder#rehomeOn} moves it onto the loop once there is one.
1110+
* @return whether the attempt may go ahead. When {@code false} the exchange has already been aborted.
11001111
*/
1101-
private void scheduleRequestTimeout(NettyResponseFuture<?> nettyResponseFuture,
1102-
InetSocketAddress originalRemoteAddress,
1103-
@Nullable Channel channel) {
1112+
private boolean scheduleRequestTimeout(NettyResponseFuture<?> nettyResponseFuture,
1113+
InetSocketAddress originalRemoteAddress,
1114+
@Nullable Channel channel) {
1115+
if (TimeoutsHolder.remainingBudget(config, nettyResponseFuture) <= 0L) {
1116+
abort(nettyResponseFuture.channel(), nettyResponseFuture,
1117+
new TimeoutException(deadlinePassedMessage(nettyResponseFuture.getTargetRequest(), nettyResponseFuture)));
1118+
return false;
1119+
}
11041120
nettyResponseFuture.touch();
11051121
TimeoutsHolder timeoutsHolder = new TimeoutsHolder(nettyTimer, timeoutExecutor(channel), nettyResponseFuture,
11061122
this, config, originalRemoteAddress);
11071123
// Arms the timeout as a part of installing the holder, which is why the pooled path attaches the
11081124
// channel first: an expiry that lands immediately reaches the channel only through the future.
11091125
nettyResponseFuture.setTimeoutsHolder(timeoutsHolder);
1126+
return true;
11101127
}
11111128

11121129
/**
@@ -1216,23 +1233,25 @@ public boolean applyIoExceptionFiltersAndReplayRequest(NettyResponseFuture<?> fu
12161233
}
12171234

12181235
public <T> void sendNextRequest(final Request request, final NettyResponseFuture<T> future) {
1219-
TimeoutsHolder timeoutsHolder = future.getTimeoutsHolder();
1220-
if (timeoutsHolder != null && timeoutsHolder.isDeadlinePassed()) {
1221-
// Arming the next hop's timeout at zero would abort it, but only after this call has taken a
1222-
// connection permit, taken a connection and written the request -- so a 307 would put the body on
1223-
// the wire and then hand the caller a TimeoutException that reads as if nothing was sent.
1224-
abort(future.channel(), future, new TimeoutException(deadlinePassedMessage(request, future)));
1225-
return;
1226-
}
12271236
sendRequest(request, future.getAsyncHandler(), future);
12281237
}
12291238

1239+
/**
1240+
* A resolution that will not be attempted, for an attempt the exchange has no time left to make. The
1241+
* exchange is aborted before this is returned, so the failure carried here only stops the listener from
1242+
* carrying on with a connect.
1243+
*/
1244+
private static <T> Future<List<InetSocketAddress>> abortedResolution(NettyResponseFuture<T> future) {
1245+
return ImmediateEventExecutor.INSTANCE.newFailedFuture(
1246+
new TimeoutException(deadlinePassedMessage(future.getTargetRequest(), future)));
1247+
}
1248+
12301249
private static String deadlinePassedMessage(Request request, NettyResponseFuture<?> future) {
12311250
return StringBuilderPool.DEFAULT.stringBuilder()
12321251
.append("Request timeout to ").append(request.getUri().getHost())
12331252
.append(':').append(request.getUri().getExplicitPort())
12341253
.append(" after ").append(unpreciseMillisTime() - future.getStart())
1235-
.append(" ms, before the next hop was sent").toString();
1254+
.append(" ms, before the request was sent").toString();
12361255
}
12371256

12381257
private static void validateWebSocketRequest(Request request, AsyncHandler<?> asyncHandler) {

‎client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java‎

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,21 +82,18 @@ public TimeoutsHolder(Timer nettyTimer, @Nullable EventExecutor eventExecutor, N
8282
final long readTimeoutInMs = targetRequest.getReadTimeout().toMillis();
8383
readTimeoutValue = readTimeoutInMs == 0 ? config.getReadTimeout().toMillis() : readTimeoutInMs;
8484

85-
long requestTimeoutInMs = targetRequest.getRequestTimeout().toMillis();
86-
if (requestTimeoutInMs == 0) {
87-
requestTimeoutInMs = config.getRequestTimeout().toMillis();
88-
}
85+
long requestTimeoutInMs = requestTimeout(config, targetRequest);
8986

9087
requestTimeoutValue = requestTimeoutInMs;
9188
absoluteDeadline = nettyResponseFuture.isUseAbsoluteRequestDeadline();
9289
if (requestTimeoutInMs > -1) {
9390
// A redirect, a retry or an auth replay builds a new holder for the same future. Giving each of
9491
// those hops the configured timeout lets a chain of n hops run for n times it; netting off what the
9592
// exchange has already spent bounds it as a whole instead. Which one applies is the caller's
96-
// choice, per request or per client. May be negative, and is deliberately left so: a deadline
97-
// already behind us has to read as behind us, so that startReadTimeout does not arm a sibling and
98-
// isDeadlinePassed can say the exchange is over.
99-
requestTimeoutMillisTime = unpreciseMillisTime() + (absoluteDeadline ? remainingBudget() : requestTimeoutInMs);
93+
// choice, per request or per client. Left negative when the deadline is already behind us, which is
94+
// what stops startReadTimeout arming a sibling for an exchange that is over.
95+
requestTimeoutMillisTime = unpreciseMillisTime()
96+
+ (absoluteDeadline ? remainingBudget(requestTimeoutInMs, nettyResponseFuture) : requestTimeoutInMs);
10097
requestTimeoutTask = new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs);
10198
} else {
10299
requestTimeoutMillisTime = -1L;
@@ -120,33 +117,51 @@ public void start() {
120117
// reading the clock again would only expose the deadline to a step between the two reads. An
121118
// absolute deadline was anchored before this holder existed, so there the remainder is the budget,
122119
// floored at zero: a task armed at zero still runs, and running is how the exchange gets failed.
123-
arm(requestTimeoutTask, absoluteDeadline ? Math.max(remainingBudget(), 0L) : requestTimeoutValue);
120+
arm(requestTimeoutTask, absoluteDeadline
121+
? Math.max(remainingBudget(requestTimeoutValue, nettyResponseFuture), 0L) : requestTimeoutValue);
124122
}
125123
}
126124

127125
/**
128-
* Whether the exchange has run out of time to start another hop with. Always {@code false} when the timeout
129-
* is per attempt, where a hop is given the configured timeout of its own by definition.
126+
* How much of a deadline spanning the whole exchange is left, in milliseconds, negative once it has passed.
127+
* {@link Long#MAX_VALUE} when the timeout is per attempt or disabled, neither of which bounds an exchange as
128+
* a whole: an attempt is then given the configured timeout of its own however long the exchange has run.
129+
* <p>
130+
* Measured from the future's monotonic start rather than by comparing wall clocks across hops, so a clock
131+
* correction landing mid-chain cannot move the deadline. Static, and asked of the future rather than of a
132+
* holder, because a caller deciding whether a request is still worth sending has the future in hand before
133+
* any holder exists for the attempt it is about to make.
130134
*
131135
* @see org.asynchttpclient.AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()
132136
*/
133-
public boolean isDeadlinePassed() {
134-
return absoluteDeadline && requestTimeoutValue > -1 && remainingBudget() <= 0L;
137+
public static long remainingBudget(AsyncHttpClientConfig config, NettyResponseFuture<?> nettyResponseFuture) {
138+
if (!nettyResponseFuture.isUseAbsoluteRequestDeadline()) {
139+
return Long.MAX_VALUE;
140+
}
141+
return remainingBudget(requestTimeout(config, nettyResponseFuture.getTargetRequest()), nettyResponseFuture);
142+
}
143+
144+
private static long remainingBudget(long requestTimeoutInMs, NettyResponseFuture<?> nettyResponseFuture) {
145+
if (requestTimeoutInMs <= -1) {
146+
return Long.MAX_VALUE;
147+
}
148+
long spent = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - nettyResponseFuture.getStartNanos());
149+
return requestTimeoutInMs - spent;
135150
}
136151

137152
/**
138-
* What is left of a deadline that spans the whole exchange, which may be negative. Measured from the
139-
* future's monotonic start rather than by comparing wall clocks across hops.
153+
* The request timeout in force for {@code request}: its own, or the client's when it does not carry one.
140154
*/
155+
private static long requestTimeout(AsyncHttpClientConfig config, Request request) {
156+
long requestTimeoutInMs = request.getRequestTimeout().toMillis();
157+
return requestTimeoutInMs == 0 ? config.getRequestTimeout().toMillis() : requestTimeoutInMs;
158+
}
159+
141160
// Visible for testing: the instant this holder's request timeout is due, as a wall-clock reading.
142161
long requestTimeoutMillisTime() {
143162
return requestTimeoutMillisTime;
144163
}
145164

146-
private long remainingBudget() {
147-
return requestTimeoutValue - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - nettyResponseFuture.getStartNanos());
148-
}
149-
150165
/**
151166
* Moves this exchange's timeouts onto {@code executor}, the loop of the channel it turned out to run on. The
152167
* connect path arms the request timeout before there is a channel -- deliberately, since it bounds address

‎client/src/test/java/org/asynchttpclient/AbsoluteRequestDeadlineTest.java‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,12 @@ void assertTimedOut() {
185185
}
186186

187187
/**
188-
* That the exchange was failed by the check before the next hop was written, rather than by the timeout
189-
* arming at zero and expiring once it had been. The message is the only thing that tells the two apart.
188+
* That the exchange was failed by the check before the request was written, rather than by a timeout
189+
* armed at zero expiring once it had been. The message is the only thing that tells the two apart.
190190
*/
191191
void assertTimedOutBeforeSending() {
192192
assertTimedOut();
193-
assertTrue(cause.getMessage().contains("before the next hop was sent"),
193+
assertTrue(cause.getMessage().contains("before the request was sent"),
194194
"expected the deadline to be caught before the write, got " + cause.getMessage());
195195
}
196196
}

‎client/src/test/java/org/asynchttpclient/netty/timeout/TimeoutsHolderTest.java‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727

2828
import java.time.Duration;
2929

30-
import static org.junit.jupiter.api.Assertions.assertFalse;
3130
import static org.junit.jupiter.api.Assertions.assertTrue;
3231

3332
/**
@@ -73,31 +72,39 @@ public void aPerAttemptTimeoutGivesTheSecondHopItsOwnBudget() throws Exception {
7372
}
7473

7574
@RepeatedIfExceptionsTest(repeats = 5)
76-
public void anExchangeThatOutranItsDeadlineSaysSo() throws Exception {
75+
public void anExchangeThatOutranItsDeadlineHasNothingLeft() throws Exception {
7776
// A budget this small is spent by the time the sleep is over, so the next hop has nothing to run in.
7877
NettyResponseFuture<?> future = exchange(true);
7978
Thread.sleep(ELAPSED_MS);
8079

81-
assertTrue(holder(future, Duration.ofMillis(1)).isDeadlinePassed(),
82-
"a spent deadline should report itself as passed");
80+
assertTrue(TimeoutsHolder.remainingBudget(config(Duration.ofMillis(1)), future) <= 0,
81+
"a spent deadline should leave nothing to send a further hop with");
8382
}
8483

8584
@RepeatedIfExceptionsTest(repeats = 5)
86-
public void aPerAttemptExchangeNeverRunsOutOfBudgetBetweenHops() throws Exception {
85+
public void aPerAttemptExchangeIsNotBoundedAsAWhole() throws Exception {
86+
// Asserted on the deadline the holder computes rather than on the budget: per attempt there is no
87+
// exchange-wide budget to run out of, so the arithmetic is not what the answer rests on.
8788
NettyResponseFuture<?> future = exchange(false);
8889
Thread.sleep(ELAPSED_MS);
8990

90-
assertFalse(holder(future, Duration.ofMillis(1)).isDeadlinePassed(),
91-
"a per-attempt timeout hands every hop a budget of its own, however long the exchange has run");
91+
long deadline = deadlineOf(future, BUDGET);
92+
93+
assertTrue(deadline - System.currentTimeMillis() >= BUDGET.toMillis() - TOLERANCE_MS,
94+
"a hop should be given the configured timeout of its own however long the exchange has run, got "
95+
+ (deadline - System.currentTimeMillis()) + " ms");
9296
}
9397

9498
private static long deadlineOf(NettyResponseFuture<?> future, Duration requestTimeout) {
9599
return holder(future, requestTimeout).requestTimeoutMillisTime();
96100
}
97101

98102
private static TimeoutsHolder holder(NettyResponseFuture<?> future, Duration requestTimeout) {
99-
return new TimeoutsHolder(null, future, null,
100-
new DefaultAsyncHttpClientConfig.Builder().setRequestTimeout(requestTimeout).build(), null);
103+
return new TimeoutsHolder(null, future, null, config(requestTimeout), null);
104+
}
105+
106+
private static AsyncHttpClientConfig config(Duration requestTimeout) {
107+
return new DefaultAsyncHttpClientConfig.Builder().setRequestTimeout(requestTimeout).build();
101108
}
102109

103110
private static NettyResponseFuture<?> exchange(boolean useAbsoluteRequestDeadline) {

0 commit comments

Comments
 (0)