From 5559e74b32afa1eb92c53cfccee1a4e74245fb68 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:49:08 +0100 Subject: [PATCH 1/4] Drop undecodable codings from Accept-Encoding With automatic decompression on, a caller-set Accept-Encoding is meant to lose br and zstd when their native libraries are missing. Three bugs defeated that: - The zstd filter ran on the original value and overwrote the Brotli result, so with both libraries missing (both are optional dependencies) br was offered again. This dates from #1950. - Each filter only cut a trailing ", br" or ", zstd". The browser-style "gzip, deflate, br, zstd" kept br even with the filters chained, and so did a coding listed first, without a space, in another case or with a weight. - Only the first Accept-Encoding field was read, and whenever either library was missing the header was replaced by that field alone, dropping the caller's other fields. A server that picks br or zstd then sends a body AHC cannot decode: the response decompressor leaves such a body as it is, so the handler receives compressed bytes where it expects decoded ones. Filter each list element by its coding token, chain the filters on the running value, and filter all fields as one list, setting the header only when something was dropped. The check moves into a package-private helper so tests can drive it with both libraries missing, as both are on the test classpath. Claude Code on behalf of Pavel Ptashyts. Co-Authored-By: Claude Opus 5.5 --- .../netty/request/NettyRequestFactory.java | 36 ++++++---- .../org/asynchttpclient/util/HttpUtils.java | 38 ++++++++--- ...NettyRequestFactoryAcceptEncodingTest.java | 68 +++++++++++++++++++ .../asynchttpclient/util/HttpUtilsTest.java | 38 +++++++++++ 4 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java index b5ea972ab..d3f123359 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java @@ -355,19 +355,10 @@ public NettyRequest newNettyRequest(Request request, boolean performConnectReque // Unconditional: it also folds several caller-set Cookie fields into one. mergeCookies(request.getCookies(), headers); - String userDefinedAcceptEncoding = headers.get(ACCEPT_ENCODING); - if (userDefinedAcceptEncoding != null) { + if (headers.contains(ACCEPT_ENCODING)) { + // For manual decompression by the user any encoding may suit, so leave it untouched if (config.isEnableAutomaticDecompression()) { - if (!Brotli.isAvailable()) { - // Brotli is not available. - // For manual decompression by user, any encoding may suite, so leave untouched - headers.set(ACCEPT_ENCODING, filterOutBrotliFromAcceptEncoding(userDefinedAcceptEncoding)); - } - if (!Zstd.isAvailable()) { - // zstd is not available. - // For manual decompression by user, any encoding may suit, so leave untouched - headers.set(ACCEPT_ENCODING, filterOutZstdFromAcceptEncoding(userDefinedAcceptEncoding)); - } + filterOutUndecodableAcceptEncodings(headers, Brotli.isAvailable(), Zstd.isAvailable()); } } else if (config.isCompressionEnforced()) { // Add Accept Encoding header if compression is enforced @@ -487,4 +478,25 @@ private static CharSequence connectionHeader(boolean keepAlive, HttpVersion http return keepAlive ? HttpHeaderValues.KEEP_ALIVE : null; } } + + /** + * Without its library a coding would reach the user still compressed, so it is not offered. Several fields are + * filtered as their comma-joined list, which means the same (RFC 9110 section 5.3). + */ + static void filterOutUndecodableAcceptEncodings(HttpHeaders headers, boolean brotliAvailable, boolean zstdAvailable) { + if (brotliAvailable && zstdAvailable) { + return; + } + String acceptEncoding = String.join(", ", headers.getAll(ACCEPT_ENCODING)); + String filtered = acceptEncoding; + if (!brotliAvailable) { + filtered = filterOutBrotliFromAcceptEncoding(filtered).toString(); + } + if (!zstdAvailable) { + filtered = filterOutZstdFromAcceptEncoding(filtered).toString(); + } + if (!filtered.equals(acceptEncoding)) { + headers.set(ACCEPT_ENCODING, filtered); + } + } } diff --git a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java index 2b970595b..e353701f4 100644 --- a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java @@ -41,8 +41,6 @@ public final class HttpUtils { public static final AsciiString GZIP_DEFLATE_HPACK = new AsciiString(HttpHeaderValues.GZIP + ", " + HttpHeaderValues.DEFLATE); private static final String CONTENT_TYPE_CHARSET_ATTRIBUTE = "charset="; private static final String CONTENT_TYPE_BOUNDARY_ATTRIBUTE = "boundary="; - private static final String BROTLY_ACCEPT_ENCODING_SUFFIX = ", br"; - private static final String ZSTD_ACCEPT_ENCODING_SUFFIX = ", zstd"; private HttpUtils() { // Prevent outside initialization @@ -179,18 +177,36 @@ private static void encodeAndAppendFormField(StringBuilder sb, String field, Cha } public static CharSequence filterOutBrotliFromAcceptEncoding(String acceptEncoding) { - // we don't support Brotly ATM - if (acceptEncoding.endsWith(BROTLY_ACCEPT_ENCODING_SUFFIX)) { - return acceptEncoding.subSequence(0, acceptEncoding.length() - BROTLY_ACCEPT_ENCODING_SUFFIX.length()); - } - return acceptEncoding; + return filterOutAcceptEncoding(acceptEncoding, HttpHeaderValues.BR); } public static CharSequence filterOutZstdFromAcceptEncoding(String acceptEncoding) { - // we don't support zstd ATM - if (acceptEncoding.endsWith(ZSTD_ACCEPT_ENCODING_SUFFIX)) { - return acceptEncoding.subSequence(0, acceptEncoding.length() - ZSTD_ACCEPT_ENCODING_SUFFIX.length()); + return filterOutAcceptEncoding(acceptEncoding, HttpHeaderValues.ZSTD); + } + + /** + * Drops every element naming {@code coding}, which may appear anywhere in the list, in any case and with a weight + * (RFC 9110 section 12.5.3). Dropping the last element leaves an empty value, which accepts no content coding. + */ + private static String filterOutAcceptEncoding(String acceptEncoding, AsciiString coding) { + if (!AsciiString.containsIgnoreCase(acceptEncoding, coding)) { + return acceptEncoding; + } + StringBuilder kept = new StringBuilder(acceptEncoding.length()); + boolean dropped = false; + for (String element : acceptEncoding.split(",")) { + String trimmed = element.trim(); + int semicolon = trimmed.indexOf(';'); + String token = semicolon < 0 ? trimmed : trimmed.substring(0, semicolon).trim(); + if (coding.contentEqualsIgnoreCase(token)) { + dropped = true; + } else if (!trimmed.isEmpty()) { + if (kept.length() > 0) { + kept.append(", "); + } + kept.append(trimmed); + } } - return acceptEncoding; + return dropped ? kept.toString() : acceptEncoding; } } diff --git a/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java b/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java new file mode 100644 index 000000000..77bc1f658 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java @@ -0,0 +1,68 @@ +/* + * 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.netty.request; + +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpHeaders; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static io.netty.handler.codec.http.HttpHeaderNames.ACCEPT_ENCODING; +import static org.asynchttpclient.netty.request.NettyRequestFactory.filterOutUndecodableAcceptEncodings; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Brotli and zstd are both on the test classpath, so the availability flags are passed in rather than taken from + * Netty's {@code Brotli.isAvailable()} and {@code Zstd.isAvailable()}. + */ +public class NettyRequestFactoryAcceptEncodingTest { + + @Test + public void dropsBothWhenNeitherIsAvailable() { + assertEquals(List.of("gzip, deflate"), filter(false, false, "gzip, deflate, br, zstd")); + assertEquals(List.of("gzip, deflate"), filter(false, false, "gzip, deflate, zstd, br")); + } + + @Test + public void dropsOnlyTheUnavailableOne() { + assertEquals(List.of("gzip, deflate, zstd"), filter(false, true, "gzip, deflate, br, zstd")); + assertEquals(List.of("gzip, deflate, br"), filter(true, false, "gzip, deflate, br, zstd")); + } + + @Test + public void leavesHeaderUntouchedWhenBothAreAvailable() { + assertEquals(List.of("gzip", "br, zstd"), filter(true, true, "gzip", "br, zstd")); + } + + @Test + public void leavesHeaderUntouchedWhenNothingIsDropped() { + assertEquals(List.of("gzip", "deflate"), filter(false, false, "gzip", "deflate")); + } + + @Test + public void filtersEveryHeaderFieldAndKeepsTheOthers() { + assertEquals(List.of("gzip, deflate"), filter(false, false, "gzip", "br", "deflate, zstd")); + } + + private static List filter(boolean brotliAvailable, boolean zstdAvailable, String... acceptEncodings) { + HttpHeaders headers = new DefaultHttpHeaders(); + headers.add(ACCEPT_ENCODING, Arrays.asList(acceptEncodings)); + filterOutUndecodableAcceptEncodings(headers, brotliAvailable, zstdAvailable); + return headers.getAll(ACCEPT_ENCODING); + } +} diff --git a/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java b/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java index 06414d864..a7aed504d 100644 --- a/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java +++ b/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java @@ -185,4 +185,42 @@ public void computeOriginForSecuredUriWithDefaultPort() { public void computeOriginForSecuredUriWithNonDefaultPort() { assertEquals("https://foo.com:444", HttpUtils.originHeader(Uri.create("wss://foo.com:444/bar"))); } + + @Test + public void filterOutBrotliDropsTheCodingWherever() { + assertEquals("gzip, deflate", filterOutBrotli("gzip, deflate, br")); + assertEquals("gzip, deflate, zstd", filterOutBrotli("gzip, deflate, br, zstd")); + assertEquals("gzip, deflate", filterOutBrotli("br, gzip, deflate")); + assertEquals("gzip, deflate", filterOutBrotli("gzip,br,deflate")); + assertEquals("", filterOutBrotli("br")); + } + + @Test + public void filterOutBrotliIgnoresCaseWhitespaceAndWeight() { + assertEquals("gzip;q=0.8, deflate", filterOutBrotli("gzip;q=0.8, BR ; q=1.0 , deflate")); + assertEquals("gzip", filterOutBrotli("gzip, Br;q=0.5")); + } + + @Test + public void filterOutBrotliKeepsOtherCodings() { + assertEquals("gzip,deflate", filterOutBrotli("gzip,deflate")); + assertEquals("gzip, brotli, x-br", filterOutBrotli("gzip, brotli, x-br")); + } + + @Test + public void filterOutZstdDropsTheCodingWherever() { + assertEquals("gzip, deflate", filterOutZstd("gzip, deflate, zstd")); + assertEquals("gzip, deflate, br", filterOutZstd("gzip, deflate, zstd, br")); + assertEquals("gzip;q=0.8, br", filterOutZstd("ZSTD;q=1, gzip;q=0.8, br")); + assertEquals("gzip, zstd-x", filterOutZstd("gzip, zstd-x")); + assertEquals("", filterOutZstd(" zstd ")); + } + + private static String filterOutBrotli(String acceptEncoding) { + return HttpUtils.filterOutBrotliFromAcceptEncoding(acceptEncoding).toString(); + } + + private static String filterOutZstd(String acceptEncoding) { + return HttpUtils.filterOutZstdFromAcceptEncoding(acceptEncoding).toString(); + } } From 93f51c00ffae5e937fa742946f045f60086629fa Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:13:18 +0100 Subject: [PATCH 2/4] Keep a zero-weight refusal in Accept-Encoding The filters dropped every element naming br or zstd, including one with weight 0. Such an element refuses the coding, so dropping it next to a wildcard turned "*, br;q=0" into "*" and let a server pick the coding AHC cannot decode, while main left that header alone. Elements with q=0, in any spelling RFC 9110 allows, are now kept. A single Accept-Encoding field, the common case, is also no longer copied through String.join on every request when a library is missing. Found by the independent review of #2381. Claude Code on behalf of Pavel Ptashyts. Co-Authored-By: Claude Opus 5.5 --- .../netty/request/NettyRequestFactory.java | 3 ++- .../org/asynchttpclient/util/HttpUtils.java | 25 +++++++++++++++++-- .../asynchttpclient/util/HttpUtilsTest.java | 9 +++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java index d7863e254..d6a9c0f39 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java @@ -495,7 +495,8 @@ static void filterOutUndecodableAcceptEncodings(HttpHeaders headers, boolean bro if (brotliAvailable && zstdAvailable) { return; } - String acceptEncoding = String.join(", ", headers.getAll(ACCEPT_ENCODING)); + List values = headers.getAll(ACCEPT_ENCODING); + String acceptEncoding = values.size() == 1 ? values.get(0) : String.join(", ", values); String filtered = acceptEncoding; if (!brotliAvailable) { filtered = filterOutBrotliFromAcceptEncoding(filtered).toString(); diff --git a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java index 481305d47..433c83d5d 100644 --- a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java @@ -202,8 +202,9 @@ public static CharSequence filterOutZstdFromAcceptEncoding(String acceptEncoding } /** - * Drops every element naming {@code coding}, which may appear anywhere in the list, in any case and with a weight + * Drops every element offering {@code coding}, which may appear anywhere in the list, in any case and with a weight * (RFC 9110 section 12.5.3). Dropping the last element leaves an empty value, which accepts no content coding. + * An element with weight 0 refuses the coding and is kept, as dropping it would let a "*" element offer it. */ private static String filterOutAcceptEncoding(String acceptEncoding, AsciiString coding) { if (!AsciiString.containsIgnoreCase(acceptEncoding, coding)) { @@ -215,7 +216,7 @@ private static String filterOutAcceptEncoding(String acceptEncoding, AsciiString String trimmed = element.trim(); int semicolon = trimmed.indexOf(';'); String token = semicolon < 0 ? trimmed : trimmed.substring(0, semicolon).trim(); - if (coding.contentEqualsIgnoreCase(token)) { + if (coding.contentEqualsIgnoreCase(token) && (semicolon < 0 || !hasZeroWeight(trimmed.substring(semicolon + 1)))) { dropped = true; } else if (!trimmed.isEmpty()) { if (kept.length() > 0) { @@ -226,4 +227,24 @@ private static String filterOutAcceptEncoding(String acceptEncoding, AsciiString } return dropped ? kept.toString() : acceptEncoding; } + + private static boolean hasZeroWeight(String parameters) { + for (String parameter : parameters.split(";")) { + int eq = parameter.indexOf('='); + if (eq >= 0 && "q".equalsIgnoreCase(parameter.substring(0, eq).trim())) { + String weight = parameter.substring(eq + 1).trim(); + if (weight.isEmpty() || weight.charAt(0) != '0') { + return false; + } + for (int i = 1; i < weight.length(); i++) { + char c = weight.charAt(i); + if (c != '0' && (c != '.' || i != 1)) { + return false; + } + } + return true; + } + } + return false; + } } diff --git a/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java b/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java index a8334df79..68dac419c 100644 --- a/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java +++ b/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java @@ -225,6 +225,15 @@ public void filterOutZstdDropsTheCodingWherever() { assertEquals("", filterOutZstd(" zstd ")); } + @Test + public void filterOutKeepsARefusalOfTheCoding() { + assertEquals("*, br;q=0", filterOutBrotli("*, br;q=0")); + assertEquals("*, BR ; Q = 0.000", filterOutBrotli("*, BR ; Q = 0.000")); + assertEquals("gzip, zstd;q=0., *", filterOutZstd("gzip, zstd;q=0., *")); + assertEquals("gzip, br;q=0", filterOutBrotli("gzip, br;q=0.001, br;q=0")); + assertEquals("*", filterOutZstd("*, zstd;q=0.5")); + } + private static String filterOutBrotli(String acceptEncoding) { return HttpUtils.filterOutBrotliFromAcceptEncoding(acceptEncoding).toString(); } From d3a31c64c55023a00151a1b454b3ca307405b968 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:25:39 +0100 Subject: [PATCH 3/4] Refuse a dropped coding next to a wildcard Dropping an element that offers br or zstd with a non-zero weight let a "*" element offer the coding at the wildcard's weight instead, so "*, zstd;q=0.5" became "*" and raised zstd from 0.5 to 1. When the list has a "*" that offers codings, the dropped elements are now replaced by one explicit ";q=0" refusal. Found by the second independent review of #2381. Claude Code on behalf of Pavel Ptashyts. Co-Authored-By: Claude Opus 5.5 --- .../org/asynchttpclient/util/HttpUtils.java | 36 ++++++++++++++----- .../asynchttpclient/util/HttpUtilsTest.java | 8 ++++- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java index 433c83d5d..fd7e76db5 100644 --- a/client/src/main/java/org/asynchttpclient/util/HttpUtils.java +++ b/client/src/main/java/org/asynchttpclient/util/HttpUtils.java @@ -204,30 +204,48 @@ public static CharSequence filterOutZstdFromAcceptEncoding(String acceptEncoding /** * Drops every element offering {@code coding}, which may appear anywhere in the list, in any case and with a weight * (RFC 9110 section 12.5.3). Dropping the last element leaves an empty value, which accepts no content coding. - * An element with weight 0 refuses the coding and is kept, as dropping it would let a "*" element offer it. + * An element with weight 0 refuses the coding and is kept. Next to a "*" element, which would still offer the coding + * at its own weight, the dropped elements are replaced by one such refusal. */ private static String filterOutAcceptEncoding(String acceptEncoding, AsciiString coding) { if (!AsciiString.containsIgnoreCase(acceptEncoding, coding)) { return acceptEncoding; } + String[] elements = acceptEncoding.split(","); + boolean wildcard = false; + for (String element : elements) { + wildcard |= offers(element.trim(), "*"); + } StringBuilder kept = new StringBuilder(acceptEncoding.length()); boolean dropped = false; - for (String element : acceptEncoding.split(",")) { + for (String element : elements) { String trimmed = element.trim(); - int semicolon = trimmed.indexOf(';'); - String token = semicolon < 0 ? trimmed : trimmed.substring(0, semicolon).trim(); - if (coding.contentEqualsIgnoreCase(token) && (semicolon < 0 || !hasZeroWeight(trimmed.substring(semicolon + 1)))) { + if (offers(trimmed, coding)) { + if (wildcard && !dropped) { + appendElement(kept, coding + ";q=0"); + } dropped = true; } else if (!trimmed.isEmpty()) { - if (kept.length() > 0) { - kept.append(", "); - } - kept.append(trimmed); + appendElement(kept, trimmed); } } return dropped ? kept.toString() : acceptEncoding; } + private static boolean offers(String element, CharSequence coding) { + int semicolon = element.indexOf(';'); + String token = semicolon < 0 ? element : element.substring(0, semicolon).trim(); + return AsciiString.contentEqualsIgnoreCase(token, coding) + && (semicolon < 0 || !hasZeroWeight(element.substring(semicolon + 1))); + } + + private static void appendElement(StringBuilder list, String element) { + if (list.length() > 0) { + list.append(", "); + } + list.append(element); + } + private static boolean hasZeroWeight(String parameters) { for (String parameter : parameters.split(";")) { int eq = parameter.indexOf('='); diff --git a/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java b/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java index 68dac419c..e2fc630fa 100644 --- a/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java +++ b/client/src/test/java/org/asynchttpclient/util/HttpUtilsTest.java @@ -231,7 +231,13 @@ public void filterOutKeepsARefusalOfTheCoding() { assertEquals("*, BR ; Q = 0.000", filterOutBrotli("*, BR ; Q = 0.000")); assertEquals("gzip, zstd;q=0., *", filterOutZstd("gzip, zstd;q=0., *")); assertEquals("gzip, br;q=0", filterOutBrotli("gzip, br;q=0.001, br;q=0")); - assertEquals("*", filterOutZstd("*, zstd;q=0.5")); + } + + @Test + public void filterOutRefusesTheCodingNextToAWildcard() { + assertEquals("*, zstd;q=0", filterOutZstd("*, zstd;q=0.5")); + assertEquals("gzip, br;q=0, *", filterOutBrotli("gzip, BR, br;q=0.2, *")); + assertEquals("*;q=0", filterOutBrotli("*;q=0, br")); } private static String filterOutBrotli(String acceptEncoding) { From cf07110c9afbda3b955dabf17fe62b48ed30a0b3 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:01:48 +0100 Subject: [PATCH 4/4] Test Accept-Encoding filtering via newNettyRequest Brotli and zstd are both on the test classpath, so nothing exercised the filter call in newNettyRequest: removing it, swapping its arguments or dropping the automatic-decompression check left every test green, as review pointed out. The factory now reads Brotli.isAvailable() and Zstd.isAvailable() once in its constructor, and a package-private constructor takes both flags. New tests build requests through newNettyRequest with each combination of the flags, with automatic decompression on and off, and with compression enforced. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 --- .../netty/request/NettyRequestFactory.java | 14 ++++-- ...NettyRequestFactoryAcceptEncodingTest.java | 44 ++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java index d6a9c0f39..800e8544d 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java @@ -235,9 +235,17 @@ private void mergeCookies(List cookies, HttpHeaders headers) { private final AsyncHttpClientConfig config; private final ClientCookieEncoder cookieEncoder; private final @Nullable AsciiString userAgent; + private final boolean brotliAvailable; + private final boolean zstdAvailable; NettyRequestFactory(AsyncHttpClientConfig config) { + this(config, Brotli.isAvailable(), Zstd.isAvailable()); + } + + NettyRequestFactory(AsyncHttpClientConfig config, boolean brotliAvailable, boolean zstdAvailable) { this.config = config; + this.brotliAvailable = brotliAvailable; + this.zstdAvailable = zstdAvailable; cookieEncoder = config.isUseLaxCookieEncoder() ? ClientCookieEncoder.LAX : ClientCookieEncoder.STRICT; // An AsciiString is bulk-copied by the encoder; a String is encoded char by char. Same bytes either // way, and the validating request headers still reject an illegal character on every request. @@ -362,15 +370,15 @@ public NettyRequest newNettyRequest(Request request, boolean performConnectReque if (headers.contains(ACCEPT_ENCODING)) { // For manual decompression by the user any encoding may suit, so leave it untouched if (config.isEnableAutomaticDecompression()) { - filterOutUndecodableAcceptEncodings(headers, Brotli.isAvailable(), Zstd.isAvailable()); + filterOutUndecodableAcceptEncodings(headers, brotliAvailable, zstdAvailable); } } else if (config.isCompressionEnforced()) { // Add Accept Encoding header if compression is enforced headers.set(ACCEPT_ENCODING, GZIP_DEFLATE); - if (Brotli.isAvailable()) { + if (brotliAvailable) { headers.add(ACCEPT_ENCODING, HttpHeaderValues.BR); } - if (Zstd.isAvailable()) { + if (zstdAvailable) { headers.add(ACCEPT_ENCODING, HttpHeaderValues.ZSTD); } } diff --git a/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java b/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java index 77bc1f658..9ba0e5576 100644 --- a/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/request/NettyRequestFactoryAcceptEncodingTest.java @@ -17,6 +17,9 @@ import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.HttpHeaders; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.DefaultAsyncHttpClientConfig; +import org.asynchttpclient.RequestBuilder; import org.junit.jupiter.api.Test; import java.util.Arrays; @@ -27,8 +30,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; /** - * Brotli and zstd are both on the test classpath, so the availability flags are passed in rather than taken from - * Netty's {@code Brotli.isAvailable()} and {@code Zstd.isAvailable()}. + * Brotli and zstd are both on the test classpath, so the availability flags are passed in, to the helper or to the + * factory, rather than taken from Netty's {@code Brotli.isAvailable()} and {@code Zstd.isAvailable()}. */ public class NettyRequestFactoryAcceptEncodingTest { @@ -59,6 +62,43 @@ public void filtersEveryHeaderFieldAndKeepsTheOthers() { assertEquals(List.of("gzip, deflate"), filter(false, false, "gzip", "br", "deflate, zstd")); } + @Test + public void newRequestDropsTheUnavailableCodingsWithAutomaticDecompression() { + assertEquals(List.of("gzip, deflate"), newRequest(true, false, false, "gzip, deflate, br, zstd")); + assertEquals(List.of("gzip, deflate, zstd"), newRequest(true, false, true, "gzip, deflate, br, zstd")); + assertEquals(List.of("gzip, deflate, br"), newRequest(true, true, false, "gzip, deflate, br, zstd")); + assertEquals(List.of("gzip, deflate, br, zstd"), newRequest(true, true, true, "gzip, deflate, br, zstd")); + } + + @Test + public void newRequestLeavesTheHeaderToTheUserWithoutAutomaticDecompression() { + assertEquals(List.of("gzip, deflate, br, zstd"), newRequest(false, false, false, "gzip, deflate, br, zstd")); + } + + @Test + public void newRequestOffersOnlyAvailableCodingsWhenCompressionIsEnforced() { + assertEquals(List.of("gzip,deflate"), newRequest(true, false, false)); + assertEquals(List.of("gzip,deflate", "br"), newRequest(true, true, false)); + assertEquals(List.of("gzip,deflate", "zstd"), newRequest(true, false, true)); + } + + private static List newRequest(boolean automaticDecompression, boolean brotliAvailable, boolean zstdAvailable, + String... acceptEncodings) { + AsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setEnableAutomaticDecompression(automaticDecompression) + .setCompressionEnforced(true) + .build(); + RequestBuilder request = new RequestBuilder("GET").setUrl("http://example.com/"); + for (String acceptEncoding : acceptEncodings) { + request.addHeader(ACCEPT_ENCODING, acceptEncoding); + } + return new NettyRequestFactory(config, brotliAvailable, zstdAvailable) + .newNettyRequest(request.build(), false, null, null, null) + .getHttpRequest() + .headers() + .getAll(ACCEPT_ENCODING); + } + private static List filter(boolean brotliAvailable, boolean zstdAvailable, String... acceptEncodings) { HttpHeaders headers = new DefaultHttpHeaders(); headers.add(ACCEPT_ENCODING, Arrays.asList(acceptEncodings));