Browsers (especially Chrome) reject non-numeric {@code :status} values. This helper is more
+ * permissive: it extracts a leading status code (Chrome HTTP/1 {@code ParseStatus} style) and
+ * trims soft-illegal field values so sampling can continue while logging the anomaly.
+ */
+public final class CustomHttp2HeaderNormalizer {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CustomHttp2HeaderNormalizer.class);
+
+ private CustomHttp2HeaderNormalizer() {
+ }
+
+ /**
+ * Normalizes a decoded header value before Jetty builds/emits the field.
+ *
+ * @return the value to use (possibly unchanged)
+ */
+ public static String normalize(HttpHeader header, String name, String value) {
+ if (value == null) {
+ return null;
+ }
+ if (isStatusPseudoHeader(header, name)) {
+ return normalizeStatus(value);
+ }
+ return softenFieldValue(name, value);
+ }
+
+ static boolean isStatusPseudoHeader(HttpHeader header, String name) {
+ if (header == HttpHeader.C_STATUS) {
+ return true;
+ }
+ return name != null && ":status".equalsIgnoreCase(name);
+ }
+
+ /**
+ * Accepts {@code 299}, {@code 299 Akamai}, or leading spaces before digits.
+ * Returns the original value when no leading status code can be recovered.
+ */
+ static String normalizeStatus(String value) {
+ String trimmedLeading = trimLeadingSpaces(value);
+ int digits = 0;
+ while (digits < trimmedLeading.length()
+ && trimmedLeading.charAt(digits) >= '0'
+ && trimmedLeading.charAt(digits) <= '9') {
+ digits++;
+ }
+ if (digits == 0) {
+ return value;
+ }
+ String code = trimmedLeading.substring(0, digits);
+ if (code.equals(value)) {
+ return value;
+ }
+ String reason = trimmedLeading.substring(digits).trim();
+ if (reason.isEmpty() && code.equals(trimmedLeading)) {
+ if (!code.equals(value)) {
+ LOG.warn("Normalized HTTP/2 :status from [{}] to [{}]", value, code);
+ }
+ return code;
+ }
+ LOG.warn("Normalized HTTP/2 :status from [{}] to [{}] (ignored reason [{}])",
+ value, code, reason);
+ return code;
+ }
+
+ /**
+ * Softens values that fail only because of leading/trailing SP/HTAB (RFC 9113).
+ * Control characters and other illegal octets are left untouched so Jetty can still reject them.
+ */
+ static String softenFieldValue(String name, String value) {
+ if (HttpTokens.isLegalFieldValue(value)) {
+ return value;
+ }
+ String trimmed = trimHttpWhitespace(value);
+ if (trimmed.equals(value)) {
+ return value;
+ }
+ if (HttpTokens.isLegalFieldValue(trimmed)) {
+ LOG.warn("Trimmed illegal HTTP/2 header value for [{}]: [{}] -> [{}]",
+ name, value, trimmed);
+ return trimmed;
+ }
+ return value;
+ }
+
+ private static String trimLeadingSpaces(String value) {
+ int i = 0;
+ while (i < value.length() && value.charAt(i) == ' ') {
+ i++;
+ }
+ return i == 0 ? value : value.substring(i);
+ }
+
+ private static String trimHttpWhitespace(String value) {
+ int start = 0;
+ int end = value.length();
+ while (start < end && isHttpWhitespace(value.charAt(start))) {
+ start++;
+ }
+ while (end > start && isHttpWhitespace(value.charAt(end - 1))) {
+ end--;
+ }
+ return (start == 0 && end == value.length()) ? value : value.substring(start, end);
+ }
+
+ private static boolean isHttpWhitespace(char c) {
+ return c == ' ' || c == '\t';
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java
new file mode 100644
index 00000000..e0112f13
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java
@@ -0,0 +1,27 @@
+package com.blazemeter.jmeter.http2.core.jetty.custom.http2;
+
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jetty.http2.client.HTTP2Client;
+import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.EndPoint;
+
+/**
+ * Custom HTTP/2 client transport that uses {@link CustomHTTP2ClientConnectionFactory}.
+ */
+public class CustomHttpClientTransportOverHTTP2 extends HttpClientTransportOverHTTP2 {
+
+ private final CustomHTTP2ClientConnectionFactory connectionFactory =
+ new CustomHTTP2ClientConnectionFactory();
+
+ public CustomHttpClientTransportOverHTTP2(HTTP2Client http2Client) {
+ super(http2Client);
+ }
+
+ @Override
+ public Connection newConnection(EndPoint endPoint, Map {@code hpackDecoder} is {@code final} in Jetty; we replace it reflectively after
+ * {@code super(...)} and before {@code init(...)} so {@code HeaderBlockParser} captures the
+ * custom decoder.
+ */
+public class CustomParser extends Parser {
+
+ public CustomParser(ByteBufferPool bufferPool, int maxHeaderSize) {
+ super(bufferPool, maxHeaderSize);
+ replaceHpackDecoder(maxHeaderSize);
+ }
+
+ public CustomParser(ByteBufferPool bufferPool, int maxHeaderSize, RateControl rateControl) {
+ super(bufferPool, maxHeaderSize, rateControl);
+ replaceHpackDecoder(maxHeaderSize);
+ }
+
+ private void replaceHpackDecoder(int maxHeaderSize) {
+ try {
+ Field field = Parser.class.getDeclaredField("hpackDecoder");
+ field.setAccessible(true);
+ field.set(this, new CustomHpackDecoder(maxHeaderSize, this::getBeginNanoTime));
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unable to install CustomHpackDecoder into Parser", e);
+ }
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java
index 4ec94d25..d876ba6c 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java
@@ -7,7 +7,7 @@
import org.eclipse.jetty.client.transport.HttpClientTransportDynamic;
import org.eclipse.jetty.http3.client.HTTP3Client;
import org.eclipse.jetty.http3.client.HTTP3ClientConnectionFactory;
-import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3;
+import org.eclipse.jetty.http3.client.transport.CustomHttp3ClientConfigurer;
import org.eclipse.jetty.io.ClientConnectionFactory;
import org.eclipse.jetty.io.EndPoint;
import org.eclipse.jetty.io.Transport;
@@ -31,14 +31,7 @@ public CustomClientConnectionFactoryOverHTTP3(HTTP3Client http3Client) {
@Override
public void setHttpClient(HttpClient httpClient) {
- try {
- java.lang.reflect.Method configureMethod = HttpClientTransportOverHTTP3.class
- .getDeclaredMethod("configure", HttpClient.class, HTTP3Client.class);
- configureMethod.setAccessible(true);
- configureMethod.invoke(null, httpClient, http3Client);
- } catch (ReflectiveOperationException e) {
- throw new IllegalStateException("Unable to configure HTTP/3 transport", e);
- }
+ CustomHttp3ClientConfigurer.configure(httpClient, http3Client);
}
@Override
diff --git a/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java b/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java
new file mode 100644
index 00000000..168e5624
--- /dev/null
+++ b/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java
@@ -0,0 +1,27 @@
+package org.eclipse.jetty.http2.client;
+
+import org.eclipse.jetty.http2.SessionContainer;
+
+/**
+ * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes
+ * {@link HTTP2Client}'s package-private {@code getSessionContainer()} to our code without
+ * reflection.
+ *
+ * {@code com.blazemeter...custom.http2.CustomHTTP2ClientConnectionFactory} rebuilds the
+ * connection setup that {@code HTTP2ClientConnectionFactory#newConnection} does upstream,
+ * including registering the client's session container as an event listener on the new
+ * connection. That container is only reachable via this package-private accessor, so this shim
+ * exposes it publicly instead of relying on reflection.
+ *
+ * Keep in sync when upgrading Jetty: if {@code getSessionContainer} is ever made public or
+ * removed, this shim (and the caller) should be updated accordingly.
+ */
+public final class CustomHttp2SessionContainerAccessor {
+
+ private CustomHttp2SessionContainerAccessor() {
+ }
+
+ public static SessionContainer getSessionContainer(HTTP2Client client) {
+ return client.getSessionContainer();
+ }
+}
diff --git a/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java b/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java
new file mode 100644
index 00000000..88e975c1
--- /dev/null
+++ b/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java
@@ -0,0 +1,31 @@
+package org.eclipse.jetty.http2.client.transport;
+
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.http2.client.HTTP2Client;
+
+/**
+ * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes
+ * {@link HttpClientTransportOverHTTP2}'s package-private {@code configure(HttpClient, HTTP2Client)}
+ * to our code without reflection.
+ *
+ * {@code com.blazemeter...custom.http2.CustomClientConnectionFactoryOverHTTP2} is a
+ * {@code ClientConnectionFactory} plus {@code HttpClient.Aware} (used inside
+ * {@code HttpClientTransportDynamic}), not an {@code HttpClientTransport} subclass, so unlike
+ * {@code CustomHttpClientTransportOverHTTP2} it cannot inherit
+ * {@code HttpClientTransportOverHTTP2.doStart()} — which calls {@code configure} for free
+ * from within Jetty's own package. It must call {@code configure} itself from
+ * {@code setHttpClient(HttpClient)}, and since that method is package-private, this shim
+ * exposes it publicly instead of relying on reflection.
+ *
+ * Keep in sync when upgrading Jetty: if {@code configure} is ever made public or removed, this
+ * shim (and the caller) should be updated accordingly.
+ */
+public final class CustomHttp2ClientConfigurer {
+
+ private CustomHttp2ClientConfigurer() {
+ }
+
+ public static void configure(HttpClient httpClient, HTTP2Client http2Client) {
+ HttpClientTransportOverHTTP2.configure(httpClient, http2Client);
+ }
+}
diff --git a/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java b/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java
new file mode 100644
index 00000000..45d9aa82
--- /dev/null
+++ b/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java
@@ -0,0 +1,368 @@
+package org.eclipse.jetty.http2.hpack;
+
+import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttp2HeaderNormalizer;
+import java.nio.ByteBuffer;
+import java.util.function.LongSupplier;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpTokens;
+import org.eclipse.jetty.http.MetaData;
+import org.eclipse.jetty.http.PreEncodedHttpField;
+import org.eclipse.jetty.http.compression.EncodingException;
+import org.eclipse.jetty.http.compression.HuffmanDecoder;
+import org.eclipse.jetty.http.compression.NBitIntegerDecoder;
+import org.eclipse.jetty.http2.hpack.HpackContext.Entry;
+import org.eclipse.jetty.http2.hpack.internal.AuthorityHttpField;
+import org.eclipse.jetty.http2.hpack.internal.MetaDataBuilder;
+import org.eclipse.jetty.util.BufferUtil;
+import org.eclipse.jetty.util.CharsetStringBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Jetty 12.1.11 {@link HpackDecoder} with Firefox-inspired soft normalization for load testing.
+ *
+ * Keep in sync when upgrading Jetty; only the normalize hooks differ from upstream.
+ *
+ * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: the
+ * {@link HpackDecoder#getHpackContext() context} it inherits is {@code private}, so this class
+ * must build its own {@link HpackContext}, and that constructor is package-private in Jetty
+ * (verified for both 12.1.7 and 12.1.11 via {@code javap}). Every other member this class uses
+ * from {@code HpackContext}, {@code MetaDataBuilder} and {@code HpackDecoder} is {@code public};
+ * only the {@code HpackContext(int)} constructor forces the package match.
+ *
+ * Keep in sync when upgrading Jetty: if {@code HpackContext(int)} is ever made public, this
+ * class could move to a {@code com.blazemeter} package instead of overriding every accessor.
+ *
+ * This is not thread safe and may only be called by 1 thread at a time.
+ */
+public class CustomHpackDecoder extends HpackDecoder {
+ private static final Logger LOG = LoggerFactory.getLogger(CustomHpackDecoder.class);
+ private static final HttpField LOWER_CASE_CONTENT_LENGTH_0 =
+ new PreEncodedHttpField(HttpHeader.CONTENT_LENGTH, "content-length", "0");
+
+ private final HpackContext context;
+ private final MetaDataBuilder builder;
+ private final HuffmanDecoder huffmanDecoder;
+ private final NBitIntegerDecoder integerDecoder;
+ private final LongSupplier beginNanoTimeSupplier;
+ private int maxTableCapacity;
+
+ /**
+ * @param maxHeaderSize the maximum allowed size of a decoded headers block, expressed as total
+ * of all name and value bytes, plus 32 bytes per field
+ * @param beginNanoTimeSupplier the supplier of a nano timestamp taken at the time the first byte
+ * was read
+ */
+ public CustomHpackDecoder(int maxHeaderSize, LongSupplier beginNanoTimeSupplier) {
+ super(maxHeaderSize, beginNanoTimeSupplier);
+ this.beginNanoTimeSupplier = beginNanoTimeSupplier;
+ context = new HpackContext(HpackContext.DEFAULT_MAX_TABLE_CAPACITY);
+ builder = new MetaDataBuilder(maxHeaderSize);
+ huffmanDecoder = new HuffmanDecoder();
+ integerDecoder = new NBitIntegerDecoder();
+ setMaxTableCapacity(HpackContext.DEFAULT_MAX_TABLE_CAPACITY);
+ }
+
+ @Override
+ public HpackContext getHpackContext() {
+ return context;
+ }
+
+ @Override
+ public int getMaxTableCapacity() {
+ return maxTableCapacity;
+ }
+
+ /**
+ * Sets the limit for the capacity of the dynamic header table.
+ *
+ * This value acts as a limit for the values received from the remote peer via the HPACK
+ * dynamic table size update instruction.
+ *
+ * After calling this method, a SETTINGS frame must be sent to the other peer, containing the
+ * {@code SETTINGS_HEADER_TABLE_SIZE} setting with the value passed as argument to this method.
+ *
+ * @param maxTableCapacity the limit for capacity of the dynamic header table
+ */
+ @Override
+ public void setMaxTableCapacity(int maxTableCapacity) {
+ this.maxTableCapacity = maxTableCapacity;
+ }
+
+ @Override
+ public int getMaxHeaderListSize() {
+ return builder.getMaxSize();
+ }
+
+ @Override
+ public void setMaxHeaderListSize(int maxHeaderListSize) {
+ builder.setMaxSize(maxHeaderListSize);
+ }
+
+ @Override
+ public MetaData decode(ByteBuffer buffer)
+ throws HpackException.SessionException, HpackException.StreamException {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug(String.format("CtxTbl[%x] decoding %d octets", context.hashCode(),
+ buffer.remaining()));
+ }
+
+ // If the buffer is larger than the max headers size, don't even start decoding it.
+ int maxSize = builder.getMaxSize();
+ if (maxSize > 0 && buffer.remaining() > maxSize) {
+ throw new HpackException.SessionException("Header fields size too large");
+ }
+
+ try {
+ boolean emitted = false;
+ while (buffer.hasRemaining()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("decode {}", BufferUtil.toHexString(buffer));
+ }
+
+ byte b = buffer.get();
+ if (b < 0) {
+ // 7.1 indexed if the high bit is set
+ int index = integerDecode(buffer, 7);
+ Entry entry = context.get(index);
+ if (entry == null) {
+ throw new HpackException.SessionException("Unknown index %d", index);
+ }
+
+ HttpField field = entry.getHttpField();
+ if (entry.isStatic()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("decode IdxStatic {}", entry);
+ }
+ emitted = true;
+ builder.emit(field);
+ } else {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("decode Idx {}", entry);
+ }
+
+ String name = field.getName();
+ if (!HttpTokens.isLegalH2H3FieldName(name)) {
+ builder.streamException("Illegal header name %s", name);
+ }
+
+ String value = CustomHttp2HeaderNormalizer.normalize(field.getHeader(),
+ field.getName(), field.getValue());
+ if (!value.equals(field.getValue())) {
+ field = new HttpField(field.getHeader(), field.getName(), value);
+ }
+ if (!HttpTokens.isLegalFieldValue(value)) {
+ builder.streamException("Illegal header value %s", value);
+ }
+
+ emitted = true;
+ builder.emit(field);
+ }
+ } else {
+ // look at the first nibble in detail
+ byte f = (byte) ((b & 0xF0) >> 4);
+ String name;
+ HttpHeader header = null;
+ String value;
+
+ boolean indexed;
+ int nameIndex;
+
+ switch (f) {
+ case 2: // 7.3
+ case 3: // 7.3
+ // change table size
+ int size = integerDecode(buffer, 5);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("decode resize={}", size);
+ }
+ if (size > getMaxTableCapacity()) {
+ throw new HpackException.CompressionException(
+ "Dynamic table resize exceeded max limit");
+ }
+ if (emitted) {
+ throw new HpackException.CompressionException(
+ "Dynamic table resize after fields");
+ }
+ context.resize(size);
+ continue;
+
+ case 0: // 7.2.2
+ case 1: // 7.2.3
+ indexed = false;
+ nameIndex = integerDecode(buffer, 4);
+ break;
+
+ case 4: // 7.2.1
+ case 5: // 7.2.1
+ case 6: // 7.2.1
+ case 7: // 7.2.1
+ indexed = true;
+ nameIndex = integerDecode(buffer, 6);
+ break;
+
+ default:
+ throw new IllegalStateException();
+ }
+
+ boolean huffmanName = false;
+
+ // decode the name
+ if (nameIndex > 0) {
+ Entry nameEntry = context.get(nameIndex);
+ if (nameEntry == null) {
+ throw new HpackException.CompressionException("Unknown index %d", nameIndex);
+ }
+ name = nameEntry.getHttpField().getName();
+ header = nameEntry.getHttpField().getHeader();
+ } else {
+ huffmanName = (buffer.get() & 0x80) == 0x80;
+ int length = integerDecode(buffer, 7);
+ if (huffmanName) {
+ name = huffmanDecode(buffer, length);
+ } else {
+ name = toISO88591String(buffer, length);
+ }
+ }
+
+ boolean illegalName = !HttpTokens.isLegalH2H3FieldName(name);
+ if (illegalName) {
+ builder.streamException("Illegal header name %s", name);
+ } else {
+ header = HttpHeader.CACHE.get(name);
+ }
+
+ // decode the value
+ boolean huffmanValue = (buffer.get() & 0x80) == 0x80;
+ int length = integerDecode(buffer, 7);
+ if (huffmanValue) {
+ value = huffmanDecode(buffer, length);
+ } else {
+ value = toISO88591String(buffer, length);
+ }
+
+ value = CustomHttp2HeaderNormalizer.normalize(header, name, value);
+
+ boolean illegalValue = !HttpTokens.isLegalFieldValue(value);
+ if (illegalValue) {
+ builder.streamException("Illegal header value %s", value);
+ }
+
+ HttpField httpField = processField(header, name, value, indexed);
+ emitted = true;
+ builder.emit(httpField);
+
+ // If indexed add to dynamic table.
+ if (indexed) {
+ context.add(httpField);
+ }
+
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("decoded '{}' by {}/{}/{}", httpField,
+ nameIndex > 0 ? "IdxName" : (huffmanName ? "HuffName" : "LitName"),
+ huffmanValue ? "HuffVal" : "LitVal", indexed ? "Idx" : "");
+ }
+ }
+ }
+
+ builder.setBeginNanoTime(beginNanoTimeSupplier.getAsLong());
+ return builder.build();
+ } catch (HpackException ex) {
+ throw ex;
+ } catch (Throwable t) {
+ throw new HpackException.SessionException(t, "HPACK decoding failure: %s", t.getMessage());
+ }
+ }
+
+ private HttpField processField(HttpHeader header, String name, String value, boolean indexed)
+ throws HpackException.SessionException {
+ HttpField field;
+ if (header == null) {
+ // just make a normal field and bypass header name lookup
+ field = new HttpField(null, name, value);
+ } else {
+ try {
+ // might be worthwhile to create a value HttpField if it is indexed
+ // and/or of a type that may be looked up multiple times.
+ switch (header) {
+ case C_STATUS:
+ if (indexed) {
+ field = new HttpField.IntValueHttpField(header, name, value);
+ } else {
+ field = new HttpField(header, name, value);
+ }
+ break;
+ case C_AUTHORITY:
+ field = new AuthorityHttpField(value);
+ break;
+ case CONTENT_LENGTH:
+ if ("0".equals(value)) {
+ field = LOWER_CASE_CONTENT_LENGTH_0;
+ } else {
+ field = new HttpField.LongValueHttpField(header, name, value);
+ }
+ break;
+ default:
+ field = new HttpField(header, name, value);
+ break;
+ }
+ } catch (Throwable t) {
+ builder.streamException(t);
+ field = new HttpField(header, name, value);
+ }
+ }
+
+ return field;
+ }
+
+ private int integerDecode(ByteBuffer buffer, int prefix)
+ throws HpackException.CompressionException {
+ try {
+ if (prefix != 8) {
+ buffer.position(buffer.position() - 1);
+ }
+
+ integerDecoder.setPrefix(prefix);
+ int decodedInt = integerDecoder.decodeInt(buffer);
+ if (decodedInt < 0) {
+ throw new EncodingException("invalid integer encoding");
+ }
+ return decodedInt;
+ } catch (EncodingException e) {
+ throw new HpackException.CompressionException(e, e.getMessage());
+ } finally {
+ integerDecoder.reset();
+ }
+ }
+
+ private String huffmanDecode(ByteBuffer buffer, int length)
+ throws HpackException.CompressionException {
+ try {
+ huffmanDecoder.setLength(length);
+ String decoded = huffmanDecoder.decode(buffer);
+ if (decoded == null) {
+ throw new HpackException.CompressionException("invalid string encoding");
+ }
+ return decoded;
+ } catch (EncodingException e) {
+ throw new HpackException.CompressionException(e, e.getMessage());
+ } finally {
+ huffmanDecoder.reset();
+ }
+ }
+
+ public static String toISO88591String(ByteBuffer buffer, int length) {
+ CharsetStringBuilder.Iso88591StringBuilder stringBuilder =
+ new CharsetStringBuilder.Iso88591StringBuilder();
+ for (int i = 0; i < length; ++i) {
+ stringBuilder.append(buffer.get());
+ }
+ return stringBuilder.build();
+ }
+
+ @Override
+ public String toString() {
+ return String.format("CustomHpackDecoder@%x{%s}", hashCode(), context);
+ }
+}
diff --git a/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java b/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java
new file mode 100644
index 00000000..415cedc0
--- /dev/null
+++ b/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java
@@ -0,0 +1,31 @@
+package org.eclipse.jetty.http3.client.transport;
+
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.http3.client.HTTP3Client;
+
+/**
+ * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes
+ * {@link HttpClientTransportOverHTTP3}'s package-private {@code configure(HttpClient, HTTP3Client)}
+ * to our code without reflection.
+ *
+ * {@code com.blazemeter...custom.http3.CustomClientConnectionFactoryOverHTTP3} is a
+ * {@code ClientConnectionFactory} plus {@code HttpClient.Aware} (used inside
+ * {@code HttpClientTransportDynamic}), not an {@code HttpClientTransport} subclass, so it cannot
+ * inherit {@code HttpClientTransportOverHTTP3.doStart()} — which calls {@code configure} for free
+ * from within Jetty's own package. It must call {@code configure} itself from
+ * {@code setHttpClient(HttpClient)}, and since that method is package-private, this shim exposes
+ * it publicly instead of relying on reflection. Mirrors
+ * {@code org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer} for the HTTP/2 case.
+ *
+ * Keep in sync when upgrading Jetty: if {@code configure} is ever made public or removed, this
+ * shim (and the caller) should be updated accordingly.
+ */
+public final class CustomHttp3ClientConfigurer {
+
+ private CustomHttp3ClientConfigurer() {
+ }
+
+ public static void configure(HttpClient httpClient, HTTP3Client http3Client) {
+ HttpClientTransportOverHTTP3.configure(httpClient, http3Client);
+ }
+}
diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java
new file mode 100644
index 00000000..7a66aa60
--- /dev/null
+++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java
@@ -0,0 +1,169 @@
+package com.blazemeter.jmeter.http2.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.catchThrowable;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import org.eclipse.jetty.http.HttpTokens;
+import org.eclipse.jetty.http.MetaData;
+import org.eclipse.jetty.http2.hpack.HpackDecoder;
+import org.eclipse.jetty.http2.hpack.HpackException;
+import org.eclipse.jetty.http2.hpack.CustomHpackDecoder;
+import org.junit.Test;
+
+/**
+ * Reproduces Jetty HPACK failures seen with Akamai-style status values such as
+ * {@code :status: 299 Akamai}, and verifies the tolerant decoder accepts them.
+ *
+ * HTTP/2 {@code :status} must be a 3-digit integer only (RFC 9113). Some intermediaries
+ * still emit an HTTP/1-style reason phrase in the pseudo-header. Stock Jetty fails while
+ * parsing that value as an int. {@link CustomHpackDecoder} normalizes leading digits
+ * (Firefox-inspired soft handling for load testing).
+ */
+public class HpackAkamaiStatusDecoderTest {
+
+ private static final String AKAMAI_STATUS_WITH_REASON = "299 Akamai";
+
+ @Test
+ public void akamaiStatusWithReasonIsLegalFieldValue() {
+ assertThat(HttpTokens.isLegalFieldValue(AKAMAI_STATUS_WITH_REASON)).isTrue();
+ assertThat(HttpTokens.isLegalFieldValue("299")).isTrue();
+ }
+
+ @Test
+ public void trailingSpaceMakesAkamaiStatusIllegalFieldValue() {
+ assertThat(HttpTokens.isLegalFieldValue(AKAMAI_STATUS_WITH_REASON + " ")).isFalse();
+ assertThat(HttpTokens.isLegalFieldValue(" " + AKAMAI_STATUS_WITH_REASON)).isFalse();
+ }
+
+ @Test
+ public void stockDecoderFailsOnStatusPseudoHeaderWithAkamaiReasonPhrase() {
+ ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, false);
+ HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime);
+
+ assertThatThrownBy(() -> decoder.decode(block))
+ .isInstanceOf(HpackException.SessionException.class)
+ .hasMessageContaining("HPACK decoding failure");
+ }
+
+ @Test
+ public void stockDecoderFailsOnIndexedStatusPseudoHeaderWithAkamaiReasonPhrase() {
+ ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, true);
+ HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime);
+
+ assertThatThrownBy(() -> decoder.decode(block))
+ .isInstanceOf(HpackException.SessionException.class)
+ .hasMessageContaining("HPACK decoding failure");
+ }
+
+ @Test
+ public void tolerantDecoderAcceptsStatusPseudoHeaderWithAkamaiReasonPhrase() throws Exception {
+ ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, false);
+ MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block);
+
+ assertThat(metadata).isInstanceOf(MetaData.Response.class);
+ assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299);
+ }
+
+ @Test
+ public void tolerantDecoderAcceptsIndexedStatusPseudoHeaderWithAkamaiReasonPhrase()
+ throws Exception {
+ ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, true);
+ MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block);
+
+ assertThat(metadata).isInstanceOf(MetaData.Response.class);
+ assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299);
+ }
+
+ @Test
+ public void shouldDecodeNumericStatus299WithoutReason() throws Exception {
+ ByteBuffer block = literalStatusHeader("299", false);
+ MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block);
+
+ assertThat(metadata).isInstanceOf(MetaData.Response.class);
+ assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299);
+ }
+
+ @Test
+ public void stockDecoderReportsIllegalHeaderValueWhenWarningHasTrailingSpace() {
+ ByteBuffer block = status200PlusWarning(AKAMAI_STATUS_WITH_REASON + " ");
+ HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime);
+
+ assertThatThrownBy(() -> decoder.decode(block))
+ .isInstanceOf(HpackException.StreamException.class)
+ .hasMessageContaining("Illegal header value")
+ .hasMessageContaining(AKAMAI_STATUS_WITH_REASON);
+ }
+
+ @Test
+ public void tolerantDecoderTrimsTrailingSpaceOnWarningHeader() throws Exception {
+ ByteBuffer block = status200PlusWarning(AKAMAI_STATUS_WITH_REASON + " ");
+ MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block);
+
+ assertThat(metadata).isInstanceOf(MetaData.Response.class);
+ assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(200);
+ assertThat(metadata.getHttpFields().get("warning")).isEqualTo(AKAMAI_STATUS_WITH_REASON);
+ }
+
+ @Test
+ public void realOversizedBlockSessionExceptionIsDetectedAsHpackFailure() {
+ ByteBuffer block = literalStatusHeader("200", false);
+ // Buffer is 5 bytes (0x08, len=3, '2','0','0'); cap below that to trip the
+ // whole-block guard at the top of CustomHpackDecoder.decode().
+ CustomHpackDecoder decoder = new CustomHpackDecoder(4, System::nanoTime);
+
+ Throwable thrown = catchThrowable(() -> decoder.decode(block));
+
+ assertThat(thrown)
+ .isInstanceOf(HpackException.SessionException.class)
+ .hasMessageContaining("Header fields size too large");
+ assertThat(HpackFailureDetector.indicatesHpackFailure(thrown))
+ .as("HpackFailureDetector must recognize CustomHpackDecoder's own SessionException")
+ .isTrue();
+ }
+
+ @Test
+ public void realOversizedFieldSessionExceptionIsDetectedAsHpackFailure() {
+ // Encoded block is only 12 bytes (0x08, len=10, 10 value bytes), but the decoded
+ // ":status" field accounts for 7 (name) + 10 (value) + 32 (overhead) = 49 logical bytes,
+ // so maxHeaderSize=20 clears the whole-block guard yet still trips MetaDataBuilder.emit().
+ ByteBuffer block = literalStatusHeader("2000000000", false);
+ CustomHpackDecoder decoder = new CustomHpackDecoder(20, System::nanoTime);
+
+ Throwable thrown = catchThrowable(() -> decoder.decode(block));
+
+ assertThat(thrown)
+ .isInstanceOf(HpackException.SessionException.class)
+ .hasMessageContaining("Header size")
+ .hasMessageContaining("49 > 20");
+ assertThat(HpackFailureDetector.indicatesHpackFailure(thrown))
+ .as("HpackFailureDetector must recognize CustomHpackDecoder's own SessionException")
+ .isTrue();
+ }
+
+ private static ByteBuffer literalStatusHeader(String value, boolean indexed) {
+ byte[] valueBytes = value.getBytes(StandardCharsets.ISO_8859_1);
+ ByteBuffer buffer = ByteBuffer.allocate(2 + valueBytes.length);
+ buffer.put(indexed ? (byte) 0x48 : (byte) 0x08);
+ buffer.put((byte) valueBytes.length);
+ buffer.put(valueBytes);
+ buffer.flip();
+ return buffer;
+ }
+
+ private static ByteBuffer status200PlusWarning(String warningValue) {
+ byte[] name = "warning".getBytes(StandardCharsets.ISO_8859_1);
+ byte[] value = warningValue.getBytes(StandardCharsets.ISO_8859_1);
+ ByteBuffer buffer = ByteBuffer.allocate(4 + name.length + value.length);
+ buffer.put((byte) 0x88);
+ buffer.put((byte) 0x00);
+ buffer.put((byte) name.length);
+ buffer.put(name);
+ buffer.put((byte) value.length);
+ buffer.put(value);
+ buffer.flip();
+ return buffer;
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java
new file mode 100644
index 00000000..2257e0c6
--- /dev/null
+++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java
@@ -0,0 +1,225 @@
+package com.blazemeter.jmeter.http2.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.blazemeter.jmeter.http2.HTTP2TestBase;
+import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler;
+import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils;
+import java.net.URI;
+import java.net.URL;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult;
+import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.apache.jmeter.util.JMeterUtils;
+import org.eclipse.jetty.http2.HTTP2Session;
+import org.eclipse.jetty.http2.api.Stream;
+import org.eclipse.jetty.http2.api.server.ServerSessionListener;
+import org.eclipse.jetty.http2.frames.FrameType;
+import org.eclipse.jetty.http2.frames.HeadersFrame;
+import org.eclipse.jetty.http2.server.RawHTTP2ServerConnectionFactory;
+import org.eclipse.jetty.io.EndPoint;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.util.Callback;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * End-to-end coverage of Akamai-style {@code :status: 299 Akamai} responses over HTTP/2.
+ *
+ * Jetty's normal response encoder never puts a reason phrase into {@code :status}, so this
+ * test uses {@link RawHTTP2ServerConnectionFactory} and writes a raw HEADERS frame whose HPACK
+ * block contains the non-numeric status value reported in production.
+ *
+ * Stock Jetty rejects that value; the plugin's tolerant HPACK path (Firefox-inspired soft
+ * normalization) accepts it and surfaces response code {@code 299}.
+ */
+public class HpackAkamaiStatusIntegrationTest extends HTTP2TestBase {
+
+ private static final String AKAMAI_STATUS_WITH_REASON = "299 Akamai";
+ private static final byte HEADERS_END_STREAM_END_HEADERS = 0x05;
+
+ private String savedSharedPool;
+ private String savedProtocolErrorFallback;
+ private String savedPriorKnowledge;
+ private Server server;
+ private HTTP2JettyClient client;
+ private int serverPort = -1;
+
+ @Before
+ public void setUp() throws Exception {
+ JMeterTestUtils.setupJmeterEnv();
+ savedSharedPool = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool");
+ savedProtocolErrorFallback =
+ JMeterUtils.getProperty("httpJettyClient.protocolErrorFallbackEnabled");
+ savedPriorKnowledge = JMeterUtils.getProperty("httpJettyClient.http2PriorKnowledge");
+ JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false");
+ JMeterUtils.setProperty("httpJettyClient.protocolErrorFallbackEnabled", "false");
+ JMeterUtils.setProperty("httpJettyClient.http2PriorKnowledge", "true");
+
+ server = startRawAkamaiStatusServer();
+ serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (client != null) {
+ client.stop();
+ client = null;
+ }
+ if (server != null) {
+ server.stop();
+ server = null;
+ }
+ serverPort = -1;
+ restoreProperty("httpJettyClient.sharedThreadPool", savedSharedPool);
+ restoreProperty("httpJettyClient.protocolErrorFallbackEnabled", savedProtocolErrorFallback);
+ restoreProperty("httpJettyClient.http2PriorKnowledge", savedPriorKnowledge);
+ }
+
+ @Test
+ public void shouldAcceptAkamaiStatusWithReasonPhraseThroughJettyClient() throws Exception {
+ client = new HTTP2JettyClient(false, "hpack-akamai-status-it", http2OnlyProfile());
+ client.start();
+
+ HTTPSampleResult result = client.sample(buildSampler(), buildBaseResult(), false, 0);
+
+ assertThat(result.isSuccessful())
+ .as("Tolerant client must accept :status with an HTTP/1 reason phrase")
+ .isTrue();
+ assertThat(result.getResponseCode()).isEqualTo("299");
+ }
+
+ private static Server startRawAkamaiStatusServer() throws Exception {
+ Server jetty = new Server();
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ RawHTTP2ServerConnectionFactory http2 =
+ new RawHTTP2ServerConnectionFactory(httpConfig, new ServerSessionListener() {
+ @Override
+ public Stream.Listener onNewStream(Stream stream, HeadersFrame frame) {
+ writeRawStatusWithReason(stream);
+ return null;
+ }
+ });
+ ServerConnector connector = new ServerConnector(jetty, http2);
+ connector.setPort(0);
+ jetty.addConnector(connector);
+ jetty.start();
+ return jetty;
+ }
+
+ /**
+ * Writes a HEADERS frame with HPACK {@code :status: 299 Akamai} directly to the connection,
+ * bypassing Jetty's encoder which would strip the reason phrase.
+ */
+ private static void writeRawStatusWithReason(Stream stream) {
+ try {
+ HTTP2Session session = (HTTP2Session) stream.getSession();
+ EndPoint endPoint = session.getEndPoint();
+ ByteBuffer frame = buildHeadersFrame(stream.getId(),
+ literalStatusHpack(AKAMAI_STATUS_WITH_REASON));
+
+ CountDownLatch written = new CountDownLatch(1);
+ AtomicReference