Skip to content

Commit fd9d2f3

Browse files
Refuse a hop the deadline has no time for
Review round one on the absolute deadline. Clamping a spent deadline to zero only delayed the abort. The next hop still took a connection permit, took a connection and wrote the request, and the timeout arrived a tick later - so a 307 put its body on the redirect target while the caller was handed a TimeoutException that reads as though nothing had been sent. sendNextRequest, which every redirect, auth replay and retry funnels through, now fails the exchange before the write when the deadline has passed. Worse than reported, as it turns out: on a coarse wheel the exchange did not merely abort late, it ran both hops and succeeded, the deadline exceeded and nobody told. The anchor was getStart(), which is currentTimeMillis. A per-attempt timeout can only be distorted by a clock step for the length of one hop; an anchor spanning a whole exchange carries the step to every hop after it, so a correction back would hand a later hop a budget it never had and one forward would abort it on a healthy connection. The future now records System.nanoTime() at submission and the budget is netted off that, leaving wall clock only where main already had it, per hop. The comment justifying the clamp said the task still cancels its read-timeout sibling. There is no sibling at that point: the read timeout is armed after the write. The reason is simply that a scheduler has no use for a negative delay and the task has to run, running being what fails the exchange. DefaultRequest's widened constructor is package private. Public, it would be pinned by revapi at twenty-seven arguments, the next per-request option would make it twenty-eight, and the two parameter lists would have to be kept in step by hand with a tail that is all reference types for the compiler to confuse. RequestBuilderBase#build is the only caller. Two fields were being dropped by hand-copied lists, which is the same fault this change exists to fix. Redirect30xInterceptor rebuilds the next request from a hand-picked set and carried neither the read timeout - reverting a per-request value to the config default on every hop after the first - nor the deadline flag, which left the Request disagreeing with the behaviour a filter or a signature calculator would read off it. RequestBuilderBase's signature-calculator copy block dropped the read timeout the same way. The interface default returns false rather than reading the property, as every other option on it does; the javadoc now says so, so that a custom config setting the property and getting nothing is documented rather than surprising. Three of the tests asserted only that nothing was thrown, which a dropped Location header or redirects turned off would have satisfied having run a single hop. They assert the final status and which hop it came from. The five were also all wall clock, so TimeoutsHolderTest covers the anchor and the clamp directly: what a second holder makes of the same exchange is the whole difference between the two modes. AsyncHttpClientDefaultsTest was asserting no default for this option, nor for the event-loop one merged alongside it, and now does both. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 8eda565 commit fd9d2f3

10 files changed

Lines changed: 336 additions & 61 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ default boolean isUseEventLoopTimeouts() {
159159
* Off by default because turning it on shortens exchanges that rely on the per-attempt behaviour. A caller
160160
* working to an end-to-end budget wants it on; {@link Request#getUseAbsoluteRequestDeadline()} sets it for a
161161
* single request.
162+
* <p>
163+
* As with every option on this interface, the {@code org.asynchttpclient.useAbsoluteRequestDeadline}
164+
* property is read by {@link DefaultAsyncHttpClientConfig.Builder}, not here: an implementation of this
165+
* interface that does not override this method gets {@code false} whatever the property says.
162166
*
163167
* @return {@code true} to treat the request timeout as a deadline for the whole exchange
164168
*/

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

Lines changed: 34 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -107,38 +107,42 @@ 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.
115+
*
110116
* @param useAbsoluteRequestDeadline whether {@code requestTimeout} bounds the whole exchange rather than
111-
* each attempt within it, or null to defer to the client config. Trailing
112-
* rather than beside {@code followRedirect} so the original signature
113-
* stays intact for callers that build a request without the builder.
117+
* each attempt within it, or null to defer to the client config
114118
*/
115-
public DefaultRequest(String method,
116-
Uri uri,
117-
@Nullable InetAddress address,
118-
@Nullable InetAddress localAddress,
119-
HttpHeaders headers,
120-
List<Cookie> cookies,
121-
byte @Nullable [] byteData,
122-
@Nullable List<byte[]> compositeByteData,
123-
@Nullable String stringData,
124-
@Nullable ByteBuffer byteBufferData,
125-
@Nullable ByteBuf byteBufData,
126-
@Nullable InputStream streamData,
127-
@Nullable BodyGenerator bodyGenerator,
128-
List<Param> formParams,
129-
List<Part> bodyParts,
130-
@Nullable String virtualHost,
131-
@Nullable ProxyServer proxyServer,
132-
@Nullable Realm realm,
133-
@Nullable File file,
134-
@Nullable Boolean followRedirect,
135-
@Nullable Duration requestTimeout,
136-
@Nullable Duration readTimeout,
137-
long rangeOffset,
138-
@Nullable Charset charset,
139-
ChannelPoolPartitioning channelPoolPartitioning,
140-
NameResolver<InetAddress> nameResolver,
141-
@Nullable Boolean useAbsoluteRequestDeadline) {
119+
DefaultRequest(String method,
120+
Uri uri,
121+
@Nullable InetAddress address,
122+
@Nullable InetAddress localAddress,
123+
HttpHeaders headers,
124+
List<Cookie> cookies,
125+
byte @Nullable [] byteData,
126+
@Nullable List<byte[]> compositeByteData,
127+
@Nullable String stringData,
128+
@Nullable ByteBuffer byteBufferData,
129+
@Nullable ByteBuf byteBufData,
130+
@Nullable InputStream streamData,
131+
@Nullable BodyGenerator bodyGenerator,
132+
List<Param> formParams,
133+
List<Part> bodyParts,
134+
@Nullable String virtualHost,
135+
@Nullable ProxyServer proxyServer,
136+
@Nullable Realm realm,
137+
@Nullable File file,
138+
@Nullable Boolean followRedirect,
139+
@Nullable Duration requestTimeout,
140+
@Nullable Duration readTimeout,
141+
long rangeOffset,
142+
@Nullable Charset charset,
143+
ChannelPoolPartitioning channelPoolPartitioning,
144+
NameResolver<InetAddress> nameResolver,
145+
@Nullable Boolean useAbsoluteRequestDeadline) {
142146
this.method = method;
143147
this.uri = uri;
144148
this.address = address;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ private RequestBuilderBase<?> executeSignatureCalculator() {
700700
rb.followRedirect = followRedirect;
701701
rb.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
702702
rb.requestTimeout = requestTimeout;
703+
rb.readTimeout = readTimeout;
703704
rb.rangeOffset = rangeOffset;
704705
rb.charset = charset;
705706
rb.channelPoolPartitioning = channelPoolPartitioning;

‎client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ public final class NettyResponseFuture<V> implements ListenableFuture<V> {
9292
.newUpdater(NettyResponseFuture.class, Object.class, "partitionKeyLock");
9393

9494
private final long start = unpreciseMillisTime();
95+
// Wall clock is what start reports, and a deadline anchored on it for the length of a whole exchange would
96+
// be moved by any clock correction that lands mid-chain: a step back would hand a later hop a budget it
97+
// never had, a step forward would abort it on a healthy connection. Elapsed time is measured from here.
98+
private final long startNanos = System.nanoTime();
9599
private final ChannelPoolPartitioning connectionPoolPartitioning;
96100
private final ConnectionSemaphore connectionSemaphore;
97101
// Not final: a filter replay can retarget this future at a different origin, reached through a
@@ -601,6 +605,14 @@ public long getStart() {
601605
return start;
602606
}
603607

608+
/**
609+
* When this exchange was submitted, on a monotonic clock, for measuring how much of a deadline spanning the
610+
* whole exchange it has spent. Comparable only with other {@link System#nanoTime()} readings.
611+
*/
612+
public long getStartNanos() {
613+
return startNanos;
614+
}
615+
604616
public Object getPartitionKey() {
605617
Object override = partitionKeyOverride;
606618
if (override != null) {

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,19 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture<?>
139139
.setNameResolver(request.getNameResolver())
140140
.setProxyServer(request.getProxyServer())
141141
.setRealm(stripAuth ? null : request.getRealm())
142-
.setRequestTimeout(request.getRequestTimeout());
142+
.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.
145+
.setReadTimeout(request.getReadTimeout());
146+
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.
151+
Boolean useAbsoluteRequestDeadline = request.getUseAbsoluteRequestDeadline();
152+
if (useAbsoluteRequestDeadline != null) {
153+
requestBuilder.setUseAbsoluteRequestDeadline(useAbsoluteRequestDeadline);
154+
}
143155

144156
if (stripAuth) {
145157
future.setRealm(null);

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@
8181
import org.asynchttpclient.proxy.ProxyType;
8282
import org.asynchttpclient.resolver.RequestHostnameResolver;
8383
import org.asynchttpclient.uri.Uri;
84+
import org.asynchttpclient.util.StringBuilderPool;
8485
import org.asynchttpclient.ws.WebSocketUpgradeHandler;
8586
import org.jetbrains.annotations.Nullable;
8687

@@ -98,9 +99,11 @@
9899
import java.util.Locale;
99100
import java.util.Map;
100101
import java.util.concurrent.TimeUnit;
102+
import java.util.concurrent.TimeoutException;
101103
import java.util.concurrent.atomic.AtomicBoolean;
102104
import java.util.function.Consumer;
103105

106+
import static org.asynchttpclient.util.DateUtils.unpreciseMillisTime;
104107
import static io.netty.handler.codec.http.HttpHeaderNames.EXPECT;
105108
import static java.util.Collections.singletonList;
106109
import static java.util.Objects.requireNonNull;
@@ -1213,9 +1216,25 @@ public boolean applyIoExceptionFiltersAndReplayRequest(NettyResponseFuture<?> fu
12131216
}
12141217

12151218
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+
}
12161227
sendRequest(request, future.getAsyncHandler(), future);
12171228
}
12181229

1230+
private static String deadlinePassedMessage(Request request, NettyResponseFuture<?> future) {
1231+
return StringBuilderPool.DEFAULT.stringBuilder()
1232+
.append("Request timeout to ").append(request.getUri().getHost())
1233+
.append(':').append(request.getUri().getExplicitPort())
1234+
.append(" after ").append(unpreciseMillisTime() - future.getStart())
1235+
.append(" ms, before the next hop was sent").toString();
1236+
}
1237+
12191238
private static void validateWebSocketRequest(Request request, AsyncHandler<?> asyncHandler) {
12201239
Uri uri = request.getUri();
12211240
boolean isWs = uri.isWebSocket();

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

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,13 @@ public TimeoutsHolder(Timer nettyTimer, @Nullable EventExecutor eventExecutor, N
9090
requestTimeoutValue = requestTimeoutInMs;
9191
absoluteDeadline = nettyResponseFuture.isUseAbsoluteRequestDeadline();
9292
if (requestTimeoutInMs > -1) {
93-
// A redirect, a retry or an auth replay builds a new holder for the same future. Anchoring the
94-
// deadline here hands each of those hops a fresh budget, so a chain of n hops runs for n times the
95-
// configured timeout; anchoring it on the future bounds the exchange as a whole instead. Which one
96-
// applies is the caller's choice, per request or per client.
97-
requestTimeoutMillisTime = (absoluteDeadline ? nettyResponseFuture.getStart() : unpreciseMillisTime())
98-
+ requestTimeoutInMs;
93+
// A redirect, a retry or an auth replay builds a new holder for the same future. Giving each of
94+
// those hops the configured timeout lets a chain of n hops run for n times it; netting off what the
95+
// 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);
99100
requestTimeoutTask = new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs);
100101
} else {
101102
requestTimeoutMillisTime = -1L;
@@ -117,11 +118,35 @@ public void start() {
117118
if (requestTimeoutTask != null) {
118119
// Per attempt, the configured duration: this runs within microseconds of the constructor, so
119120
// reading the clock again would only expose the deadline to a step between the two reads. An
120-
// absolute deadline is anchored before this holder existed, so there the remainder is the budget.
121-
arm(requestTimeoutTask, absoluteDeadline ? remainingRequestTimeout() : requestTimeoutValue);
121+
// absolute deadline was anchored before this holder existed, so there the remainder is the budget,
122+
// 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);
122124
}
123125
}
124126

127+
/**
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.
130+
*
131+
* @see org.asynchttpclient.AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()
132+
*/
133+
public boolean isDeadlinePassed() {
134+
return absoluteDeadline && requestTimeoutValue > -1 && remainingBudget() <= 0L;
135+
}
136+
137+
/**
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.
140+
*/
141+
// Visible for testing: the instant this holder's request timeout is due, as a wall-clock reading.
142+
long requestTimeoutMillisTime() {
143+
return requestTimeoutMillisTime;
144+
}
145+
146+
private long remainingBudget() {
147+
return requestTimeoutValue - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - nettyResponseFuture.getStartNanos());
148+
}
149+
125150
/**
126151
* Moves this exchange's timeouts onto {@code executor}, the loop of the channel it turned out to run on. The
127152
* connect path arms the request timeout before there is a channel -- deliberately, since it bounds address
@@ -186,8 +211,8 @@ private static void release(@Nullable TimeoutTimerTask task) {
186211
}
187212

188213
private long remainingRequestTimeout() {
189-
// A deadline already behind us is armed at zero rather than negative, so the task still runs and still
190-
// cancels its read-timeout sibling, which is bookkeeping only it does.
214+
// Floored at zero rather than passed on negative: a scheduler has no use for a negative delay, and the
215+
// task has to run either way, since running is what fails the exchange.
191216
return Math.max(requestTimeoutMillisTime - unpreciseMillisTime(), 0L);
192217
}
193218

0 commit comments

Comments
 (0)