diff --git a/README.md b/README.md index b19bef1621..b6eadd0258 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,14 @@ AsyncHttpClient client = asyncHttpClient(config() .setHttp2CleartextEnabled(true)); // h2c prior knowledge ``` +When a handler suspends a response with `ResponseBodyControl`, the HTTP/2 +per-stream window remains the buffering bound for that response. AHC continues +returning connection-level credit so the suspended stream cannot stall sibling +streams. Consequently, aggregate queued response data can scale with the number +of concurrently suspended streams. Use `http2InitialWindowSize` and +`http2MaxConcurrentStreams` together when an application needs a tighter +aggregate bound. + To force HTTP/1.1, disable HTTP/2: ```java diff --git a/client/src/main/java/org/asynchttpclient/AsyncHandler.java b/client/src/main/java/org/asynchttpclient/AsyncHandler.java index 22451fe097..9e53d7f3ab 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHandler.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHandler.java @@ -34,6 +34,7 @@ *
+ * The control is thread-safe and remains valid until its response completes. Calls made after completion have no + * effect. + * + * @since 3.0.14 + */ +public interface ResponseBodyControl { + + /** + * Stops requesting additional response bytes from the transport. Body parts that were already read may still be + * delivered to the {@link AsyncHandler}. + *
+ * While reads are suspended, the read timeout is paused but the request timeout remains active. If the request + * timeout is disabled, failing to resume or cancel the response can retain its transport resources indefinitely. + *
+ * For HTTP/2, AHC continues returning connection-level flow-control credit so a suspended stream cannot block
+ * sibling streams. The per-stream window still applies, so roughly
+ * {@link AsyncHttpClientConfig#getHttp2InitialWindowSize()} bytes can be queued for each suspended stream. Aggregate
+ * buffering can therefore scale with the number of concurrent suspended streams; applications can bound it with
+ * {@link AsyncHttpClientConfig#getHttp2InitialWindowSize()} and
+ * {@link AsyncHttpClientConfig#getHttp2MaxConcurrentStreams()}.
+ */
+ void suspend();
+
+ /**
+ * Resumes requesting response bytes after a call to {@link #suspend()}.
+ */
+ void resume();
+
+ /**
+ * Stops processing the response body. As with {@link AsyncHandler.State#ABORT}, the handler is completed normally.
+ * Returning {@code ABORT} is the preferred way to stop from within an {@link AsyncHandler} callback; this method is
+ * intended for cancellation after the callback has returned, including from another thread.
+ */
+ void cancel();
+}
diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java
index f305fb3f34..39cf60abe7 100755
--- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java
+++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java
@@ -37,6 +37,8 @@
import io.netty.handler.codec.http.websocketx.WebSocket08FrameEncoder;
import io.netty.handler.codec.http.websocketx.WebSocketFrameAggregator;
import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketClientCompressionHandler;
+import io.netty.handler.codec.http2.DefaultHttp2Connection;
+import io.netty.handler.codec.http2.DefaultHttp2LocalFlowController;
import io.netty.handler.codec.http2.DefaultHttp2ResetFrame;
import io.netty.handler.codec.http2.Http2Error;
import io.netty.handler.codec.http2.Http2FrameCodec;
@@ -1079,7 +1081,7 @@ public void upgradePipelineToHttp2(ChannelPipeline pipeline) {
// Netty's default and a pushing server could trip a connection-level PROTOCOL_ERROR.
.pushEnabled(false);
- Http2FrameCodec frameCodec = Http2FrameCodecBuilder.forClient()
+ Http2FrameCodec frameCodec = new ClientHttp2FrameCodecBuilder()
.initialSettings(settings)
.build();
@@ -1284,6 +1286,28 @@ private static final class ConnectionCounts {
private long idleConnectionCount;
}
+ private static final class ClientHttp2FrameCodecBuilder extends Http2FrameCodecBuilder {
+
+ private ClientHttp2FrameCodecBuilder() {
+ // Http2FrameCodecBuilder.forClient() sets this through its package-private constructor. This subclass must
+ // use the protected no-argument constructor, so set it explicitly to preserve the client factory behavior.
+ gracefulShutdownTimeoutMillis(0);
+
+ DefaultHttp2Connection connection = new DefaultHttp2Connection(false);
+ // Refill shared credit on receipt so a suspended stream cannot starve siblings. Per-stream windows retain
+ // application backpressure, at the deliberate cost that aggregate queued data can scale with the number of
+ // suspended streams. ResponseBodyControl documents the relevant configuration bounds.
+ connection.local().flowController(new DefaultHttp2LocalFlowController(
+ connection, DefaultHttp2LocalFlowController.DEFAULT_WINDOW_UPDATE_RATIO, true));
+ connection(connection);
+ }
+
+ @Override
+ public boolean isServer() {
+ return false;
+ }
+ }
+
public boolean isOpen() {
return channelPool.isOpen();
}
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/AsyncHttpClientHandler.java b/client/src/main/java/org/asynchttpclient/netty/handler/AsyncHttpClientHandler.java
index d53e98d58e..08b15452e9 100755
--- a/client/src/main/java/org/asynchttpclient/netty/handler/AsyncHttpClientHandler.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/AsyncHttpClientHandler.java
@@ -91,6 +91,7 @@ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
}
Channel channel = ctx.channel();
+ NettyResponseBodyControl.discardForChannelClose(channel);
channelManager.removeAll(channel);
Object attribute = Channels.getAttribute(channel);
@@ -122,6 +123,7 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable e) {
}
Channel channel = ctx.channel();
+ NettyResponseBodyControl.discardForChannelClose(channel);
NettyResponseFuture> future = null;
logger.debug("Unexpected I/O exception on channel {}", channel, cause);
@@ -179,7 +181,9 @@ public void channelActive(ChannelHandlerContext ctx) {
@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
- readIfNeeded(ctx);
+ if (!NettyResponseBodyControl.isSuspended(ctx.channel())) {
+ readIfNeeded(ctx);
+ }
}
/**
@@ -196,6 +200,7 @@ private static void readIfNeeded(ChannelHandlerContext ctx) {
}
void finishUpdate(NettyResponseFuture> future, Channel channel, boolean close) {
+ NettyResponseBodyControl.complete(channel);
future.cancelTimeouts();
if (close) {
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java b/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java
index 7c581acbd8..e3626bc6c4 100644
--- a/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java
@@ -180,14 +180,26 @@ private void handleHttp2HeadersFrame(Http2HeadersFrame headersFrame, Channel cha
if (!abort) {
abort = handler.onHeadersReceived(responseHeaders) == State.ABORT;
}
+ if (!abort) {
+ NettyResponseBodyControl control = NettyResponseBodyControl.create(
+ channel, future::touch, () -> finishUpdate(future, channel, false));
+ abort = handler.onResponseBodyStart(control) == State.ABORT;
+ if (abort) {
+ NettyResponseBodyControl.complete(channel);
+ }
+ }
if (abort) {
- finishUpdate(future, channel, false);
+ // cancel() may have completed the future inline from onResponseBodyStart.
+ if (!future.isDone()) {
+ finishUpdate(future, channel, false);
+ }
return;
}
}
// If headers frame also ends the stream (no body), finish the response
- if (headersFrame.isEndStream()) {
+ // unless cancel() already completed it inline from onResponseBodyStart.
+ if (headersFrame.isEndStream() && !future.isDone()) {
finishUpdate(future, channel, false);
}
}
@@ -205,6 +217,10 @@ private void handleHttp2DataFrame(Http2DataFrame dataFrame, Channel channel,
if (data.isReadable() || last) {
HttpResponseBodyPart bodyPart = config.getResponseBodyPartFactory().newResponseBodyPart(data, last);
boolean abort = handler.onBodyPartReceived(bodyPart) == State.ABORT;
+ // cancel() may have completed the future inline from the handler callback.
+ if (future.isDone()) {
+ return;
+ }
if (abort || last) {
finishUpdate(future, channel, false);
}
@@ -224,6 +240,10 @@ private void handleHttp2TrailingHeadersFrame(Http2HeadersFrame headersFrame, Cha
boolean abort = false;
if (!trailingHeaders.isEmpty()) {
abort = handler.onTrailingHeadersReceived(trailingHeaders) == State.ABORT;
+ // cancel() may have completed the future inline from the handler callback.
+ if (future.isDone()) {
+ return;
+ }
}
if (abort || headersFrame.isEndStream()) {
@@ -285,6 +305,7 @@ private void handleHttp2ResetFrame(Http2ResetFrame resetFrame, Channel channel,
*/
@Override
void finishUpdate(NettyResponseFuture> future, Channel streamChannel, boolean close) {
+ NettyResponseBodyControl.complete(streamChannel);
future.cancelTimeouts();
// Stream channels are single-use in HTTP/2 — close the stream
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java
index c09db7b812..b057138039 100755
--- a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java
@@ -56,6 +56,17 @@ private static boolean abortAfterHandlingHeaders(AsyncHandler> handler, HttpHe
return !responseHeaders.isEmpty() && handler.onHeadersReceived(responseHeaders) == State.ABORT;
}
+ private boolean abortAfterStartingResponseBody(Channel channel, NettyResponseFuture> future,
+ AsyncHandler> handler) throws Exception {
+ NettyResponseBodyControl control = NettyResponseBodyControl.create(
+ channel, future::touch, () -> finishUpdate(future, channel, true));
+ boolean abort = handler.onResponseBodyStart(control) == State.ABORT;
+ if (abort) {
+ NettyResponseBodyControl.complete(channel);
+ }
+ return abort;
+ }
+
private void handleHttpResponse(final HttpResponse response, final Channel channel, final NettyResponseFuture> future, AsyncHandler> handler) throws Exception {
HttpRequest httpRequest = future.getNettyRequest().getHttpRequest();
if (logger.isDebugEnabled()) {
@@ -68,8 +79,11 @@ private void handleHttpResponse(final HttpResponse response, final Channel chann
HttpHeaders responseHeaders = response.headers();
if (!interceptors.exitAfterIntercept(channel, future, handler, response, status, responseHeaders)) {
- boolean abort = abortAfterHandlingStatus(handler, httpRequest.method(), status) || abortAfterHandlingHeaders(handler, responseHeaders);
- if (abort) {
+ boolean abort = abortAfterHandlingStatus(handler, httpRequest.method(), status)
+ || abortAfterHandlingHeaders(handler, responseHeaders)
+ || abortAfterStartingResponseBody(channel, future, handler);
+ // cancel() may have completed the future inline from onResponseBodyStart.
+ if (abort && !future.isDone()) {
finishUpdate(future, channel, true);
}
}
@@ -92,6 +106,10 @@ private void handleChunk(HttpContent chunk, final Channel channel, final NettyRe
if (!abort && (buf.isReadable() || last)) {
HttpResponseBodyPart bodyPart = config.getResponseBodyPartFactory().newResponseBodyPart(buf, last);
abort = handler.onBodyPartReceived(bodyPart) == State.ABORT;
+ // cancel() may have completed the future inline from the handler callback.
+ if (future.isDone()) {
+ return;
+ }
}
if (abort || last) {
diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/NettyResponseBodyControl.java b/client/src/main/java/org/asynchttpclient/netty/handler/NettyResponseBodyControl.java
new file mode 100644
index 0000000000..3072759cdd
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/NettyResponseBodyControl.java
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.netty.handler;
+
+import io.netty.channel.Channel;
+import io.netty.util.AttributeKey;
+import org.asynchttpclient.ResponseBodyControl;
+import org.jetbrains.annotations.ApiStatus;
+
+import java.util.Objects;
+
+/**
+ * Netty implementation of {@link ResponseBodyControl}.
+ */
+@ApiStatus.Internal
+public final class NettyResponseBodyControl implements ResponseBodyControl {
+
+ private static final AttributeKey