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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions client/src/main/java/org/asynchttpclient/AsyncHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
* <ol>
* <li>{@link #onStatusReceived(HttpResponseStatus)},</li>
* <li>{@link #onHeadersReceived(HttpHeaders)},</li>
* <li>{@link #onResponseBodyStart(ResponseBodyControl)},</li>
* <li>{@link #onBodyPartReceived(HttpResponseBodyPart)}, which could be invoked multiple times,</li>
* <li>{@link #onTrailingHeadersReceived(HttpHeaders)}, which is only invoked if trailing HTTP headers are received</li>
* <li>{@link #onCompleted()}, once the response has been fully read.</li>
Expand Down Expand Up @@ -79,6 +80,21 @@ public interface AsyncHandler<T> {
*/
State onHeadersReceived(HttpHeaders headers) throws Exception;

/**
* Invoked after the final response headers and before any response body parts are delivered. The supplied control
* can suspend and resume transport reads, or cancel the response body. This callback is also invoked for responses
* that have no body. Return {@link State#ABORT} to stop processing from this callback; retain the control and call
* {@link ResponseBodyControl#cancel()} to stop processing asynchronously after this callback returns.
*
* @param control control for this response body.
* @return a {@link State} telling to CONTINUE or ABORT the current processing.
* @throws Exception if something wrong happens
* @since 3.0.14
*/
default State onResponseBodyStart(ResponseBodyControl control) throws Exception {
return State.CONTINUE;
}

/**
* Invoked as soon as some response body part are received. Could be invoked many times.
* Beware that, depending on the provider (Netty) this can be notified with empty body parts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ default boolean isHttp2Enabled() {
}

/**
* This is also the per-stream flow-control bound for response data queued while a
* {@link ResponseBodyControl} is suspended. Aggregate queued data can scale with the number of concurrent suspended
* streams; use {@link #getHttp2MaxConcurrentStreams()} to bound that concurrency.
*
* @return the HTTP/2 initial window size in bytes, defaults to 16777216 (16 MiB)
*/
default int getHttp2InitialWindowSize() {
Expand Down Expand Up @@ -355,6 +359,9 @@ default int getHttp2MaxHeaderListSize() {
}

/**
* This setting can be combined with {@link #getHttp2InitialWindowSize()} to bound response data queued for
* concurrently suspended HTTP/2 streams.
*
* @return the HTTP/2 max concurrent streams per connection, -1 means unlimited (server-controlled)
*/
default int getHttp2MaxConcurrentStreams() {
Expand Down
55 changes: 55 additions & 0 deletions client/src/main/java/org/asynchttpclient/ResponseBodyControl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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;

/**
* Controls transport reads for a response body.
* <p>
* 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}.
* <p>
* 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.
* <p>
* 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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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(

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 turns the connection level bound off for every H2 connection, not only the ones that use the new control. With the shipped defaults (http2InitialWindowSize 16 MiB, http2MaxConcurrentStreams -1) nothing aggregate is left, so the ceiling becomes the per stream window times whatever concurrency the server picks. Before this a slow consumer was capped at 64 KiB per connection.

Can we auto refill only while a control on that connection is actually suspended, or keep an aggregate byte budget? I would rather not change the default for users who never touch ResponseBodyControl. The README points at http2MaxConcurrentStreams as the mitigation, but that is off by default too.

connection, DefaultHttp2LocalFlowController.DEFAULT_WINDOW_UPDATE_RATIO, true));
connection(connection);
}

@Override
public boolean isServer() {
return false;
}
}

public boolean isOpen() {
return channelPool.isOpen();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -122,6 +123,7 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable e) {
}

Channel channel = ctx.channel();
NettyResponseBodyControl.discardForChannelClose(channel);

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 runs before the two branches below that keep the exchange alive on purpose, the IOException filter one and recoverOnReadOrWriteException. In both we do not close the channel, but the control is already discarded, so the handler is left holding one where suspend(), resume() and cancel() are silent no ops. detach(false) also leaves autoRead off on a channel that survives. Should we move it down to the paths that really tear the channel down?

NettyResponseFuture<?> future = null;

logger.debug("Unexpected I/O exception on channel {}", channel, cause);
Expand Down Expand Up @@ -179,7 +181,9 @@ public void channelActive(ChannelHandlerContext ctx) {

@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
readIfNeeded(ctx);
if (!NettyResponseBodyControl.isSuspended(ctx.channel())) {

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.

Nit: this does an attribute lookup on every read complete for every channel, including each H2 stream child, and in the normal case readIfNeeded then no ops anyway. suspend0() always sets autoRead to false, so checking isAutoRead() first is equivalent and keeps the lookup off the hot path.

readIfNeeded(ctx);
}
}

/**
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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.

When the HEADERS frame ends the stream we still create a control and call this, and finishUpdate runs a few lines down. A handler that suspends here gets no data and no error, and the resume() its consumer sends later hits !active and returns silently. Both new tests suspend unconditionally in onResponseBodyStart, so the path is exercised but the ignored suspend is never asserted. Either skip the callback when there is no body, or state on ResponseBodyControl that suspending in that case does nothing.

if (abort) {
NettyResponseBodyControl.complete(channel);

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.

finishUpdate calls complete() as its first statement, and if cancel() ran inline we already detached, so this never does anything. Same for the copy in HttpHandler.abortAfterStartingResponseBody. I would drop both and keep finishUpdate as the single completion point.

}
}
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);
}
}
Expand All @@ -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);
}
Expand All @@ -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()) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(

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.

handleHttp2HeadersFrame returns for every 1xx before it reaches the equivalent code, this path does not. exitAfterIntercept only special cases 100, so a 103 Early Hints lands here, gets a control and calls onResponseBodyStart on an interim response. The decoder treats 1xx as empty and hands us LastHttpContent right away, so we complete the future on the 103 and the real response then hits the future.isDone() branch in handleRead. onStatusReceived and onHeadersReceived have the same hole today, but this is new API and it would be good not to inherit it.

channel, future::touch, () -> finishUpdate(future, channel, true));

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.

close is hardcoded to true, so a cancel() from the last onBodyPartReceived throws away a connection that has nothing unread on it. The reason given in the description, that unread bytes cannot be pooled, does not apply once the body is fully read, and cancellationFromTerminalBodyCallbackCompletesOnce asserts connectionCount == 2 which locks that in. Can we derive close from whether we have seen LastHttpContent yet?

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()) {
Expand All @@ -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);
}
}
Expand All @@ -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()) {

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.

The same guard is missing after onTrailingHeadersReceived a few lines up. cancel() from a trailer callback completes the future and runs onCompleted(), then we come in here because last is true and call onBodyPartReceived on a handler that is already done. handleHttp2TrailingHeadersFrame got the guard, this one did not.

return;
}
}

if (abort || last) {
Expand Down
Loading
Loading