Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,35 @@ public interface AsyncHttpClientConfig {

/**
* Return the maximum time an {@link AsyncHttpClient} waits until the response is completed.
* <p>
* By default this bounds each attempt within an exchange rather than the exchange as a whole: a redirect, a
* retry and an auth replay each start it again, so a chain of n hops may run for n times this value. Set
* {@link #isUseAbsoluteRequestDeadline()} to bound the exchange instead.
*
* @return the maximum time an {@link AsyncHttpClient} waits until the response is completed.
*/
Duration getRequestTimeout();

/**
* Whether {@link #getRequestTimeout()} is a deadline for the whole exchange rather than for each attempt
* within it.
* <p>
* A redirect, a retry and an auth replay all continue the same exchange on the same response future, but
* each builds its own timeout state. Anchoring the deadline on that state gives every hop a fresh budget,
* which is why a five-redirect chain can legitimately take six times the configured timeout today. Enabling
* this anchors it on when the exchange was submitted instead, so a later hop gets whatever is left and the
* caller's total wait is bounded by the one value.
* <p>
* Off by default because turning it on shortens exchanges that rely on the per-attempt behaviour. A caller
* working to an end-to-end budget wants it on; {@link Request#getUseAbsoluteRequestDeadline()} sets it for a
* single request.
*
* @return {@code true} to treat the request timeout as a deadline for the whole exchange
*/
default boolean isUseAbsoluteRequestDeadline() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoding false here means the property only ever works through the builder. Someone on a custom config or a wrapper sets org.asynchttpclient.useAbsoluteRequestDeadline, gets nothing, and nothing is logged, while a sibling service on the builder honours it. Either delegate to defaultUseAbsoluteRequestDeadline or say in the javadoc that the property is builder only.

return false;
}

/**
* Is HTTP redirect enabled
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultEnabledProtocols;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultExpiredCookieEvictionDelay;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownEnabled;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultUseAbsoluteRequestDeadline;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFailedIpCooldownPeriod;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFilterInsecureCipherSuites;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultFollowRedirect;
Expand Down Expand Up @@ -138,6 +139,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig {
private final int maxRequestRetry;
private final LoadBalance loadBalance;
private final boolean failedIpCooldownEnabled;
private final boolean useAbsoluteRequestDeadline;
private final Duration failedIpCooldownPeriod;
private final boolean disableUrlEncodingForBoundRequests;
private final boolean useLaxCookieEncoder;
Expand Down Expand Up @@ -243,6 +245,7 @@ private DefaultAsyncHttpClientConfig(// http
int maxRequestRetry,
LoadBalance loadBalance,
boolean failedIpCooldownEnabled,
boolean useAbsoluteRequestDeadline,
Duration failedIpCooldownPeriod,
boolean disableUrlEncodingForBoundRequests,
boolean useLaxCookieEncoder,
Expand Down Expand Up @@ -348,6 +351,7 @@ private DefaultAsyncHttpClientConfig(// http
this.maxRequestRetry = maxRequestRetry;
this.loadBalance = loadBalance;
this.failedIpCooldownEnabled = failedIpCooldownEnabled;
this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
this.failedIpCooldownPeriod = failedIpCooldownPeriod;
this.disableUrlEncodingForBoundRequests = disableUrlEncodingForBoundRequests;
this.useLaxCookieEncoder = useLaxCookieEncoder;
Expand Down Expand Up @@ -518,6 +522,11 @@ public boolean isFailedIpCooldownEnabled() {
return failedIpCooldownEnabled;
}

@Override
public boolean isUseAbsoluteRequestDeadline() {
return useAbsoluteRequestDeadline;
}

@Override
public Duration getFailedIpCooldownPeriod() {
return failedIpCooldownPeriod;
Expand Down Expand Up @@ -937,6 +946,7 @@ public static class Builder {
private int maxRequestRetry = defaultMaxRequestRetry();
private LoadBalance loadBalance = defaultLoadBalance();
private boolean failedIpCooldownEnabled = defaultFailedIpCooldownEnabled();
private boolean useAbsoluteRequestDeadline = defaultUseAbsoluteRequestDeadline();
private Duration failedIpCooldownPeriod = defaultFailedIpCooldownPeriod();
private boolean disableUrlEncodingForBoundRequests = defaultDisableUrlEncodingForBoundRequests();
private boolean useLaxCookieEncoder = defaultUseLaxCookieEncoder();
Expand Down Expand Up @@ -1045,6 +1055,7 @@ public Builder(AsyncHttpClientConfig config) {
maxRequestRetry = config.getMaxRequestRetry();
loadBalance = config.getLoadBalance();
failedIpCooldownEnabled = config.isFailedIpCooldownEnabled();
useAbsoluteRequestDeadline = config.isUseAbsoluteRequestDeadline();
failedIpCooldownPeriod = config.getFailedIpCooldownPeriod();
disableUrlEncodingForBoundRequests = config.isDisableUrlEncodingForBoundRequests();
useLaxCookieEncoder = config.isUseLaxCookieEncoder();
Expand Down Expand Up @@ -1244,6 +1255,17 @@ public Builder setFailedIpCooldownEnabled(boolean failedIpCooldownEnabled) {
return this;
}

/**
* @param useAbsoluteRequestDeadline whether the request timeout is a deadline for the whole exchange
* rather than for each attempt within it; see
* {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()}
* @return this
*/
public Builder setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) {
this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
return this;
}

/**
* @param failedIpCooldownPeriod how long a failed IP is deprioritized before it is re-probed;
* {@code null} resets to the default. Must not be negative; use
Expand Down Expand Up @@ -1751,6 +1773,7 @@ public DefaultAsyncHttpClientConfig build() {
maxRequestRetry,
loadBalance,
failedIpCooldownEnabled,
useAbsoluteRequestDeadline,
failedIpCooldownPeriod,
disableUrlEncodingForBoundRequests,
useLaxCookieEncoder,
Expand Down
46 changes: 46 additions & 0 deletions client/src/main/java/org/asynchttpclient/DefaultRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public class DefaultRequest implements Request {
private final @Nullable Realm realm;
private final @Nullable File file;
private final @Nullable Boolean followRedirect;
private final @Nullable Boolean useAbsoluteRequestDeadline;
private final Duration requestTimeout;
private final Duration readTimeout;
private final long rangeOffset;
Expand Down Expand Up @@ -99,6 +100,45 @@ public DefaultRequest(String method,
@Nullable Charset charset,
ChannelPoolPartitioning channelPoolPartitioning,
NameResolver<InetAddress> nameResolver) {
this(method, uri, address, localAddress, headers, cookies, byteData, compositeByteData, stringData,
byteBufferData, byteBufData, streamData, bodyGenerator, formParams, bodyParts, virtualHost,
proxyServer, realm, file, followRedirect, requestTimeout, readTimeout, rangeOffset, charset,
channelPoolPartitioning, nameResolver, null);
}

/**
* @param useAbsoluteRequestDeadline whether {@code requestTimeout} bounds the whole exchange rather than
* each attempt within it, or null to defer to the client config. Trailing
* rather than beside {@code followRedirect} so the original signature
* stays intact for callers that build a request without the builder.
*/
public DefaultRequest(String method,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the part we cannot take back later. A 27 arg public constructor gets pinned by revapi, the next per request option makes it 28, and the two parameter lists have to stay in lockstep with no compiler help if they drift, since the tail is all reference types. Only RequestBuilderBase.build calls it. Package private, or a small options holder, costs nothing today.

Uri uri,
@Nullable InetAddress address,
@Nullable InetAddress localAddress,
HttpHeaders headers,
List<Cookie> cookies,
byte @Nullable [] byteData,
@Nullable List<byte[]> compositeByteData,
@Nullable String stringData,
@Nullable ByteBuffer byteBufferData,
@Nullable ByteBuf byteBufData,
@Nullable InputStream streamData,
@Nullable BodyGenerator bodyGenerator,
List<Param> formParams,
List<Part> bodyParts,
@Nullable String virtualHost,
@Nullable ProxyServer proxyServer,
@Nullable Realm realm,
@Nullable File file,
@Nullable Boolean followRedirect,
@Nullable Duration requestTimeout,
@Nullable Duration readTimeout,
long rangeOffset,
@Nullable Charset charset,
ChannelPoolPartitioning channelPoolPartitioning,
NameResolver<InetAddress> nameResolver,
@Nullable Boolean useAbsoluteRequestDeadline) {
this.method = method;
this.uri = uri;
this.address = address;
Expand All @@ -119,6 +159,7 @@ public DefaultRequest(String method,
this.realm = realm;
this.file = file;
this.followRedirect = followRedirect;
this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
this.requestTimeout = requestTimeout == null ? Duration.ZERO : requestTimeout;
this.readTimeout = readTimeout == null ? Duration.ZERO : readTimeout;
this.rangeOffset = rangeOffset;
Expand Down Expand Up @@ -232,6 +273,11 @@ public List<Part> getBodyParts() {
return followRedirect;
}

@Override
public @Nullable Boolean getUseAbsoluteRequestDeadline() {
return useAbsoluteRequestDeadline;
}

@Override
public Duration getRequestTimeout() {
return requestTimeout;
Expand Down
11 changes: 11 additions & 0 deletions client/src/main/java/org/asynchttpclient/Request.java
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ public interface Request {
@Nullable
Boolean getFollowRedirect();

/**
* Whether {@link #getRequestTimeout()} is a deadline for the whole exchange rather than for each attempt
* within it. See {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()}.
*
* @return the override, or null to use the config value
*/
@Nullable
default Boolean getUseAbsoluteRequestDeadline() {
return null;
}

/**
* @return the request timeout. Non zero values means "override config value".
*/
Expand Down
17 changes: 16 additions & 1 deletion client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public abstract class RequestBuilderBase<T extends RequestBuilderBase<T>> {
protected @Nullable Realm realm;
protected @Nullable File file;
protected @Nullable Boolean followRedirect;
protected @Nullable Boolean useAbsoluteRequestDeadline;
protected @Nullable Duration requestTimeout;
protected @Nullable Duration readTimeout;
protected long rangeOffset;
Expand Down Expand Up @@ -165,6 +166,7 @@ protected RequestBuilderBase(Request prototype, boolean disableUrlEncoding, bool
realm = prototype.getRealm();
file = prototype.getFile();
followRedirect = prototype.getFollowRedirect();
useAbsoluteRequestDeadline = prototype.getUseAbsoluteRequestDeadline();
requestTimeout = prototype.getRequestTimeout();
readTimeout = prototype.getReadTimeout();
rangeOffset = prototype.getRangeOffset();
Expand Down Expand Up @@ -598,6 +600,17 @@ public T setRealm(Realm realm) {
return asDerivedType();
}

/**
* @param useAbsoluteRequestDeadline whether this request's timeout is a deadline for the whole exchange
* rather than for each attempt within it, overriding
* {@link AsyncHttpClientConfig#isUseAbsoluteRequestDeadline()}
* @return {@code this}
*/
public T setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) {
this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
return asDerivedType();
}

public T setFollowRedirect(boolean followRedirect) {
this.followRedirect = followRedirect;
return asDerivedType();
Expand Down Expand Up @@ -685,6 +698,7 @@ private RequestBuilderBase<?> executeSignatureCalculator() {
rb.realm = realm;
rb.file = file;
rb.followRedirect = followRedirect;
rb.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
rb.requestTimeout = requestTimeout;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are already in this copy block, readTimeout is missing from it. Set a signature calculator and a per request read timeout silently falls back to the config 60s. Exactly the hand copied field list problem this PR is about.

rb.rangeOffset = rangeOffset;
rb.charset = charset;
Expand Down Expand Up @@ -755,6 +769,7 @@ public Request build() {
rb.rangeOffset,
rb.charset,
rb.channelPoolPartitioning,
rb.nameResolver);
rb.nameResolver,
rb.useAbsoluteRequestDeadline);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public final class AsyncHttpClientConfigDefaults {
public static final String MAX_REQUEST_RETRY_CONFIG = "maxRequestRetry";
public static final String LOAD_BALANCE_CONFIG = "loadBalance";
public static final String FAILED_IP_COOLDOWN_ENABLED_CONFIG = "failedIpCooldownEnabled";
public static final String USE_ABSOLUTE_REQUEST_DEADLINE_CONFIG = "useAbsoluteRequestDeadline";
public static final String FAILED_IP_COOLDOWN_PERIOD_CONFIG = "failedIpCooldownPeriod";
public static final String DISABLE_URL_ENCODING_FOR_BOUND_REQUESTS_CONFIG = "disableUrlEncodingForBoundRequests";
public static final String USE_LAX_COOKIE_ENCODER_CONFIG = "useLaxCookieEncoder";
Expand Down Expand Up @@ -183,6 +184,10 @@ public static boolean defaultFailedIpCooldownEnabled() {
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + FAILED_IP_COOLDOWN_ENABLED_CONFIG);
}

public static boolean defaultUseAbsoluteRequestDeadline() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing asserts this default. AsyncHttpClientDefaultsTest covers the other flags, and if the key is ever dropped or misspelled getBoolean returns false quietly, which is also the intended default, so the regression stays invisible until someone flips it. A direct TimeoutsHolder test for the anchor and the clamp would help too, the five new tests are all wall clock.

return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + USE_ABSOLUTE_REQUEST_DEADLINE_CONFIG);
}

public static Duration defaultFailedIpCooldownPeriod() {
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getDuration(ASYNC_CLIENT_CONFIG_ROOT + FAILED_IP_COOLDOWN_PERIOD_CONFIG);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,9 @@ public final class NettyResponseFuture<V> implements ListenableFuture<V> {
// future no longer takes, which is how a connection through a proxy comes to be offered as a direct one.
// Volatile: the mutators run on the redirect and replay paths while reads happen on other threads.
private volatile Object basePartitionKeyCache;
// Read when a TimeoutsHolder is built, which happens on the caller thread, an event loop or the timer
// thread depending on the path, so it is published rather than plain.
private volatile boolean useAbsoluteRequestDeadline;

public NettyResponseFuture(Request originalRequest,
AsyncHandler<V> asyncHandler,
Expand Down Expand Up @@ -726,6 +729,21 @@ public void acquirePartitionLockLazily(boolean nonBlocking) throws IOException {
}
}

/**
* Whether this exchange's request timeout is a deadline for the exchange as a whole. Resolved once, from the
* request the caller submitted and the client config, and then kept here rather than re-read per hop: a
* redirect rebuilds the request from a hand-picked set of fields, so anything carried only on the request
* would silently revert to the config value partway through the exchange, which is exactly the case this
* setting exists for.
*/
public boolean isUseAbsoluteRequestDeadline() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Holding it on the future is the right call, but the Request now disagrees with the behaviour. After a redirect, getTargetRequest().getUseAbsoluteRequestDeadline() is null, so a filter, a signature calculator or a handler reading the request thinks we are on per attempt timeouts while the future is enforcing a deadline. Redirect30xInterceptor can carry it across for free. While in there, it also drops readTimeout, which resets a 500ms read timeout to the config default on every hop after the first.

return useAbsoluteRequestDeadline;
}

public void setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) {
this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
}

public Realm getRealm() {
return realm;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
import static org.asynchttpclient.util.HttpUtils.GZIP_DEFLATE;
import static org.asynchttpclient.util.HttpUtils.GZIP_DEFLATE_HPACK;
import static org.asynchttpclient.util.HttpUtils.hostHeader;
import static org.asynchttpclient.util.HttpUtils.useAbsoluteRequestDeadline;
import static org.asynchttpclient.util.MiscUtils.getCause;
import static org.asynchttpclient.util.ProxyUtils.getProxyServer;

Expand Down Expand Up @@ -629,6 +630,8 @@ private <T> NettyResponseFuture<T> newNettyResponseFuture(Request request, Async
connectionSemaphore,
proxyServer);

future.setUseAbsoluteRequestDeadline(useAbsoluteRequestDeadline(config, request));

String expectHeader = request.getHeaders().get(EXPECT);
if (HttpHeaderValues.CONTINUE.contentEqualsIgnoreCase(expectHeader)) {
future.setDontWriteBodyBecauseExpectContinue(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,16 @@ public TimeoutsHolder(Timer nettyTimer, NettyResponseFuture<?> nettyResponseFutu
}

if (requestTimeoutInMs > -1) {
requestTimeoutMillisTime = unpreciseMillisTime() + requestTimeoutInMs;
requestTimeout = newTimeout(new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs), requestTimeoutInMs);
// A redirect, a retry or an auth replay builds a new holder for the same future. Anchoring the
// deadline here hands each of those hops a fresh budget, so a chain of n hops runs for n times the
// configured timeout; anchoring it on the future bounds the exchange as a whole instead. Which one
// applies is the caller's choice, per request or per client.
requestTimeoutMillisTime = (nettyResponseFuture.isUseAbsoluteRequestDeadline()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getStart is currentTimeMillis, but the timer that actually fires runs on nanoTime. Until now a clock step could only distort the hop in flight. With an absolute anchor, a backward NTP correction of a few seconds effectively removes a 600ms deadline for the rest of the chain, and a forward one aborts every later hop instantly on a healthy connection. An absolute anchor really wants a monotonic source.

? nettyResponseFuture.getStart() : unpreciseMillisTime()) + requestTimeoutInMs;
// A deadline already behind us is scheduled at zero rather than negative, so the task still runs and
// still cancels its read-timeout sibling, which is bookkeeping only it does.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This justification does not hold. readTimeout is still null at this point, it is only set once scheduleReadTimeout runs after the write, and requestTimeout is not assigned yet either because the constructor registers with the timer first. In the case you are describing the task cancels nothing.

requestTimeout = newTimeout(new RequestTimeoutTimerTask(nettyResponseFuture, requestSender, this, requestTimeoutInMs),
Math.max(requestTimeoutMillisTime - unpreciseMillisTime(), 0L));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clamping to zero does not stop the hop, it only delays the abort. The wheel timer fires on tick boundaries, so with a deadline already behind us we still take a permit, still take a connection and still write the request, then fail it a tick later. On a 307 that means the POST body reached the redirect target while the caller is handed a TimeoutException that reads like nothing was sent. I think this wants a remaining budget check in sendNextRequest that fails the exchange before the write.

} else {
requestTimeoutMillisTime = -1L;
requestTimeout = null;
Expand Down
5 changes: 5 additions & 0 deletions client/src/main/java/org/asynchttpclient/util/HttpUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ public static boolean followRedirect(AsyncHttpClientConfig config, Request reque
return request.getFollowRedirect() != null ? request.getFollowRedirect() : config.isFollowRedirect();
}

public static boolean useAbsoluteRequestDeadline(AsyncHttpClientConfig config, Request request) {
Boolean override = request.getUseAbsoluteRequestDeadline();
return override != null ? override : config.isUseAbsoluteRequestDeadline();
}

public static ByteBuffer urlEncodeFormParams(List<Param> params, Charset charset) {
return StringUtils.charSequence2ByteBuffer(urlEncodeFormParams0(params, charset), US_ASCII);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ org.asynchttpclient.keepAlive=true
org.asynchttpclient.maxRequestRetry=5
org.asynchttpclient.loadBalance=DEFAULT
org.asynchttpclient.failedIpCooldownEnabled=true
org.asynchttpclient.useAbsoluteRequestDeadline=false
org.asynchttpclient.failedIpCooldownPeriod=PT10S
org.asynchttpclient.disableUrlEncodingForBoundRequests=false
org.asynchttpclient.useLaxCookieEncoder=false
Expand Down
Loading
Loading