Description
When the client is configured with useHttpCompression(true) + compressServerResponse(true), it sends Accept-Encoding: lz4 and then assumes the response body is LZ4-framed. Accept-Encoding is only a hint — the server, or any proxy in between (Cloudflare, nginx, an API gateway), is free to return the body uncompressed, typically for small payloads.
HttpAPIClientHelper.wrapResponseEntity does check Content-Encoding for the HTTP-compression branch, but when the header is absent it falls through to an unconditional LZ4 wrap:
https://github.com/ClickHouse/clickhouse-java/blob/main/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java#L1039-L1055
if (httpEntity.getContentEncoding() != null) {
return new CompressedEntity(httpEntity, true, CompressorStreamFactory.getSingleton());
}
// data compression
if (serverCompression && !(httpStatus == SC_FORBIDDEN || httpStatus == SC_UNAUTHORIZED)) {
return new LZ4Entity(httpEntity, useHttpCompression, true, false, buffSize, true, lz4Factory);
}
LZ4Entity.getContent() then does:
https://github.com/ClickHouse/clickhouse-java/blob/main/client-v2/src/main/java/com/clickhouse/client/api/internal/LZ4Entity.java#L52-L63
InputStream content = httpEntity.getContent();
try {
return new FramedLZ4CompressorInputStream(content);
} catch (IOException e) {
// ... "easiest way to handle empty content"
return content;
}
FramedLZ4CompressorInputStream's constructor consumes the 4 signature bytes before deciding the stream is not LZ4-framed. The catch swallows that and returns the same, already-advanced stream. The result is not an error — it is silent data corruption: the first 4 bytes of every uncompressed response are dropped.
This is the Java analogue of ClickHouse/clickhouse-rs#483, but with a worse failure mode (silent truncation rather than a decompression error).
Note this only affects the HTTP-compression mode. In the default mode (useHttpCompression=false) the client uses the compress=1 query parameter, which is ClickHouse's native block compression and is not subject to HTTP content negotiation, so a proxy cannot turn it off.
ClickHouse server version
Verified against server 26.9.6.6 for general sanity; the reproduction itself uses WireMock to stand in for the non-compressing proxy, since the corruption is entirely client-side.
Reproduction
Add to client-v2/src/test/java/com/clickhouse/client/api/:
package com.clickhouse.client.api;
import com.clickhouse.client.api.query.QueryResponse;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.testng.Assert;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* Simulates a proxy that ignores Accept-Encoding: lz4 and returns a plain,
* uncompressed body with no Content-Encoding response header.
*/
public class NoContentEncodingScratchTest {
private static final String BODY = "{\"num\":1}\n{\"num\":2}\n";
private WireMockServer mockServer;
@BeforeMethod
public void setUp() {
mockServer = new WireMockServer(WireMockConfiguration.options().dynamicPort());
mockServer.start();
mockServer.stubFor(WireMock.post(WireMock.anyUrl())
.willReturn(WireMock.aResponse().withStatus(200)
.withHeader("Content-Type", "application/json")
// NOTE: no Content-Encoding header, body is NOT compressed
.withBody(BODY)));
}
@AfterMethod
public void tearDown() {
if (mockServer != null) {
mockServer.stop();
}
}
private String read(Client client) throws Exception {
try (QueryResponse response = client.query("SELECT 1 AS num").get();
InputStream in = response.getInputStream()) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
int n;
while ((n = in.read(buf)) > 0) {
out.write(buf, 0, n);
}
return new String(out.toByteArray(), StandardCharsets.UTF_8);
}
}
@Test
public void testHttpCompressionUncompressedResponse() throws Exception {
try (Client client = new Client.Builder()
.addEndpoint("http://localhost:" + mockServer.port())
.setUsername("default").setPassword("")
.setDefaultDatabase("default")
.compressServerResponse(true)
.useHttpCompression(true)
.build()) {
Assert.assertEquals(read(client), BODY);
}
}
@Test
public void testNoCompressionBaseline() throws Exception {
try (Client client = new Client.Builder()
.addEndpoint("http://localhost:" + mockServer.port())
.setUsername("default").setPassword("")
.setDefaultDatabase("default")
.compressServerResponse(false)
.build()) {
Assert.assertEquals(read(client), BODY);
}
}
}
Run:
mvn -pl client-v2 -Dtest=NoContentEncodingScratchTest test
Result:
[ERROR] Failures:
[ERROR] NoContentEncodingScratchTest.testHttpCompressionUncompressedResponse:68 expected [{"num":1}
{"num":2}
] but found [m":1}
{"num":2}
]
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
- Expected:
{"num":1}\n{"num":2}\n
- Actual:
m":1}\n{"num":2}\n — the leading {"nu (the 4 bytes consumed by the LZ4 frame-signature check) is gone.
The testNoCompressionBaseline case passes, confirming the corruption comes from the compression wrapper and not from the test harness.
Suggested fix
In HttpAPIClientHelper.wrapResponseEntity (client-v2/.../internal/HttpAPIClientHelper.java:1039-1055), when useHttpCompression is true the decision should be driven solely by the response's Content-Encoding header — if it is absent, return the entity unwrapped rather than falling through to the serverCompression LZ4 branch (which is meant for the native compress=1 mode, where useHttpCompression is false).
Independently, the catch (IOException) in LZ4Entity.getContent() (client-v2/.../internal/LZ4Entity.java:57-63) should not return a partially-consumed stream. If that fallback is kept for the empty-body case, the underlying stream needs to be buffered/pushback-wrapped so the signature bytes can be replayed, or the emptiness should be detected explicitly instead of inferred from a thrown exception.
Link
Analogous upstream report: ClickHouse/clickhouse-rs#483
Description
When the client is configured with
useHttpCompression(true)+compressServerResponse(true), it sendsAccept-Encoding: lz4and then assumes the response body is LZ4-framed.Accept-Encodingis only a hint — the server, or any proxy in between (Cloudflare, nginx, an API gateway), is free to return the body uncompressed, typically for small payloads.HttpAPIClientHelper.wrapResponseEntitydoes checkContent-Encodingfor the HTTP-compression branch, but when the header is absent it falls through to an unconditional LZ4 wrap:https://github.com/ClickHouse/clickhouse-java/blob/main/client-v2/src/main/java/com/clickhouse/client/api/internal/HttpAPIClientHelper.java#L1039-L1055
LZ4Entity.getContent()then does:https://github.com/ClickHouse/clickhouse-java/blob/main/client-v2/src/main/java/com/clickhouse/client/api/internal/LZ4Entity.java#L52-L63
FramedLZ4CompressorInputStream's constructor consumes the 4 signature bytes before deciding the stream is not LZ4-framed. Thecatchswallows that and returns the same, already-advanced stream. The result is not an error — it is silent data corruption: the first 4 bytes of every uncompressed response are dropped.This is the Java analogue of ClickHouse/clickhouse-rs#483, but with a worse failure mode (silent truncation rather than a decompression error).
Note this only affects the HTTP-compression mode. In the default mode (
useHttpCompression=false) the client uses thecompress=1query parameter, which is ClickHouse's native block compression and is not subject to HTTP content negotiation, so a proxy cannot turn it off.ClickHouse server version
Verified against server
26.9.6.6for general sanity; the reproduction itself uses WireMock to stand in for the non-compressing proxy, since the corruption is entirely client-side.Reproduction
Add to
client-v2/src/test/java/com/clickhouse/client/api/:Run:
Result:
{"num":1}\n{"num":2}\nm":1}\n{"num":2}\n— the leading{"nu(the 4 bytes consumed by the LZ4 frame-signature check) is gone.The
testNoCompressionBaselinecase passes, confirming the corruption comes from the compression wrapper and not from the test harness.Suggested fix
In
HttpAPIClientHelper.wrapResponseEntity(client-v2/.../internal/HttpAPIClientHelper.java:1039-1055), whenuseHttpCompressionis true the decision should be driven solely by the response'sContent-Encodingheader — if it is absent, return the entity unwrapped rather than falling through to theserverCompressionLZ4 branch (which is meant for the nativecompress=1mode, whereuseHttpCompressionis false).Independently, the
catch (IOException)inLZ4Entity.getContent()(client-v2/.../internal/LZ4Entity.java:57-63) should not return a partially-consumed stream. If that fallback is kept for the empty-body case, the underlying stream needs to be buffered/pushback-wrapped so the signature bytes can be replayed, or the emptiness should be detected explicitly instead of inferred from a thrown exception.Link
Analogous upstream report: ClickHouse/clickhouse-rs#483