Skip to content

fix(netty): fail fast when InputStream body cannot be reset - #2312

Merged
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
arimu1:fix/1973-consumed-stream-http1-fail-fast
Aug 16, 2026
Merged

fix(netty): fail fast when InputStream body cannot be reset#2312
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
arimu1:fix/1973-consumed-stream-http1-fail-fast

Conversation

@arimu1

@arimu1 arimu1 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • On HTTP/1, a consumed InputStream request body used to log a warning and return, leaving NettyRequestSender.writeRequest without completing the future so the request hung until timeout (Failed stream request hangs until timeout #1973). Headers used channel.write with no flush; the peer has seen nothing.
  • Throw IOException instead so writeRequest aborts the future. A failed reset() on a closed markable stream (for example BufferedInputStream after WriteProgressListener closes it) is treated the same way, so retries fail fast instead of hanging or throwing only "Stream closed".
  • HTTP/1 100 Continue now uses the same bodyWasDeferred guard as HTTP/2, so an unsolicited 100 (RFC 9110 15.2.1) does not replay a body that was already sent.
  • Add regression coverage for write, writeHttp2, the writeRequest abort path, and a real request replay.

Fixes #1973

AI disclosure

Composer 2.5 on behalf of arimu1 (Cursor harness). Commit includes Co-Authored-By: Composer 2.5 per AGENTS.md.

Test plan

  • ./mvnw -pl client -Dtest=NettyInputStreamBodyTest,Continue100InterceptorTest,NettyRequestSenderConsumedBodyRetryTest test (JDK 17)

Made with Cursor

arimu1 and others added 2 commits August 15, 2026 10:10
HTTP/1 path silently returned after a warn when a consumed non-resettable
InputStream body was reused, leaving a half-sent request that hung until
timeout. Throw IOException so sendHttpRequest aborts the future (matches
existing HTTP/2 behavior).

Composer 2.5 on behalf of arimu1

Fixes AsyncHttpClient#1973

Co-Authored-By: Composer 2.5 <composer@cursor.com>
Composer 2.5 on behalf of arimu1

Co-Authored-By: Composer 2.5 <composer@cursor.com>
Unsolicited HTTP/1 100 Continue was replaying bodies that were
already sent. Guard with bodyWasDeferred like HTTP/2. Treat a
failed reset() on a closed markable stream as unreplayable so
retries abort the future instead of hanging or throwing Stream
closed. Comments now cite NettyRequestSender.writeRequest.

Addresses review on AsyncHttpClient#2312.

Composer 2.5 on behalf of arimu1

Co-Authored-By: Composer 2.5 <composer@cursor.com>
@arimu1

arimu1 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the CHANGES_REQUESTED review (commit da62c2fd1):

  1. HTTP/1 unsolicited 100 -- Continue100Interceptor now has the same bodyWasDeferred guard as HTTP/2. We only schedule writeRequest when the body was actually deferred for Expect: 100-continue.
  2. markSupported() / closed stream -- replayConsumedStream still tries reset() when mark is supported, but wraps a failed reset (closed BufferedInputStream, no prior mark()) in a clear IOException so the user does not see a bare "Stream closed".
  3. Unsolicited 100 + resettable body / second LastHttpContent -- closed by the same bodyWasDeferred guard.
  4. sendHttpRequest -- comments and the PR description now cite NettyRequestSender.writeRequest (headers ~732, catch/abort ~751-754).
  5. Throw-comment nits -- HTTP/1 comment now states that headers used channel.write with no flush (peer has seen nothing) and that the real hang is the future never completing. ASCII only (-- / commas, no em dash).
  6. HTTP/2 issue number -- restored #2160 on the half-open-stream comment. #1973 stays on the HTTP/1 consumed-body path.
  7. Tests -- NettyRequestSender.writeRequest is driven twice (unconsumed, then consumed) and asserts the future fails within 2s. Integration replay of a real POST covers the #1973 hang. Also covered: markSupported()==true reset-fail (BufferedInputStream after close), writeHttp2 sibling, stream closed after the first write. No exact exception-message asserts. Non-markable branch uses InputStream.nullInputStream() (JDK 11).

Happy to follow up if anything still looks off.

@hyperxpro
hyperxpro merged commit 793aae9 into AsyncHttpClient:main Aug 16, 2026
13 checks passed
@hyperxpro

Copy link
Copy Markdown
Member

Thanks a lot!

hyperxpro added a commit that referenced this pull request Aug 31, 2026
## Summary

- Build keep-body redirects from the original request, preserving every
supported body representation and per-request setting.
- Clear target-specific routing and credential state when a redirect
crosses an origin, including separately stored Cookie objects.
- Preserve an explicit `Content-Length` when replaying a raw
`InputStream` or an unknown-length `InputStreamBodyGenerator`.
- Replay resettable streams and fail promptly for consumed raw streams,
streamed multipart parts, and vanished files that cannot be replayed
safely.
- Pin body bytes, headers, body-selection precedence, caller-owned
`ByteBuf` references, and the new failure modes with focused tests.

## Problem

`Redirect30xInterceptor` rebuilds a request when it follows a strict
302, 307, or 308 redirect. Its keep-body copy chain handled form
parameters, strings, byte arrays, `ByteBuffer`, body generators, and
multipart bodies, but omitted four real request send paths:

- `List<byte[]>` / composite byte arrays
- Netty `ByteBuf`
- `InputStream`
- `File`

The redirected request therefore kept its method and Content-Type but
sent zero bytes. The `File` case is especially risky for uploads because
the target can accept an apparently valid empty PUT or POST.
Reconstructing the request field by field also omitted unrelated
per-request state such as the read timeout and range offset, and
maintaining a second body-selection chain alongside
`NettyRequestFactory` made future drift likely.

This is a pre-existing omission. AHC issue
[#1643](#1643)
previously fixed the same class of bug for multipart bodies. The copy
chain was carried through pull request
[#1843](#1843)
without a policy discussion. Focused searches found no existing issue or
pull request covering these four representations.

## Change

Build a keep-body redirect with `request.toBuilder()` and then replace
only redirect-specific state. This preserves all current and future body
representations and per-request options without duplicating
`NettyRequestFactory.body`. Headers are copied before redirect-only
values are removed, so the original request is not mutated.

On a cross-origin redirect, the copied request drops the previous
resolved address, virtual host, realm, authorization headers, and Cookie
objects before the cookie store adds cookies that legitimately match the
new URI. The body is not covered by that boundary. It follows the
existing keep-body policy, which means a `File` or `InputStream` body
that a cross-origin redirect leg previously received as empty is now
sent in full, and a target that keeps redirecting can receive it once
per hop up to `maxRedirects`. That is the same exposure byte arrays,
strings, form parameters, and multipart bodies already have today.

Composite byte arrays, caller-owned `ByteBuf`s, and files are
repeatable. A resettable `InputStream`, such as `ByteArrayInputStream`,
also replays. A caller-supplied `Content-Length` is retained for a raw
`InputStream` or an `InputStreamBodyGenerator` without a declared
length, because neither has an intrinsic size from which to recompute
it. A consumed stream that cannot be reset reaches the existing
fail-fast guard added in #2312 and completes the future with
`IOException`; that is preferable to silently succeeding with an empty
body.

An `InputStreamPart` is closed by the first multipart send and has no
equivalent replay guard, so a keep-body redirect now fails promptly
instead of risking a hang or incomplete multipart request. A selected
`File` or `FileBodyGenerator` is also checked before dispatching the
redirect; if it disappeared after the first send, the future fails with
`IOException` before a target pooled channel can be removed and an
unchecked constructor exception can escape. The validation follows
`NettyRequestFactory` precedence so a sticky `File` field is ignored
when a higher-priority body representation was actually sent.

The change does not alter which methods or status codes keep a body, nor
does it introduce a new cross-origin policy. It makes the existing
strict-302, 307, and 308 behavior complete for every supported
request-body representation.

## Compatibility

There is no public API change. Requests that previously sent an empty
body on a keep-body redirect now resend their configured body.

**Behavior changes:**

- A non-resettable `InputStream` on a keep-body redirect previously
completed successfully after sending an empty redirected request. It now
completes the request future exceptionally with `IOException`. This
includes `FileInputStream`, which is closed after the first send and
cannot be reset for replay.
- A multipart `InputStreamPart` now fails promptly with `IOException`
when a keep-body redirect requires replay. Reusing its already-consumed
and closed stream could previously hang or send incomplete multipart
content.
- A selected file that disappears between the first request and redirect
now fails with `IOException` before redirect dispatch rather than
allowing an unchecked `IllegalArgumentException` to escape while
constructing the next request.
- A `File` or `InputStream` body is now sent on a keep-body redirect to
a different origin, where the redirected request previously carried no
body. Credentials are still stripped at that boundary, but the payload
is not.

Callers that accidentally relied on an empty or incomplete redirected
request will observe an exception, but the failure is explicit instead
of silently losing configured content. There is no public API change.

## AI disclosure

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

## Test plan

- [x] On untouched `upstream/main`, the focused suite reproduced five
failures: four body types arrived as zero bytes and a non-resettable
stream incorrectly completed successfully.
- [x] Before the generator fix, a one-argument
`InputStreamBodyGenerator` sent `Content-Length: 13` on the first leg
and no `Content-Length` on the redirected leg.
- [x] `./mvnw -pl client
-Dtest=RedirectBodyTest,RedirectCredentialSecurityTest test` on JDK 11:
40 tests passed, including Netty leak detection.
- [x] `./mvnw clean verify` on JDK 11: 1,496 tests passed and Revapi
completed without failures (`BUILD SUCCESS`).

Generated with OpenAI Codex.

---------

Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com>
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.

Failed stream request hangs until timeout

2 participants