Skip to content
Merged
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
18 changes: 12 additions & 6 deletions client/src/main/java/org/asynchttpclient/Response.java
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,19 @@
* body part's own array, it is the array reachable from the part handed to
* {@link AsyncHandler#onBodyPartReceived}, the one each
* {@link org.asynchttpclient.handler.TransferListener} is given by a
* {@link org.asynchttpclient.handler.TransferCompletionHandler}, and the one
* {@link org.asynchttpclient.handler.TransferCompletionHandler}, the one
* {@link HttpResponseBodyPart#getBodyByteBuffer()} wraps for a
* {@link org.asynchttpclient.handler.resumable.ResumableListener}, and the one
* {@link #getResponseBodyAsByteBuf()} wraps. Writing to it changes what all of those see, and a write
* through any of them changes what this returns.
*
* <p>Whether anything is shared at all is not something to rely on. It depends on how the body happened to
* arrive - how the origin chunked it, whether a proxy re-chunked it, whether it was compressed - and on the
* body parts the implementation was given, none of which is visible from here. The same body from the
* same server may be shared on one response and copied on the next. No array identity is guaranteed between
* calls either.
* arrive - how the origin chunked it, whether a proxy re-chunked it, whether it was compressed - on how the
* client happened to read it off the wire, which varies with connection age and client configuration, and on
* the body parts the implementation was given. The same body from the same server may be shared on one
* response and copied on the next. Which of the two a caller gets is the peer's choice rather than the
* caller's, so code that writes to the array can behave one way against a friendly server and another way
* against a hostile one. No array identity is guaranteed between calls either.
*
* <p>A caller that needs an array it may modify should copy what it receives. {@link
* #getResponseBodyAsBytes()} is the accessor to reach for first, but it is implemented by whoever implements
Expand Down Expand Up @@ -139,7 +143,7 @@
String getContentType();

/**
* @param name the header name

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / compile-and-check

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 21)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 17)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 25)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 11)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 21)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 17)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 25)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 146 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 11)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.
* @return the first response header value
*/
String getHeader(CharSequence name);
Expand Down Expand Up @@ -170,7 +174,7 @@
String toString();

/**
* @return the list of {@link Cookie}.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / compile-and-check

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 21)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 17)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 25)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 11)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 21)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 17)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 25)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.

Check warning on line 177 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 11)

[MissingSummary] A summary fragment is required; consider using the value of the @return block as a summary fragment instead.
*/
List<Cookie> getCookies();

Expand Down Expand Up @@ -239,7 +243,7 @@
}

/**
* @param bodyPart a body part (possibly empty, but will be filtered out)

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / compile-and-check

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 21)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 17)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 25)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest, 11)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 21)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 17)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 25)

[MissingSummary] A summary line is required on public/protected Javadocs.

Check warning on line 246 in client/src/main/java/org/asynchttpclient/Response.java

View workflow job for this annotation

GitHub Actions / test (macos-latest, 11)

[MissingSummary] A summary line is required on public/protected Javadocs.
*/
public void accumulate(HttpResponseBodyPart bodyPart) {
if (bodyPart.length() > 0) {
Expand All @@ -253,7 +257,9 @@
* @return a {@link Response} instance
*/
public @Nullable Response build() {
return status == null ? null : new NettyResponse(status, headers, bodyParts);
// A copy: reset() clears this list, and a builder may build more than once, so a response that held
// the builder's own list would lose its body or take on the next response's.
return status == null ? null : new NettyResponse(status, headers, new ArrayList<>(bodyParts));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ public byte[] getResponseBodyAsBytes() {
}

/**
* {@inheritDoc}
* <p>
* Returns a lone body part's array; concatenates into one of its own when there are several, or an empty
* array when there are none. Which of those a given response takes is not a property of the body: see
* {@link Response#getResponseBodyAsBytesView()}, whose contract is deliberately weaker than this.
Expand Down Expand Up @@ -228,7 +230,9 @@ public ByteBuffer getResponseBodyAsByteBuffer() {

@Override
public ByteBuf getResponseBodyAsByteBuf() {
CompositeByteBuf compositeByteBuf = ByteBufAllocator.DEFAULT.compositeBuffer(bodyParts.size());
// At least one component: CompositeByteBuf rejects a maxNumComponents of 0, so a bodyless response
// would otherwise throw here rather than return an empty buffer.
CompositeByteBuf compositeByteBuf = ByteBufAllocator.DEFAULT.compositeBuffer(Math.max(1, bodyParts.size()));
for (HttpResponseBodyPart part : bodyParts) {
compositeByteBuf.addComponent(true, part.getBodyByteBuf());
}
Expand All @@ -249,15 +253,15 @@ public String getResponseBody() {
* documents it as read-only and names the other holders. {@link #getResponseBodyAsBytes()} stays the
* copying accessor for callers who want an array of their own.
* <p>
* Private, and called directly by the accessors below rather than through
* Private, and called directly by the string accessors rather than through
* {@link #getResponseBodyAsBytesView()}, so that overriding the view does not silently change what this
* response's text says as well.
*/
private byte[] sharedBodyBytes() {
if (bodyParts.isEmpty()) {
// A HEAD, a 204 or a 304 otherwise walks the aggregating path to allocate an empty array and a
// buffer to wrap it, on every call. Nothing can be written through a zero-length array, so one
// shared instance serves every empty body.
// A response with no body - a HEAD, a 204, a 304, a discarded CONNECT failure, one aborted from a
// handler - otherwise walks the aggregating path to allocate an empty array and a buffer to wrap it,
// on every call. Nothing can be written through a zero-length array, so one instance serves them all.
return EMPTY_BODY;
}
return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes();
Expand Down
75 changes: 75 additions & 0 deletions client/src/test/java/org/asynchttpclient/ResponseBuilderTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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;

import io.netty.buffer.Unpooled;
import org.asynchttpclient.netty.EagerResponseBodyPart;
import org.asynchttpclient.netty.NettyResponseStatus;
import org.junit.jupiter.api.Test;

import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class ResponseBuilderTest {

private static void accumulateBody(Response.ResponseBuilder builder, String body) {
builder.accumulate(new NettyResponseStatus(null, null, null));
builder.accumulate(new EagerResponseBodyPart(Unpooled.wrappedBuffer(body.getBytes(StandardCharsets.UTF_8)), true));
}

@Test
public void testAResponseKeepsItsBodyWhenTheBuilderIsReused() {
// AsyncCompletionHandler resets its builder on every onStatusReceived, so a handler instance reused for
// a second request refills the same list. A response holding that list would take on the second body.
Response.ResponseBuilder builder = new Response.ResponseBuilder();
accumulateBody(builder, "first");
Response first = builder.build();

builder.reset();
accumulateBody(builder, "second");
Response second = builder.build();

assertEquals("first", first.getResponseBody(StandardCharsets.UTF_8));
assertEquals("second", second.getResponseBody(StandardCharsets.UTF_8));
}

@Test
public void testAResponseKeepsItsBodyWhenTheBuilderAccumulatesAgain() {
// Without a reset in between, a further part must not appear in a response that was already built.
Response.ResponseBuilder builder = new Response.ResponseBuilder();
accumulateBody(builder, "first");
Response first = builder.build();

builder.accumulate(new EagerResponseBodyPart(Unpooled.wrappedBuffer(" second".getBytes(StandardCharsets.UTF_8)), true));

assertEquals("first", first.getResponseBody(StandardCharsets.UTF_8));
assertEquals("first second", builder.build().getResponseBody(StandardCharsets.UTF_8));
}

@Test
public void testAResponseKeepsItsBodyWhenTheBuilderIsResetAndNotRebuilt() {
Response.ResponseBuilder builder = new Response.ResponseBuilder();
accumulateBody(builder, "first");
Response first = builder.build();

builder.reset();

assertTrue(first.hasResponseBody());
assertEquals("first", first.getResponseBody(StandardCharsets.UTF_8));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,15 @@ public void testGetResponseBodyDecodesOnePartAndSplitPartsIdentically() {
// 0xC3 0xA9 encodes U+00E9; split between its two bytes so neither half decodes on its own
int split = 4;

// Clones onto the wire, so that utf8 stays an oracle rather than becoming a part's own storage.
List<HttpResponseBodyPart> onePart = new LinkedList<>();
onePart.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8), true));
onePart.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8.clone()), true));
NettyResponse single = new NettyResponse(new NettyResponseStatus(null, null, null), null, onePart);

byte[] wire = utf8.clone();
List<HttpResponseBodyPart> splitParts = new LinkedList<>();
splitParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8, 0, split), false));
splitParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8, split, utf8.length - split), true));
splitParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(wire, 0, split), false));
splitParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(wire, split, wire.length - split), true));
NettyResponse multiple = new NettyResponse(new NettyResponseStatus(null, null, null), null, splitParts);

assertEquals(expected, single.getResponseBody(StandardCharsets.UTF_8));
Expand Down Expand Up @@ -182,7 +184,39 @@ public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() {
public void testGetResponseBodyAsBytesViewReturnsEmptyArray() {
NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, new LinkedList<>());

assertArrayEquals(new byte[0], response.getResponseBodyAsBytesView());
byte[] view = response.getResponseBodyAsBytesView();
assertArrayEquals(new byte[0], view);
// One shared instance rather than an allocation per call, which is the whole point of the branch.
assertSame(view, response.getResponseBodyAsBytesView());
}

@Test
public void testBodylessResponseHasAnEmptyByteBuf() {
// A CompositeByteBuf rejects a maxNumComponents of 0, so a HEAD, a 204 or a 304 threw from here.
NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, new LinkedList<>());

ByteBuf body = response.getResponseBodyAsByteBuf();
try {
assertEquals(0, body.readableBytes());
} finally {
body.release();
}
}

@Test
public void testOverridingTheViewLeavesTheResponseTextAlone() {
// The string accessors go through a private helper rather than the overridable view, so a subclass that
// hardens the view cannot silently change what this response's text says.
List<HttpResponseBodyPart> bodyParts = new LinkedList<>();
bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer("Hello World".getBytes(StandardCharsets.UTF_8)), true));
NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, bodyParts) {
@Override
public byte[] getResponseBodyAsBytesView() {
return "Goodbye".getBytes(StandardCharsets.UTF_8);
}
};

assertEquals("Hello World", response.getResponseBody(StandardCharsets.UTF_8));
}

@Test
Expand Down
Loading