Skip to content

Add response body flow control - #2318

Merged
hyperxpro merged 12 commits into
AsyncHttpClient:mainfrom
mkurz:feature/response-body-control
Aug 31, 2026
Merged

Add response body flow control#2318
hyperxpro merged 12 commits into
AsyncHttpClient:mainfrom
mkurz:feature/response-body-control

Conversation

@mkurz

@mkurz mkurz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a thread-safe ResponseBodyControl callback after final response headers.
  • Support suspending, resuming, and cancelling HTTP/1.1 and HTTP/2 response bodies without coupling AHC to a streaming API.
  • Pause the network read timeout while reads are intentionally suspended while leaving the request timeout active.
  • Keep suspended HTTP/2 streams independent and cover transport backpressure, cancellation, timeouts, and connection reuse.

Motivation

AHC 3 removed StreamedAsyncHandler and its Reactive Streams integration in pull request #1843. That removal avoids coupling AHC to a particular streaming library, but AsyncHandler by itself has no way to stop transport reads while a downstream consumer has no demand.

Play WS is the driving consumer for this change. Play WS needs transport backpressure to preserve its existing Pekko Streams and Reactive Streams response APIs while upgrading to AHC 3. A working adapter on a currently local Play WS development branch turns this control into a single-subscriber Reactive Streams publisher and has been tested against this AHC branch.

This pull request adds only the transport primitive. Streaming-library policy and dependencies remain in Play WS, so AHC does not regain a dependency on Reactive Streams or JDK Flow.

Semantics

  • AsyncHandler.onResponseBodyStart runs after final headers and before body parts, including for a response with no body.
  • Calls to the supplied control are thread-safe, idempotent, and ignored after response completion.
  • suspend() stops requesting new transport data, although body parts already read may still be delivered.
  • resume() permits transport reads again.
  • Returning State.ABORT is the synchronous callback-time way to stop processing. A handler can retain the control and call cancel() when an asynchronous decision is made after the callback returns.
  • Cancelling an HTTP/1.1 body closes its connection when bytes may remain unread; cancellation after terminal content has been received can reuse a keep-alive connection. Cancelling an HTTP/2 body closes only its stream.
  • Fully consumed responses retain the existing HTTP/1.1 pooling and HTTP/2 parent-connection reuse behavior.
  • Suspension pauses only the network read timeout. The request timeout remains active; if the request timeout is disabled, an application that never resumes or cancels can retain the exchange and its transport resources indefinitely.

HTTP/2 flow control

Connections with no actively suspended response retain Netty's normal connection-level receive-window accounting. The default 65,535-byte connection window therefore continues to cap unconsumed flow-controlled DATA across all streams for users that never call suspend().

When the first response on a connection is suspended, AHC returns connection-level credit that has already accumulated and continues returning that shared credit as DATA arrives. This prevents the suspended stream from exhausting the connection window and starving sibling streams. Per-stream credit remains consumption-driven at all times, so each suspended stream is still bounded by its own receive window. Multiple simultaneous suspensions are counted, and normal connection accounting resumes after the last one ends. Credit already returned and data already queued cannot be revoked, and the controller tracks credit returned early so later application consumption does not return it twice.

During an active suspension, aggregate buffering can still scale with the number of concurrent streams; the relevant controls are http2InitialWindowSize, http2MaxConcurrentStreams, and the connection limits. The defaults do not impose a hard client-side aggregate bound during that interval: the initial per-stream window is 16 MiB and http2MaxConcurrentStreams = -1 leaves concurrency server-controlled. A rough upper-bound estimate is connection count times effective concurrent streams times the initial window, excluding network and decoder overhead. Applications requiring a finite policy must configure these values together. A hard aggregate byte budget is a separate design and is outside this pull request.

Netty's connection auto-refill state is private and fixed when DefaultHttp2LocalFlowController is constructed, so it cannot be enabled only for the lifetime of a suspension through composition or subclassing. The package-private SuspensionAwareHttp2LocalFlowController therefore adapts DefaultHttp2LocalFlowController from Netty 4.2.17.Final, preserving its normal behavior while making connection refill suspension-scoped.

This adaptation has a maintenance cost: AHC owns the copied flow-control logic and every Netty upgrade must compare it with the corresponding upstream implementation for correctness and security fixes. The exact source version and that obligation are recorded in the class Javadoc. An upstream Netty API that permits connection auto-refill to be changed at runtime would provide the exit path and allow AHC to remove the adaptation; no such API exists in Netty 4.2.17.Final.

The auto-refill mode requires a custom Http2Connection. Netty's builder treats server() and connection() as mutually exclusive, so ClientHttp2FrameCodecBuilder supplies the connection through the protected builder API and overrides isServer() to retain client mode.

The explicit gracefulShutdownTimeoutMillis(0) is not a new shutdown policy. Http2FrameCodecBuilder.forClient() selects zero through its package-private client constructor; the subclass must use the protected no-argument constructor, so it sets zero explicitly to preserve the existing client-factory behavior.

Scope and commit structure

The API/lifecycle work and the HTTP/2 independence work are kept as separate logical commits, but they belong in one pull request. Without the HTTP/2 work, a suspended response can consume the shared connection window and block unrelated sibling streams, so the public control would not have correct multiplexed behavior. Follow-up review fixes are also split into focused commits covering exchange ownership, terminal cleanup, terminal HTTP/1.1 cancellation, bodyless responses, interim responses, indefinite-suspension diagnostics, and suspension-scoped HTTP/2 refill.

History checked

  • Issue #544 originally identified the lack of AsyncHandler backpressure, and pull request #963 addressed it by adding Reactive Streams support.
  • Issues #1233 and #1721 document the interaction between downstream demand and read timeouts in the former streamed handler.
  • Pull request #1843 removed StreamedAsyncHandler for AHC 3.
  • Discussion #1925 asks how to migrate streamed consumers to AHC 3; the maintainer response declines restoring Reactive Streams because other libraries provide that policy and maintaining it adds overhead.
  • Focused GitHub issue, pull-request, discussion, and local history searches found no existing AHC 3 proposal for a streaming-library-neutral suspend/resume/cancel response-body control.

Compatibility

  • AsyncHandler.onResponseBodyStart is a new Java default method, so existing handler implementations remain source- and binary-compatible and retain their previous behavior unless they override it.
  • ResponseBodyControl is a new public interface.
  • NettyResponseBodyControl is public only to support AHC's cross-package transport integration and is marked @ApiStatus.Internal; consumers should depend on ResponseBodyControl instead.
  • HTTP/1.1 informational responses from 102 through 199 are now treated as interim and no longer reach onStatusReceived or onHeadersReceived; this matches the existing HTTP/2 behavior and prevents a 103 Early Hints response from completing the exchange before the final response. Existing 100 Continue handling and 101 protocol switching are preserved.
  • Cancelling HTTP/1.1 processing from a terminal trailer or body callback now reuses a keep-alive connection because LastHttpContent has already been received; it previously closed that fully read connection.
  • The full JDK 11 verification, including Revapi, passes.

AI disclosure

OpenAI Codex on behalf of Matthias Kurz. The commits include Co-Authored-By: OpenAI Codex <codex@openai.com> per AGENTS.md.

Test plan

  • On the first commit alone, the new HTTP/2 sibling-stream test reproduced shared connection-window starvation: a sibling response did not progress while the first response remained suspended.
  • The focused ResponseBodyControlTest, Http2ResponseBodyControlTest, NettyResponseFutureTest, TimeoutTimerTaskTest, SuspensionAwareHttp2LocalFlowControllerTest, and Continue100InterceptorTest suites pass 38 tests: 12 HTTP/1.1 control tests, 6 HTTP/2 integration tests, 9 future-lifecycle tests, 4 timeout-task tests, 5 flow-controller accounting tests, and 2 HTTP/1.1 Continue tests.
  • The flow-controller tests cover unchanged connection accounting without suspension, connection-only refill during suspension, DATA received before the suspension callback, overlapping suspensions, and avoiding double credit after normal accounting resumes.
  • JAVA_HOME=<jdk-11> ./mvnw clean verify: BUILD SUCCESS for the full reactor, including tests, Javadocs, coverage, and Revapi.
  • The locally published AHC snapshot passes the full Play WS Scala 2.13 and Scala 3.3.8 test matrices on Java 17 and Java 21: 183 integration tests and 76 unit tests pass in each combination, with 2 expected pending tests.
  • Play WS code validation, documentation, Scala 2 and Scala 3 MiMa checks, and dependency-tree verification pass against the locally published AHC branch.

Comment thread client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java Outdated
Comment thread client/src/main/java/org/asynchttpclient/netty/handler/Http2Handler.java Outdated
Comment thread client/src/main/java/org/asynchttpclient/netty/timeout/ReadTimeoutTimerTask.java Outdated
Comment thread client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java Outdated
mkurz and others added 10 commits August 28, 2026 00:52
Expose a thread-safe response body control after final response headers so
handlers can suspend, resume, or cancel transport reads without coupling AHC
to a streaming API.

Integrate the control with HTTP/1.1 and HTTP/2, pause network read timeouts
while reads are intentionally suspended, and keep request timeouts active.
Make completion, cancellation, callback aborts, and channel teardown restore
transport state exactly once.

Cover backpressure, cancellation, timeout, callback failures, and connection
reuse for both protocols.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Return connection-level receive credit as DATA frames arrive while preserving
per-stream flow control for application backpressure. This prevents one
suspended response from starving siblings on the same multiplexed connection.

Document that aggregate queued data can scale with the number of suspended
streams and point users to the initial-window and concurrent-stream controls.
Preserve Netty's client shutdown behavior in the custom frame-codec builder.

Cover sibling progress, repeated cancellation, and the retained per-stream
window bound.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Store each response body control on its response future instead of its
transport channel. Replays can then replace the control without leaving a
suspended channel behind, and calls on a stale control become harmless.

Keep recoverable exception paths alive until their replay decision is made,
avoid the control lookup on the normal auto-read path, and tolerate event loop
shutdown racing with late control calls.

Cover replay replacement, old-channel draining, stale controls, and calls made
after client shutdown.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Record when HTTP/1.1 has received the terminal content before invoking trailer
or body callbacks. Cancellation from either terminal callback can then finish
normally and reuse a keep-alive connection instead of closing it as unread.

Check for inline cancellation after trailers so no later terminal body
callback is delivered. Remove duplicate control completion from callback-abort
paths and leave finishUpdate as the single completion owner.

Cover cancellation from terminal body and trailer callbacks, single handler
completion, skipped callbacks, and HTTP/1.1 connection reuse.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Document that onResponseBodyStart is invoked even when final headers end the
response, but suspension cannot defer completion in that case. The control is
inactive once the callback returns and later calls are harmless.

Cover that behavior explicitly for HTTP/1.1 and HTTP/2, including completion
without resume, late control calls, and connection reuse.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Treat HTTP/1.1 informational responses as interim rather than delivering them
to the response handler or creating a response body control. Ignore Netty's
synthetic LastHttpContent for each interim response while preserving the
special terminator used to release a deferred 100-continue request body.

Cover a 103 Early Hints followed by a final response, callback counts, body
delivery, completion, and connection reuse. Retain the existing deferred
100-continue behavior.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Log once when a response remains suspended for a read-timeout interval while
its request timeout is disabled. Such an exchange otherwise has no configured
deadline and retains its handler and transport resources until application
code resumes or cancels it.

Keep the timeout path lock-free using the race-safe arming logic already on
main, and cover warning severity, wording, and one-shot behavior.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Preserve the normal connection-level receive-window bound until a
handler actually suspends a response. While any response on the
connection is suspended, return shared credit without returning its
per-stream credit so unrelated streams can continue.

Track credit returned early until application consumption catches up.
This avoids returning connection credit twice when suspension ends and
normal accounting resumes.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Use the existing last-content callback, tied to the response future, to consume Netty's synthetic terminator after an HTTP/1.1 interim response. This prevents a channel marker from surviving into a later exchange.

Explain how the unchanged channel attribute identifies an unsolicited 100 response after the continue interceptor runs.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
Record that the package-private controller is adapted from Netty 4.2.17.Final and must be compared with upstream when Netty is upgraded for correctness and security fixes.

Identify a runtime auto-refill API in Netty as the exit path that would let AHC remove the maintained adaptation.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
@mkurz
mkurz force-pushed the feature/response-body-control branch from 00ada81 to 08be4ab Compare August 28, 2026 20:45
@mkurz
mkurz requested a review from hyperxpro August 28, 2026 20:56
@mkurz

mkurz commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

@hyperxpro if you have time to look at this PR that would be really great, so I know how to move on in play-ws. Thanks!

hyperxpro and others added 2 commits September 1, 2026 01:16
External cancellation and timeout paths mark the channel as discarded
before channelInactive runs, so channel cleanup cannot recover the response
future. A suspended HTTP/2 control can then leave connection auto-refill
enabled and remain callable from onThrowable.

Make the future's terminal transitions deactivate the control before user
callbacks, while retaining channel-close cleanup for replay paths. Cover
both abort and explicit cancellation.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
@mkurz
mkurz requested a review from hyperxpro August 31, 2026 21:01
@hyperxpro
hyperxpro merged commit 492eb2d into AsyncHttpClient:main Aug 31, 2026
13 checks passed
@mkurz
mkurz deleted the feature/response-body-control branch August 31, 2026 21:52
@hyperxpro

Copy link
Copy Markdown
Member

Thanks a lot!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants