Skip to content

Commit 20ef8d3

Browse files
authored
Do not switch to chunked encoding in webclient when the length is known (helidon-io#10828)
* Do not switch to chunked encoding in webclient when the length is known a priori. * If chunked encoding is explicitly requested, we must honor it even if content length is set too.
1 parent 1714bea commit 20ef8d3

File tree

2 files changed

+117
-0
lines changed

2 files changed

+117
-0
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright (c) 2025 Oracle and/or its affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.helidon.jersey.connector;
17+
18+
import java.io.UncheckedIOException;
19+
import java.util.NoSuchElementException;
20+
21+
import io.helidon.http.Header;
22+
import io.helidon.http.HeaderNames;
23+
import io.helidon.http.Status;
24+
import io.helidon.webserver.WebServer;
25+
import io.helidon.webserver.http.HttpRules;
26+
import io.helidon.webserver.http.ServerRequest;
27+
import io.helidon.webserver.http.ServerResponse;
28+
import io.helidon.webserver.testing.junit5.ServerTest;
29+
import io.helidon.webserver.testing.junit5.SetUpRoute;
30+
31+
import jakarta.ws.rs.ProcessingException;
32+
import jakarta.ws.rs.client.Client;
33+
import jakarta.ws.rs.client.ClientBuilder;
34+
import jakarta.ws.rs.client.Entity;
35+
import jakarta.ws.rs.core.MediaType;
36+
import jakarta.ws.rs.core.Response;
37+
import org.glassfish.jersey.client.ClientConfig;
38+
import org.junit.jupiter.api.Test;
39+
40+
import static org.hamcrest.CoreMatchers.instanceOf;
41+
import static org.hamcrest.CoreMatchers.is;
42+
import static org.hamcrest.MatcherAssert.assertThat;
43+
44+
@ServerTest
45+
class ConnectorContentLengthTest {
46+
// must be larger than 8KB used by Jersey for writing
47+
private static final String LARGE_ENTITY = "A".repeat(128 * 1024);
48+
49+
private final String baseURI;
50+
private final Client client;
51+
52+
ConnectorContentLengthTest(WebServer webServer) {
53+
baseURI = "http://localhost:" + webServer.port();
54+
ClientConfig config = new ClientConfig();
55+
config.connectorProvider(HelidonConnectorProvider.create()); // use Helidon's provider
56+
client = ClientBuilder.newClient(config);
57+
}
58+
59+
@SetUpRoute
60+
static void routing(HttpRules rules) {
61+
rules.post("/largeEntity", ConnectorContentLengthTest::largeEntity);
62+
}
63+
64+
static void largeEntity(ServerRequest request, ServerResponse response) {
65+
try {
66+
Header header = request.headers().get(HeaderNames.CONTENT_LENGTH);
67+
request.content().as(String.class); // consume entity
68+
response.status(Status.OK_200).send(header.getString());
69+
} catch (NoSuchElementException e) {
70+
response.status(Status.BAD_REQUEST_400).send();
71+
}
72+
}
73+
74+
@Test
75+
public void testLargeEntity() {
76+
try (Response response = client.target(baseURI)
77+
.path("largeEntity")
78+
.request()
79+
.header("Content-Length", LARGE_ENTITY.length())
80+
.post(Entity.entity(LARGE_ENTITY, MediaType.TEXT_PLAIN_TYPE))) {
81+
assertThat(response.getStatus(), is(200));
82+
String entity = response.readEntity(String.class);
83+
assertThat(entity, is(String.valueOf(LARGE_ENTITY.length())));
84+
}
85+
}
86+
87+
@Test
88+
public void testLargeEntityBadLength() {
89+
try (Response response = client.target(baseURI)
90+
.path("largeEntity")
91+
.request()
92+
.header("Content-Length", LARGE_ENTITY.length() + 1) // incorrect
93+
.post(Entity.entity(LARGE_ENTITY, MediaType.TEXT_PLAIN_TYPE))) {
94+
assertThat(response.getStatus(), is(200));
95+
String entity = response.readEntity(String.class);
96+
assertThat(entity, is(String.valueOf(LARGE_ENTITY.length())));
97+
} catch (ProcessingException e) {
98+
assertThat(e.getCause(), instanceOf(UncheckedIOException.class)); // bad length
99+
}
100+
}
101+
}

webclient/http1/src/main/java/io/helidon/webclient/http1/Http1CallOutputStreamChain.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,17 @@ public void write(byte[] b, int off, int len) throws IOException {
241241
throw new IOException("Output stream already closed");
242242
}
243243

244+
// if not chunked and length known, write directly checking length at close
245+
if (!chunked && contentLength > 0) {
246+
if (!whenSent.isDone()) {
247+
sendPrologueAndHeader();
248+
noData = false;
249+
}
250+
writeContent(BufferData.create(b, off, len));
251+
return;
252+
}
253+
254+
// if length not known, try to optimize for single write
244255
if (!chunked) {
245256
if (firstPacket == null) {
246257
BufferData first = BufferData.create(len - off);
@@ -279,6 +290,11 @@ public void close() throws IOException {
279290
ctx.log(LOGGER_REQ_ENTITY, System.Logger.Level.TRACE, "send data%n%s", terminating.debugDataHex());
280291
}
281292
writer.write(terminating);
293+
} else if (contentLength > 0) {
294+
if (contentLength != bytesWritten) {
295+
throw new IOException("Content length is set to " + contentLength
296+
+ ", but the number of bytes written was " + bytesWritten);
297+
}
282298
} else {
283299
headers.remove(HeaderNames.TRANSFER_ENCODING);
284300
if (noData) {

0 commit comments

Comments
 (0)