Skip to content

fix(netty): preserve all redirect body types - #2316

Open
mkurz wants to merge 1 commit into
AsyncHttpClient:mainfrom
mkurz:fix/redirect-body-replay
Open

fix(netty): preserve all redirect body types#2316
mkurz wants to merge 1 commit into
AsyncHttpClient:mainfrom
mkurz:fix/redirect-body-replay

Conversation

@mkurz

@mkurz mkurz commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • Preserve composite byte arrays, ByteBuf, InputStream, and File bodies when following redirects whose policy retains the request body.
  • Select the same body representation that NettyRequestFactory selected for the original request.
  • Replay resettable streams and fail promptly for consumed streams that cannot be reset, using the existing replay guard.
  • Pin the four omissions byte-for-byte while retaining ByteBuf ownership, form-parameter replay, and multipart replay.

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.

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

Change

Extract the redirect body copy into one helper and keep its selection order aligned with NettyRequestFactory.body. This matters because a Request can retain a File or BodyGenerator alongside another representation; the redirect must copy the representation that the original request actually sent.

Composite byte arrays, caller-owned ByteBufs, and files are repeatable. A resettable InputStream, such as ByteArrayInputStream, also replays. A consumed stream that cannot be reset now 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.

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 change: 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. Callers that accidentally relied on the empty request will observe an exception, but the failure is explicit instead of silently losing the configured content.

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

  • 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.
  • ./mvnw -pl client -Dtest=RedirectBodyTest test on JDK 11: 12 tests passed, including Netty leak detection.
  • ./mvnw clean verify on JDK 11: BUILD SUCCESS (full reactor, including tests, Javadocs, artifact signing, coverage, and Revapi).

Generated with OpenAI Codex.

Redirect30xInterceptor copied only six request-body representations when a
redirect retained the body. Composite byte arrays, ByteBufs, InputStreams,
and Files therefore became empty requests on 307, 308, and strict 302
redirects.

Copy the representation selected for the original request, following
NettyRequestFactory's precedence. Resettable streams can then replay, while
non-resettable streams fail promptly through the existing replay guard.

Cover all four omissions byte-for-byte, pin coexistence precedence, retain
the caller-owned ByteBuf, and keep form and multipart replay behavior.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
@mkurz

mkurz commented Aug 26, 2026

Copy link
Copy Markdown
Author

We use ahc in https://github.com/playframework/play-ws and I am in the process of upgrading to v3 - and found some thing worth adressing.

private static void copyBody(RequestBuilder requestBuilder, Request request) {
requestBuilder.setCharset(request.getCharset());

// Keep this precedence aligned with NettyRequestFactory.body. A Request can retain a File or

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.

Can we build this from request.toBuilder() and override method, uri, headers and realm after? RequestBuilderBase already copies all the body fields, plus charset, rangeOffset and readTimeout.

The builder above copies setRequestTimeout but not setReadTimeout, so a per-request read timeout silently falls back to the config default after the first redirect. toBuilder() would fix that too, and this wouldn't drift out of sync with NettyRequestFactory.body again.

requestBuilder.setBody(request.getByteBufferData());
} else if (request.getByteBufData() != null) {
requestBuilder.setBody(request.getByteBufData());
} else if (request.getStreamData() != null) {

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.

WriteProgressListener closes the stream after the first leg, so this only replays if close() is a no-op. ByteArrayInputStream is fine, Files.newInputStream(...) and anything buffered will fail the reset() in NettyInputStreamBody.

We used to send an empty body on the redirect leg, so this turns a silent bug into an IOException for the common case. That's the right trade I think, but it needs a release note, and a test with a real FileInputStream and not just ByteArrayInputStream.

} else if (request.getByteBufData() != null) {
requestBuilder.setBody(request.getByteBufData());
} else if (request.getStreamData() != null) {
requestBuilder.setBody(request.getStreamData());

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.

propagatedHeaders removes CONTENT_LENGTH and the rebuilt NettyInputStreamBody doesn't have a length, so a stream that went out with Content-Length on the first leg goes out chunked on the second. Some targets won't accept a chunked request body. Can we carry the original length over?

requestBuilder.setBody(request.getStreamData());
} else if (isNonEmpty(request.getFormParams())) {
requestBuilder.setFormParams(request.getFormParams());
} else if (isNonEmpty(request.getBodyParts())) {

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.

InputStreamMultipartPart closes its stream once it has written it, and we don't have a consumed check here like the one NettyInputStreamBody got in #2312.

So for an InputStreamPart the replayed request still advertises the declared Content-Length, but transferContentTo sees -1 straight away and writes nothing. The server then waits for a body that never arrives. Should we detect the parts we can't replay and fail fast the same way?

requestBuilder.setFormParams(request.getFormParams());
} else if (isNonEmpty(request.getBodyParts())) {
requestBuilder.setBodyParts(request.getBodyParts());
} else if (request.getFile() != null) {

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.

Today the File is dropped on redirect, so a 307 to another origin sends nothing. After this we upload the whole file to whatever host Location points at, and a hostile target can bounce us maxRedirects times to get several copies.

Same origin is fine. Cross origin I'd like to be a deliberate decision, and same for getStreamData() above.

} else if (isNonEmpty(request.getBodyParts())) {
requestBuilder.setBodyParts(request.getBodyParts());
} else if (request.getFile() != null) {
requestBuilder.setBody(request.getFile());

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.

NettyFileBody throws IllegalArgumentException if the file is gone by now, and we're on the event loop here. Nothing in newNettyRequestAndResponseFuture catches it, so it ends up in exceptionCaught after drainChannelAndOffer already gave the old channel back to the pool, and then we close a connection another request might be using. Check the file here and abort the future with an IOException instead?

ExecutionException thrown = assertThrows(ExecutionException.class,
() -> execute307(c.preparePost(getTargetUrl()).setBody(body)));

assertInstanceOf(IOException.class, thrown.getCause());

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.

Any channel-level failure is an IOException too, so this still passes if the replay guard in NettyInputStreamBody goes away. Assert on the message instead. The FilterInputStream is also never closed.

public void multipart307KeepsBody() throws Exception {
try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
Response response = c.preparePost(getTargetUrl())
.addBodyPart(new StringPart("field", "multipart value"))

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.

StringPart is replayable so this passes either way. The one that breaks is InputStreamPart with a declared length, please add that one.

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