Skip to content

Provide an opt-in zero-copy response body view - #2322

Open
pavel-ptashyts wants to merge 2 commits into
AsyncHttpClient:mainfrom
maygemdev:perf/zero-copy-response-body-view
Open

Provide an opt-in zero-copy response body view#2322
pavel-ptashyts wants to merge 2 commits into
AsyncHttpClient:mainfrom
maygemdev:perf/zero-copy-response-body-view

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Summary

  • add Response#getResponseBodyAsBytesView() as a compatible default method
    with an explicit possibly-shared, read-only contract
  • return the existing byte array for a single eager Netty body part
  • use the body part accessor for lazy/direct/sliced buffers, avoiding a second
    aggregate copy without exposing ByteBuf storage
  • preserve defensive-copy behavior for getResponseBodyAsBytes(),
    getResponseBodyAsByteBuffer(), and getResponseBodyAsStream()
  • reuse the new accessor for string decoding so the fast path from Decode a lone response body part in place #2303 stays
    centralized

Closes #2321.

Compatibility and safety

The default implementation delegates to getResponseBodyAsBytes(), so existing
third-party Response implementations keep their current behavior. Netty uses
the optimized path only for exactly one body part. Empty and multipart bodies
continue through the existing aggregation path.

Tests cover eager identity and repeated access, defensive-copy isolation,
heap and direct lazy slices, reader/writer indices, reference counts, empty
bodies, multipart ordering and split UTF-8 characters, stream isolation, and
default-method delegation.

Verification

  • mvnw.cmd -B -ntp -pl client -Dtest=NettyAsyncResponseTest test
  • mvnw.cmd clean verify
    • Amazon Corretto 11.0.32.1
    • 1,489 tests; 0 failures; 0 errors; 26 skipped
    • Error Prone, NullAway, Javadocs, GPG signing, and Revapi passed

Allocation benchmark

JMH 1.37, Corretto 11.0.32.1, one fork, three 500 ms warmups, five
500 ms measurements, gc profiler. Values are allocated bytes per operation,
rounded to whole bytes; zero means below the profiler's resolution.

Body shape getResponseBodyAsBytes() getResponseBodyAsBytesView()
Empty 16 B/op 16 B/op
1 eager part, 512 B 576 B/op 0 B/op
1 eager part, 4 KiB 4,160 B/op 0 B/op
1 eager part, 16 KiB 16,448 B/op 0 B/op
1 eager part, 128 KiB 131,136 B/op 0 B/op
1 lazy direct part, 4 KiB 8,272 B/op 4,112 B/op
1 lazy direct part, 16 KiB 32,848 B/op 16,400 B/op
2 eager parts, 4 KiB each 8,256 B/op 8,208 B/op
8 eager parts, 4 KiB each 32,832 B/op 32,832 B/op

The eager single-part view measured about 2.3 ns/op regardless of body size,
versus 322 ns/op for 4 KiB and 11.0 us/op for 128 KiB defensive copies. Lazy
single-part allocation is halved as expected. Multipart bodies retain the
payload-sized aggregation allocation.

Codex on behalf of Pavel Ptashyts

pavel-ptashyts and others added 2 commits August 31, 2026 12:28
Byte-array consumers currently pay for an aggregate copy even when a
response has a single body part. Add an explicit read-only view accessor
so callers can opt into sharing while the existing accessor retains its
defensive-copy contract.

Reuse the view for string decoding and cover eager, lazy, multipart, and
third-party Response implementations.

Refs AsyncHttpClient#2321

Codex on behalf of Pavel Ptashyts

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Mockito 4 cannot invoke interface default methods through
CALLS_REAL_METHODS on JDK 21 and newer. Invoke the Response default
method explicitly so delegation remains covered on every supported JDK.

Refs AsyncHttpClient#2321

Codex on behalf of Pavel Ptashyts

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

@Override
public byte[] getResponseBodyAsBytesView() {
return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes();

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.

bodyParts.size() == 1 is not a property of the response, it is the number of non-empty chunks we happened to read. The same 48 byte body handed over in one chunk gives the caller our array, in two chunks gives them a copy. Any proxy that re-chunks flips it. gzip flips it the other way, because the decompressor output does not get split at httpClientCodecMaxChunkSize, so a 20000 byte body arrives as 4 parts uncompressed and 1 part compressed.

It is not even stable for a fixed server. On a fresh connection the split is at 2048 bytes, on a warm pooled one it is around 8192, so the caller gets a copy on request 1 and our array on request 2. A test that makes one client per test with a 4 KiB fixture never sees the sharing path.

}

@Override
public byte[] getResponseBodyAsBytesView() {

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 array is not only ours. EagerResponseBodyPart returns its bytes field by identity, and that is the same array we already passed to the user's AsyncHandler.onBodyPartReceived, the same one TransferCompletionHandler gives to every TransferListener, and the one ResumableAsyncHandler wraps writable in a ByteBuffer. So the caller can corrupt those, and they can corrupt the caller's view after we returned it.

@Override
public String getResponseBody(Charset charset) {
return new String(sharedBodyBytes(), charset);
return new String(getResponseBodyAsBytesView(), charset);

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 used to go through the private sharedBodyBytes(). Now it is a virtual call to a public method on a non-final class, so any subclass that overrides the view silently changes getResponseBody(), getResponseBody(Charset) and toString() as well, and the String and the bytes of one response can disagree. getStatusCode, getHeaders and isRedirected right next to it are final. Can we keep a private helper here and leave the public method for callers?

return getResponseBody(withDefault(extractContentTypeCharsetAttribute(getContentType()), UTF_8));
}

/**

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 comment was carrying two things. The reason multi part gets concatenated, a multi byte char can straddle a part boundary, which is the only place that is written down. And the invariant from #2303, that the array does not escape the method. #2303 landed a month ago and its tests are still in this file. If we are reversing it, the PR description should say why instead of just deleting the comment.

/**
* Returns the entire response body as a byte array that may share its storage with this response.
*
* <p>The returned array must be treated as read-only. Modifying it may change the content subsequently returned

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 response's other body accessors" is too narrow. The array belongs to the HttpResponseBodyPart, not to the response, and it is reachable from the handler callback, from TransferListener and from getResponseBodyAsByteBuf(). It also goes the other way: bb.setByte(10, '9') on that ByteBuf changes what the caller's read-only view says. Worth spelling out who the other holders are.

@Test
public void testGetResponseBodyAsBytesViewReadsDirectLazyPart() {
byte[] backing = "XXXHello WorldYYY".getBytes(StandardCharsets.UTF_8);
ByteBuf direct = Unpooled.directBuffer(backing.length);

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.

direct is allocated, written and sliced before the try, so anything throwing in there leaks a direct buffer and the leak detector fails the build. Open the try right after the allocation.

assertEquals("Hello World",
new String(response.getResponseBodyAsStream().readAllBytes(), StandardCharsets.UTF_8));
byte[] view = response.getResponseBodyAsBytesView();
assertSame(bodyParts.get(0).getBodyPartBytes(), view);

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 javadoc says no array identity is guaranteed between calls and that the implementation may return a copy, and then we assert both of those here. Swap the fixture to LazyResponseBodyPart and the same assertion fails. One of the two has to give, otherwise the next person cannot tell which one is the contract.


@Test
public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() {
byte[] expected = "Hello World".getBytes(StandardCharsets.UTF_8);

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.

expected is both the array we wrap into the part and the oracle we compare against. It holds today only because EagerResponseBodyPart copies. Add the obvious hasArray() fast path there and expected becomes the part's live storage, so firstCopy[0] = 'X' mutates it too and both assertions pass on a corrupt response. Unpooled.wrappedBuffer(expected.clone()) keeps it honest.

}

@Test
public void testGetResponseBodyAsBytesViewReadsDirectLazyPart() {

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 state cannot happen on a real request. AsyncHttpClientHandler.channelRead releases the message in a finally as soon as onBodyPartReceived returns and LazyResponseBodyPart never retains, so by the time anyone holds a Response the buffer is at refCnt 0 and this call throws IllegalReferenceCountException. With -Dio.netty.buffer.checkAccessible=false it does not throw, it returns recycled pool memory instead. The test keeps the buffer alive by hand, which makes LAZY look supported when it is not. Not something you introduced, but adding coverage here signs off on it.

@Override
public byte[] getResponseBodyAsBytesView() {
return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes();
}

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: an empty body still goes down the aggregating path and allocates a byte[0] plus a ByteBuffer on every call. HEAD, 204 and 304 are common enough that a static final byte[] EMPTY short circuit is worth it, and it is the one case here that really is zero allocation.

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.

Provide an opt-in zero-copy response body byte-array view

2 participants