diff --git a/.github/workflows/ci-build.yaml b/.github/workflows/ci-build.yaml
index 61391b10..f5b93863 100644
--- a/.github/workflows/ci-build.yaml
+++ b/.github/workflows/ci-build.yaml
@@ -10,6 +10,13 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ # 5.5 is the minimum supported JMeter version, 5.6.3 the latest; both are exercised
+ # end-to-end (unit + HTTP parity regression tests) since ApacheJMeter_* are provided-scope
+ # and the same compiled plugin must behave correctly against either at runtime.
+ jmeter_version: ["5.5", "5.6.3"]
steps:
- name: Checkout
uses: actions/checkout@v5
@@ -21,14 +28,17 @@ jobs:
java-version: "17"
cache: maven
- - name: Build and test
- run: xvfb-run -a mvn -U --batch-mode clean install
+ - name: Build and test (JMeter ${{ matrix.jmeter_version }})
+ run: >-
+ xvfb-run -a mvn -U --batch-mode
+ -Djmeter.version=${{ matrix.jmeter_version }}
+ clean install
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@v6
with:
- name: ci-build-artifacts-${{ github.run_id }}
+ name: ci-build-artifacts-${{ matrix.jmeter_version }}-${{ github.run_id }}
path: |
target/jmeter-bzm-http2-*.jar
target/jmeter-test
diff --git a/.github/workflows/ci-jmeter-compatibility.yaml b/.github/workflows/ci-jmeter-compatibility.yaml
index b0170a7b..93a7f4fa 100644
--- a/.github/workflows/ci-jmeter-compatibility.yaml
+++ b/.github/workflows/ci-jmeter-compatibility.yaml
@@ -55,7 +55,13 @@ jobs:
env:
JMETER_PATH_BASE: ${{ github.workspace }}/.jmeter
run: |
- JMETER_VERSION=5.6.3 JMETER_PATH=$JMETER_PATH_BASE/5.6.3 sh testJMeter.sh
+ # Verify the built plugin jar loads and runs against every JMeter version we claim
+ # to support: 5.5 (minimum) through 5.6.3 (latest), regardless of which version the
+ # jar itself was compiled against (jmeter.version in pom.xml is provided-scope).
+ for JMETER_VERSION in 5.5 5.6.3; do
+ echo "=== Compatibility check against JMeter $JMETER_VERSION ==="
+ JMETER_VERSION=$JMETER_VERSION JMETER_PATH=$JMETER_PATH_BASE/$JMETER_VERSION sh testJMeter.sh
+ done
- name: Upload compatibility artifacts on failure
if: failure()
diff --git a/pom.xml b/pom.xml
index d6ce80bb..7735dcb4 100644
--- a/pom.xml
+++ b/pom.xml
@@ -30,10 +30,10 @@
UTF-8UTF-8
- 5.4.1
- 12.1.6
-
- 1.20.0
+ 5.5
+ 12.1.11
+
+ 1.23.0false
@@ -50,11 +50,11 @@
provided
-
+
org.apache.httpcomponentshttpcore
- 4.4.14
+ 4.4.15compile
@@ -66,7 +66,7 @@
org.apache.commonscommons-lang3
- 3.12.0
+ 3.18.0compile
@@ -189,10 +189,27 @@
2.28.2test
+
+
+ net.bytebuddy
+ byte-buddy
+ 1.14.18
+ test
+
+
+ net.bytebuddy
+ byte-buddy-agent
+ 1.14.18
+ test
+ org.assertjassertj-core
- 3.12.2
+ 3.27.7test
@@ -275,7 +292,7 @@
com.google.guavaguava
- 29.0-jre
+ 32.0.0-jretest
diff --git a/scripts/jmx-migrate.cmd b/scripts/jmx-migrate.cmd
new file mode 100644
index 00000000..5e3ada18
--- /dev/null
+++ b/scripts/jmx-migrate.cmd
@@ -0,0 +1,14 @@
+@echo off
+rem Migrates stock JMeter HTTP Request samplers to the BlazeMeter HTTP sampler in a .jmx file
+rem or directory. Run from a real JMeter installation with jmeter-bzm-http2-*.jar installed
+rem under lib\ext\ (this script assumes it lives at \bin\jmx-migrate.cmd).
+rem
+rem Usage: see `jmx-migrate.cmd --help`
+
+setlocal
+set SCRIPT_DIR=%~dp0
+set JMETER_HOME_DIR=%SCRIPT_DIR%..
+
+java -Djava.awt.headless=true ^
+ -cp "%JMETER_HOME_DIR%\lib\*;%JMETER_HOME_DIR%\lib\ext\*" ^
+ com.blazemeter.jmeter.http2.sampler.JmxBlazeMeterHttpMigratorCli %*
diff --git a/scripts/jmx-migrate.sh b/scripts/jmx-migrate.sh
new file mode 100644
index 00000000..89f4286d
--- /dev/null
+++ b/scripts/jmx-migrate.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# Migrates stock JMeter HTTP Request samplers to the BlazeMeter HTTP sampler in a .jmx file
+# or directory. Run from a real JMeter installation with jmeter-bzm-http2-*.jar installed
+# under lib/ext/ (this script assumes it lives at /bin/jmx-migrate.sh).
+#
+# Usage: see `jmx-migrate.sh --help`
+
+SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
+JMETER_HOME_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
+
+exec java -Djava.awt.headless=true \
+ -cp "$JMETER_HOME_DIR/lib/*:$JMETER_HOME_DIR/lib/ext/*" \
+ com.blazemeter.jmeter.http2.sampler.JmxBlazeMeterHttpMigratorCli "$@"
diff --git a/src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java b/src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java
new file mode 100644
index 00000000..efb9b295
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/cli/JMeterCliEnvironment.java
@@ -0,0 +1,96 @@
+package com.blazemeter.jmeter.http2.cli;
+
+import java.io.File;
+import java.net.URISyntaxException;
+import java.security.CodeSource;
+import org.apache.jmeter.util.JMeterUtils;
+
+/**
+ * Bootstraps a headless JMeter environment for command-line tools shipped inside this plugin's
+ * jar, without requiring the JMeter GUI or the lightweight test emulator used by unit tests.
+ *
+ *
Resolves {@code JMETER_HOME} by locating this jar under {@code /lib/ext/},
+ * matching how JMeter loads plugins; falls back to the {@code JMETER_HOME} environment variable
+ * or the {@code jmeter.home} system property when the jar isn't installed there.
+ */
+public final class JMeterCliEnvironment {
+
+ private static volatile boolean initialized;
+
+ private JMeterCliEnvironment() {
+ }
+
+ public static synchronized void ensureInitialized() {
+ if (initialized) {
+ return;
+ }
+ System.setProperty("java.awt.headless", "true");
+ if (isNotBlank(JMeterUtils.getJMeterHome())) {
+ // Some other bootstrap (JMeter GUI/engine, or the in-process test emulator) already
+ // configured JMeterUtils; reloading properties here would discard that configuration.
+ initialized = true;
+ return;
+ }
+ String home = resolveJMeterHome();
+ JMeterUtils.setJMeterHome(home);
+ File propertiesFile = new File(new File(home, "bin"), "jmeter.properties");
+ JMeterUtils.loadJMeterProperties(propertiesFile.getAbsolutePath());
+ JMeterUtils.initLocale();
+ JMeterUtils.initLogging();
+ initialized = true;
+ }
+
+ static String resolveJMeterHome() {
+ String fromProperty = System.getProperty("jmeter.home");
+ if (isNotBlank(fromProperty)) {
+ return fromProperty.trim();
+ }
+ String fromEnv = System.getenv("JMETER_HOME");
+ if (isNotBlank(fromEnv)) {
+ return fromEnv.trim();
+ }
+ String fromJarLocation = homeFromExtDirJar(locateOwnJarFile());
+ if (fromJarLocation != null) {
+ return fromJarLocation;
+ }
+ throw new IllegalStateException(
+ "Could not determine JMeter home. This tool expects to run from /lib/ext/, "
+ + "or with -Djmeter.home= / the JMETER_HOME environment variable set.");
+ }
+
+ /**
+ * @return {@code } when {@code jarFile} looks like {@code /lib/ext/some.jar},
+ * {@code null} otherwise (e.g. when running from a build output directory).
+ */
+ static String homeFromExtDirJar(File jarFile) {
+ if (jarFile == null) {
+ return null;
+ }
+ File extDir = jarFile.getParentFile();
+ if (extDir == null || !"ext".equalsIgnoreCase(extDir.getName())) {
+ return null;
+ }
+ File libDir = extDir.getParentFile();
+ if (libDir == null || !"lib".equalsIgnoreCase(libDir.getName())) {
+ return null;
+ }
+ File home = libDir.getParentFile();
+ return home == null ? null : home.getAbsolutePath();
+ }
+
+ private static File locateOwnJarFile() {
+ try {
+ CodeSource codeSource = JMeterCliEnvironment.class.getProtectionDomain().getCodeSource();
+ if (codeSource == null) {
+ return null;
+ }
+ return new File(codeSource.getLocation().toURI());
+ } catch (URISyntaxException | IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private static boolean isNotBlank(String value) {
+ return value != null && !value.trim().isEmpty();
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java
index f1bd9a67..0aa739f1 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/DeflateContentDecoderFactory.java
@@ -12,6 +12,14 @@
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.IO;
+/**
+ * Jetty's compression module ({@code org.eclipse.jetty.compression.*}) ships gzip, brotli, and
+ * zstd decoders but no deflate one, so this streams deflate content through {@link Inflater}
+ * directly. Note this only handles zlib-wrapped deflate (RFC 1950); it is not registered as a
+ * per-request decoder in {@link HTTP2JettyClient} because retrying as raw/headerless deflate
+ * (RFC 1951) requires the fully buffered response body - see
+ * {@link HTTP2JettyClient#decodeDeflate}.
+ */
public class DeflateContentDecoderFactory extends ContentDecoder.Factory {
private final ByteBufferPool bufferPool;
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java
index e9b366f9..328e87dc 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2FutureResponseListener.java
@@ -3,7 +3,6 @@
import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug;
import java.io.IOException;
-import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.StandardCharsets;
@@ -16,12 +15,9 @@
import java.util.concurrent.TimeoutException;
import org.eclipse.jetty.client.BufferingResponseListener;
import org.eclipse.jetty.client.ContentResponse;
-import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.Request;
import org.eclipse.jetty.client.Response;
import org.eclipse.jetty.client.Result;
-import org.eclipse.jetty.http.HttpFields;
-import org.eclipse.jetty.http.HttpHeader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -34,7 +30,6 @@ public class HTTP2FutureResponseListener extends BufferingResponseListener
private volatile boolean onCompleteCalled = false;
private final CountDownLatch latch = new CountDownLatch(1);
private Request request;
- private HttpClient fallbackHttp1Client;
private ContentResponse response;
private Throwable failure;
private volatile boolean cancelled;
@@ -64,10 +59,6 @@ public Request getRequest() {
return request;
}
- public void setFallbackHttp1Client(HttpClient fallbackHttp1Client) {
- this.fallbackHttp1Client = fallbackHttp1Client;
- }
-
protected void setStart() {
if (this.responseStart == 0) {
this.responseStart = System.currentTimeMillis();
@@ -112,16 +103,22 @@ public void onFailure(Response response, Throwable failure) {
: "null";
lowLevelDebug("Failure: {}", failureInfo);
- // Store the failure immediately
+ // Store the failure immediately. If HPACK decode failed earlier,
+ // avoid protocol_error handling.
this.failure = failure;
-
+ if (failure != null && HpackFailureDetector.indicatesHpackFailure(failure)) {
+ lowLevelDebug("HPACK-related failure detected for request");
+ super.onFailure(response, failure);
+ return;
+ }
+
// Check if this is a protocol_error
if (failure != null) {
lowLevelDebug("Checking isProtocolError in onFailure() for: {}",
failure.getClass().getName());
boolean isProtocolError = ProtocolErrorException.isProtocolError(failure);
lowLevelDebug("isProtocolError returned: {}", isProtocolError);
-
+
if (isProtocolError) {
lowLevelDebug("=== PROTOCOL_ERROR DETECTED IN onFailure() ===");
lowLevelDebug("Original failure: {}: {}",
@@ -131,13 +128,13 @@ public void onFailure(Response response, Throwable failure) {
String message = failure.getMessage();
// Replace failure with ProtocolErrorException so it can be caught specifically
this.failure = new ProtocolErrorException(
- message != null ? message : "protocol_error",
+ message != null ? message : "protocol_error",
failure);
lowLevelDebug("Replaced with ProtocolErrorException: {}",
this.failure.getClass().getName());
}
}
-
+
// Call super to maintain normal behavior
super.onFailure(response, failure);
}
@@ -178,16 +175,15 @@ public void onComplete(Result result) {
boolean isProtocolError = ProtocolErrorException.isProtocolError(failure);
lowLevelDebug("isProtocolError returned: {}", isProtocolError);
- if (isProtocolError) {
+ if (isProtocolError && !HpackFailureDetector.indicatesHpackFailure(failure)) {
lowLevelDebug("=== PROTOCOL_ERROR DETECTED IN onComplete() ===");
lowLevelDebug("Original failure: {}: {}",
failure.getClass().getName(), failure.getMessage());
lowLevelDebug("HTTP/2 protocol_error detected in onComplete() - "
+ "replacing with ProtocolErrorException");
String message = failure.getMessage();
- // Replace failure with ProtocolErrorException so it can be caught specifically
failure = new ProtocolErrorException(
- message != null ? message : "protocol_error",
+ message != null ? message : "protocol_error",
failure);
lowLevelDebug("Replaced with ProtocolErrorException: {}", failure.getClass().getName());
}
@@ -341,13 +337,9 @@ public ContentResponse get() throws InterruptedException, ExecutionException {
try {
return getResult();
} catch (ProtocolErrorException e) {
- ContentResponse fallback = tryHttp11Fallback();
- if (fallback != null) {
- return fallback;
- }
LOG.error("ProtocolErrorException caught in get(), wrapping in ExecutionException");
- // Wrap ProtocolErrorException in ExecutionException to maintain interface contract
- // The calling code will unwrap it and handle the fallback
+ // Wrap ProtocolErrorException in ExecutionException so HTTP2JettyClient (the single,
+ // centralized place for HTTP/1.1 fallback decisions) can unwrap it and handle the fallback.
throw new ExecutionException(e);
}
}
@@ -371,13 +363,9 @@ public ContentResponse get(long timeout, TimeUnit unit)
try {
return getResult();
} catch (ProtocolErrorException e) {
- ContentResponse fallback = tryHttp11Fallback();
- if (fallback != null) {
- return fallback;
- }
LOG.error("ProtocolErrorException caught in get(timeout), wrapping in ExecutionException");
- // Wrap ProtocolErrorException in ExecutionException to maintain interface contract
- // The calling code will unwrap it and handle the fallback
+ // Wrap ProtocolErrorException in ExecutionException so HTTP2JettyClient (the single,
+ // centralized place for HTTP/1.1 fallback decisions) can unwrap it and handle the fallback.
throw new ExecutionException(e);
}
}
@@ -406,6 +394,9 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce
throw (CancellationException) new CancellationException().initCause(failure);
}
if (failure != null) { // Failure and Response can coexist.
+ if (HpackFailureDetector.indicatesHpackFailure(failure)) {
+ lowLevelDebug("HPACK-related failure detected in getResult()");
+ }
if (response == null) { // Only generate exception response when an response not exist
// Generated by nginx GOAWAY
LOG.error("Request failed without response: exception type={}, message={}",
@@ -418,7 +409,7 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce
boolean isProtocolError = ProtocolErrorException.isProtocolError(failure);
LOG.error("ProtocolErrorException.isProtocolError() returned: {}", isProtocolError);
- if (isProtocolError) {
+ if (isProtocolError && !HpackFailureDetector.indicatesHpackFailure(failure)) {
String message = failure.getMessage();
LOG.error("HTTP/2 protocol_error detected in getResult() - "
+ "throwing ProtocolErrorException");
@@ -426,7 +417,7 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce
LOG.error(" - Server rejected HTTP/2 connection");
LOG.error(" - This will trigger HTTP/1.1 fallback");
throw new ProtocolErrorException(message != null ? message : "protocol_error", failure);
- } else {
+ } else if (!HpackFailureDetector.indicatesHpackFailure(failure)) {
LOG.error("Failure is NOT detected as protocol_error, will throw ExecutionException");
}
@@ -444,7 +435,8 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce
response.getStatus(), response.getVersion(), failure.getClass().getName());
// Check if this is a protocol_error even though we have a response
- if (ProtocolErrorException.isProtocolError(failure)) {
+ if (ProtocolErrorException.isProtocolError(failure)
+ && !HpackFailureDetector.indicatesHpackFailure(failure)) {
String message = failure.getMessage();
LOG.error("HTTP/2 protocol_error detected after response - "
+ "throwing ProtocolErrorException");
@@ -465,46 +457,5 @@ private ContentResponse getResult() throws ExecutionException, ProtocolErrorExce
return response;
}
- private ContentResponse tryHttp11Fallback() {
- if (fallbackHttp1Client == null || request == null) {
- return null;
- }
- try {
- Request http11Request = fallbackHttp1Client.newRequest(request.getURI())
- .method(request.getMethod())
- .followRedirects(request.isFollowRedirects());
- if (request.getHeaders() != null) {
- HttpFields originalHeaders = request.getHeaders();
- HttpFields requestHeaders = http11Request.getHeaders();
- if (requestHeaders instanceof HttpFields.Mutable) {
- HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders;
- originalHeaders.forEach(field -> {
- String name = field.getName();
- if (!name.startsWith(":")) {
- newHeaders.put(name, field.getValue());
- }
- });
- if (!newHeaders.contains(HttpHeader.HOST)) {
- URI uri = request.getURI();
- String host = uri.getHost() != null ? uri.getHost() : uri.getAuthority();
- int port = uri.getPort();
- int defaultPort = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
- boolean includePort = port > 0 && port != defaultPort;
- String hostValue = includePort ? host + ":" + port : host;
- newHeaders.put(HttpHeader.HOST, hostValue);
- }
- }
- }
- if (request.getBody() != null) {
- http11Request.body(request.getBody());
- }
- lowLevelDebug("Retrying request with HTTP/1.1 in listener fallback: {}", request.getURI());
- return http11Request.send();
- } catch (Exception e) {
- LOG.error("HTTP/1.1 fallback in listener failed", e);
- return null;
- }
- }
-
}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java
index 2648ffca..0fafdb9a 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java
@@ -2,13 +2,17 @@
import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug;
+import com.blazemeter.jmeter.http2.core.jetty.CustomWwwAuthenticationProtocolHandler;
import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2;
+import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttpClientTransportOverHTTP2;
import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3;
import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler;
import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties;
+import com.blazemeter.jmeter.http2.util.Rfc9110Redirects;
import com.github.luben.zstd.ZstdInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
@@ -17,8 +21,10 @@
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
+import java.net.URLConnection;
import java.net.URLDecoder;
import java.nio.ByteBuffer;
+import java.nio.channels.ClosedChannelException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -46,6 +52,7 @@
import java.util.regex.Pattern;
import java.util.stream.StreamSupport;
import java.util.zip.GZIPInputStream;
+import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;
import org.apache.commons.lang3.StringUtils;
import org.apache.jmeter.protocol.http.control.AuthManager;
@@ -58,6 +65,7 @@
import org.apache.jmeter.protocol.http.util.HTTPArgument;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
import org.apache.jmeter.protocol.http.util.HTTPFileArg;
+import org.apache.jmeter.services.FileServer;
import org.apache.jmeter.testelement.property.JMeterProperty;
import org.apache.jmeter.util.JMeterUtils;
import org.brotli.dec.BrotliInputStream;
@@ -99,7 +107,6 @@
import org.eclipse.jetty.http2.HTTP2Session;
import org.eclipse.jetty.http2.api.Session;
import org.eclipse.jetty.http2.client.HTTP2Client;
-import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
import org.eclipse.jetty.http2.frames.Frame;
import org.eclipse.jetty.http2.frames.GoAwayFrame;
import org.eclipse.jetty.http2.frames.HeadersFrame;
@@ -132,19 +139,26 @@ public class HTTP2JettyClient {
"HTTP2JettyClient build: host-header-filter+http3-always-v2026-01-26";
private static final boolean FORCE_HTTP2_ONLY = false;
private static final Set SUPPORTED_METHODS = new HashSet<>(Arrays
- .asList(HTTPConstants.GET, HTTPConstants.POST, HTTPConstants.PUT, HTTPConstants.PATCH,
- HTTPConstants.OPTIONS, HTTPConstants.DELETE));
+ .asList(HTTPConstants.GET, HTTPConstants.HEAD, HTTPConstants.POST, HTTPConstants.PUT,
+ HTTPConstants.PATCH, HTTPConstants.OPTIONS, HTTPConstants.DELETE));
private static final Set METHODS_WITH_BODY = new HashSet<>(Arrays
.asList(HTTPConstants.POST, HTTPConstants.PUT, HTTPConstants.PATCH));
private static final Path ALPN_DEBUG_LOG_PATH = resolveAlpnLogPath();
private static final boolean ADD_CONTENT_TYPE_TO_POST_IF_MISSING = JMeterUtils.getPropDefault(
"http.post_add_content_type_if_missing", false);
+ // Matches HTTPFileImpl: caps stored response data for file:// samples, while sampleEnd's
+ // bodySize still reflects the true total bytes read.
+ private static final int MAX_FILE_SAMPLE_BYTES_TO_STORE = JMeterUtils.getPropDefault(
+ "httpsampler.max_bytes_to_store_per_request", 10 * 1024 * 1024);
+ private static final int FILE_SAMPLE_BUFFER_SIZE = 4096;
private static final Pattern PORT_PATTERN = Pattern.compile("\\d+");
private static final String MULTI_PART_SEPARATOR = "--";
private static final String LINE_SEPARATOR = "\r\n";
private static final String DEFAULT_FILE_MIME_TYPE = "application/octet-stream";
private static final String ALT_SVC_HEADER = "alt-svc";
private static final String ATTR_HTTP3_ATTEMPTED = "bzm.http3.attempted";
+ private static final String ATTR_H2C_FALLBACK_ATTEMPTED = "bzm.h2cFallbackAttempted";
+ private static final String ATTR_SKIP_H2C_UPGRADE = "bzm.skipH2cUpgrade";
private static final String ATTR_ORIGIN_KEY = "bzm.http3.origin";
private static final String ATTR_REQUEST_HEADERS_SERIALIZED = "bzm.request.headers.serialized";
private static final String PROP_SKIP_REDUNDANT_MANUAL_DECODE =
@@ -213,6 +227,10 @@ public class HTTP2JettyClient {
private CompressionContentDecoderFactory brotliDecoderFactory;
private CompressionContentDecoderFactory zstdDecoderFactory;
private ContentDecoder.Factory gzipDecoderFactory;
+ // Jetty's compression module ships gzip/brotli/zstd decoders but no deflate one, so
+ // DeflateContentDecoderFactory is our own Inflater-based implementation (not a Jetty class).
+ // Kept initialized for the disableDeflateDecoder diagnostic toggle, but no longer registered
+ // per-request; see the comment in configureContentDecoders() below.
private DeflateContentDecoderFactory deflateDecoderFactory;
private boolean decoderFactoriesInitialized = false;
private int quicMaxIdleTimeout = 30000;
@@ -272,6 +290,11 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
ClientConnectionFactory.Info http11 = HttpClientConnectionFactory.HTTP11;
HTTP2Client http2Client = new HTTP2Client(clientConnector);
+ // HTTP2Client defaults to 8 KiB; HttpClient defaults to -1 (no local HPACK cap). Match
+ // HttpClient here so parsers are not created with 8192. On start(), Jetty configure()
+ // re-syncs from HttpClient.getMaxResponseHeadersSize(), so this stays dynamic if callers
+ // change HttpClient before start().
+ http2Client.setMaxResponseHeadersSize(-1);
enableFrameLoggingIfConfigured(http2Client);
// Add session listener to log SETTINGS frames received from server (for debugging Issue #12071)
@@ -506,6 +529,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
ClientConnector h2cUpgradeConnector = createClientConnector(name + "-h2c-upgrade");
HTTP2Client http2cUpgradeClient = new HTTP2Client(h2cUpgradeConnector);
+ http2cUpgradeClient.setMaxResponseHeadersSize(-1);
http2cUpgradeClient.setUseALPN(false);
if (disableServerPush) {
http2cUpgradeClient.setMaxConcurrentPushedStreams(0);
@@ -524,13 +548,14 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name,
ClientConnector h2cConnector = createClientConnector(name + "-h2c");
HTTP2Client http2cClient = new HTTP2Client(h2cConnector);
+ http2cClient.setMaxResponseHeadersSize(-1);
http2cClient.setUseALPN(false);
if (disableServerPush) {
http2cClient.setMaxConcurrentPushedStreams(0);
} else {
http2cClient.setMaxConcurrentPushedStreams(maxConcurrentPushedStreams);
}
- HttpClientTransport h2cTransport = new HttpClientTransportOverHTTP2(http2cClient);
+ HttpClientTransport h2cTransport = new CustomHttpClientTransportOverHTTP2(http2cClient);
configureTransport(h2cTransport);
this.httpClientH2cPrior = new HttpClient(h2cTransport);
configureHttpClient(this.httpClientH2cPrior, h2cConnector);
@@ -1042,6 +1067,7 @@ public void start() throws Exception {
lowLevelDebug("Starting HttpClient: name={}, http1UpgradeRequired={}",
httpClient.getName(), http1UpgradeRequired);
httpClient.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClient);
lowLevelDebug("HttpClient started successfully");
} else {
lowLevelDebug("HttpClient already started");
@@ -1049,22 +1075,26 @@ public void start() throws Exception {
if (httpClientNoH3 != httpClient && !httpClientNoH3.isStarted()) {
lowLevelDebug("Starting HttpClient (no HTTP/3): name={}", httpClientNoH3.getName());
httpClientNoH3.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientNoH3);
lowLevelDebug("HttpClient (no HTTP/3) started successfully");
}
if (!httpClientHttp1Only.isStarted()) {
lowLevelDebug("Starting HttpClient (HTTP/1.1 only): name={}", httpClientHttp1Only.getName());
httpClientHttp1Only.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientHttp1Only);
lowLevelDebug("HttpClient (HTTP/1.1 only) started successfully");
}
if (!httpClientH2cPrior.isStarted()) {
lowLevelDebug("Starting HttpClient (H2C prior knowledge): name={}",
httpClientH2cPrior.getName());
httpClientH2cPrior.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientH2cPrior);
lowLevelDebug("HttpClient (H2C prior knowledge) started successfully");
}
if (!httpClientH2cUpgrade.isStarted()) {
lowLevelDebug("Starting HttpClient (H2C upgrade): name={}", httpClientH2cUpgrade.getName());
httpClientH2cUpgrade.start();
+ CustomWwwAuthenticationProtocolHandler.install(httpClientH2cUpgrade);
lowLevelDebug("HttpClient (H2C upgrade) started successfully");
}
}
@@ -1100,6 +1130,7 @@ private HttpClient createHTTP11OnlyClient(String name) throws Exception {
// Start the client
if (!http11Client.isStarted()) {
http11Client.start();
+ CustomWwwAuthenticationProtocolHandler.install(http11Client);
lowLevelDebug("HTTP/1.1-only fallback client started");
}
@@ -1134,10 +1165,11 @@ public HTTPSampleResult retryWithHTTP11Only(HTTP2Sampler sampler, HTTPSampleResu
}
ensureHostHeader(http11Request, url);
- configureContentDecoders(httpClientHttp1Only, http11Request);
+ configureContentDecodersAndCapture(httpClientHttp1Only, http11Request);
// Copy body if present
- setBody(http11Request, sampler, result);
+ setBody(http11Request, sampler, result, false);
+ JmeterRequestHeadersSupport.prepareFromSampler(http11Request, sampler.getUseKeepAlive());
// Send request
lowLevelDebug("Sending HTTP/1.1 fallback request");
@@ -1171,49 +1203,12 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest,
lowLevelDebug("Retrying request with HTTP/1.1 only: method={}, URI={}",
originalRequest.getMethod(), uri);
- clearContentDecoders(httpClientHttp1Only);
-
try {
- // Rebuild the request with the shared HTTP/1.1-only client
- Request http11Request = httpClientHttp1Only.newRequest(uri)
- .method(originalRequest.getMethod())
- .timeout(requestTimeout, TimeUnit.MILLISECONDS)
- .followRedirects(originalRequest.isFollowRedirects());
-
- // Copy headers from original request
- if (originalRequest.getHeaders() != null) {
- HttpFields originalHeaders = originalRequest.getHeaders();
- HttpFields requestHeaders = http11Request.getHeaders();
- if (requestHeaders instanceof HttpFields.Mutable) {
- HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders;
- originalHeaders.forEach(field -> {
- // Skip HTTP/2 pseudo-headers (they don't exist in HTTP/1.1)
- String name = field.getName();
- if (!name.startsWith(":")) {
- newHeaders.put(name, field.getValue());
- }
- });
- }
- // Note: In Jetty 12, Request headers are typically mutable.
- // If they're not mutable, the headers from the original request
- // will be lost, but this is an edge case.
- }
- ensureHostHeader(http11Request, uri);
-
- configureContentDecoders(httpClientHttp1Only, http11Request);
-
- // Copy body if present
- if (originalRequest.getBody() != null) {
- http11Request.body(originalRequest.getBody());
- }
-
- // Send request and wait for response
+ Request http11Request = buildHttp11FallbackRequest(originalRequest);
lowLevelDebug("Sending HTTP/1.1 fallback request");
ContentResponse response = http11Request.send();
-
lowLevelDebug("HTTP/1.1 fallback request succeeded: status={}, version={}",
response.getStatus(), response.getVersion());
-
return response;
} catch (Exception e) {
LOG.error("HTTP/1.1 fallback also failed for URI: {}", uri, e);
@@ -1221,6 +1216,142 @@ private ContentResponse sendWithHTTP11Only(Request originalRequest,
}
}
+ private Request buildHttp11FallbackRequest(Request originalRequest) {
+ URI uri = originalRequest.getURI();
+ clearContentDecoders(httpClientHttp1Only);
+ Request http11Request = httpClientHttp1Only.newRequest(uri)
+ .method(originalRequest.getMethod())
+ .timeout(requestTimeout, TimeUnit.MILLISECONDS)
+ .followRedirects(originalRequest.isFollowRedirects());
+ JmeterRequestHeadersSupport.copySamplerHeaderState(originalRequest, http11Request);
+ http11Request.attribute(ATTR_H2C_FALLBACK_ATTEMPTED, Boolean.TRUE);
+ if (originalRequest.getHeaders() != null) {
+ HttpFields originalHeaders = originalRequest.getHeaders();
+ HttpFields requestHeaders = http11Request.getHeaders();
+ if (requestHeaders instanceof HttpFields.Mutable) {
+ HttpFields.Mutable newHeaders = (HttpFields.Mutable) requestHeaders;
+ originalHeaders.forEach(field -> {
+ // Skip HTTP/2 pseudo-headers and h2c upgrade headers - neither exists in HTTP/1.1,
+ // and re-sending them would trigger another (futile) upgrade attempt on this retry.
+ String name = field.getName();
+ if (name.startsWith(":") || isH2cUpgradeHeader(name, field.getValue())) {
+ return;
+ }
+ newHeaders.put(name, field.getValue());
+ });
+ }
+ }
+ ensureHostHeader(http11Request, uri);
+ Object useKeepAlive =
+ http11Request.getAttributes().get(JmeterRequestHeadersSupport.ATTR_USE_KEEPALIVE);
+ if (useKeepAlive instanceof Boolean) {
+ JmeterRequestHeadersSupport.prepareFromSampler(http11Request, (Boolean) useKeepAlive);
+ }
+ configureContentDecodersAndCapture(httpClientHttp1Only, http11Request);
+ Request.Content body = originalRequest.getBody();
+ if (body != null) {
+ // The original attempt may already have read some or all of this content before
+ // failing; rewind it back to the start before reuse, exactly as Jetty's own retry
+ // paths do (see AuthenticationProtocolHandler/HttpRedirector). Skipping this can send
+ // a fallback request that declares the right Content-Length but no actual body bytes,
+ // hanging the server until its idle timeout instead of failing fast.
+ if (!body.rewind()) {
+ throw new IllegalStateException(
+ "Request body for " + uri + " is not reproducible for HTTP/1.1 fallback retry");
+ }
+ http11Request.body(body);
+ }
+ SslClientCertAliasSupport.copyFromRequest(originalRequest, http11Request);
+ return http11Request;
+ }
+
+ private static boolean isH2cUpgradeHeader(String name, String value) {
+ if (HttpHeader.UPGRADE.is(name) || HttpHeader.HTTP2_SETTINGS.is(name)) {
+ return true;
+ }
+ if (HttpHeader.CONNECTION.is(name) && value != null) {
+ String lower = value.toLowerCase(Locale.ROOT);
+ return lower.contains("upgrade") || lower.contains("http2-settings");
+ }
+ return false;
+ }
+
+ /** True if {@code request} carried an {@code Upgrade: h2c} header or attribute. */
+ private boolean wasH2cUpgradeAttempt(Request request) {
+ if (request == null) {
+ return false;
+ }
+ Object protocol = request.getAttributes().get(HttpUpgrader.PROTOCOL_ATTRIBUTE);
+ if ("h2c".equals(protocol)) {
+ return true;
+ }
+ HttpFields headers = request.getHeaders();
+ if (headers == null) {
+ return false;
+ }
+ String upgrade = headers.get(HttpHeader.UPGRADE);
+ return upgrade != null && upgrade.toLowerCase(Locale.ROOT).contains("h2c");
+ }
+
+ /**
+ * The server answered (no timeout/error) but never actually negotiated HTTP/2 despite our
+ * {@code Upgrade: h2c} attempt - e.g. it silently ignored the header, as a compliant HTTP/1.1
+ * server that doesn't support h2c is allowed to do. Retry once with a plain HTTP/1.1 request.
+ */
+ private boolean shouldRetryAfterFailedH2cUpgrade(Request request, ContentResponse response) {
+ if (!enableHttp1 || !http1UpgradeRequired || request == null || response == null) {
+ return false;
+ }
+ URI uri = request.getURI();
+ if (uri == null || !"http".equalsIgnoreCase(uri.getScheme())) {
+ return false;
+ }
+ if (Boolean.TRUE.equals(
+ request.getAttributes().get(ATTR_H2C_FALLBACK_ATTEMPTED))) {
+ return false;
+ }
+ if (!wasH2cUpgradeAttempt(request)) {
+ return false;
+ }
+ return response.getVersion() != HttpVersion.HTTP_2;
+ }
+
+ private void markCleartextHttp1Only(URI uri) {
+ if (!enableHttp1 || !http1OnlyCacheEnabled || http1OnlyCooldownMs <= 0 || uri == null) {
+ return;
+ }
+ if (!"http".equalsIgnoreCase(uri.getScheme())) {
+ return;
+ }
+ markHttp1OnlyOrigin(originKey(uri));
+ }
+
+ /**
+ * Retries a request that timed out or failed while attempting an h2c upgrade, as plain
+ * HTTP/1.1. Returns {@code null} (instead of throwing) when the failure isn't h2c-related, or
+ * when a fallback was already attempted for this request, so callers can fall through to their
+ * existing (non-h2c) handling.
+ */
+ private ContentResponse tryCleartextHttp11FallbackAfterH2cFailure(
+ Request request, HTTP2FutureResponseListener listener)
+ throws InterruptedException, TimeoutException, ExecutionException {
+ if (!enableHttp1 || request == null) {
+ return null;
+ }
+ URI uri = request.getURI();
+ if (uri == null || !"http".equalsIgnoreCase(uri.getScheme())
+ || !wasH2cUpgradeAttempt(request)) {
+ return null;
+ }
+ if (Boolean.TRUE.equals(
+ request.getAttributes().get(ATTR_H2C_FALLBACK_ATTEMPTED))) {
+ return null;
+ }
+ lowLevelDebug("Falling back to HTTP/1.1 after failed H2C upgrade for {}", uri);
+ markCleartextHttp1Only(uri);
+ return sendWithHTTP11Only(request, listener);
+ }
+
private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest)
throws InterruptedException, TimeoutException, ExecutionException {
URI uri = originalRequest.getURI();
@@ -1254,7 +1385,7 @@ private ContentResponse sendWithH2cPriorKnowledge(Request originalRequest)
}
}
ensureHostHeader(h2cRequest, uri);
- configureContentDecoders(httpClientH2cPrior, h2cRequest);
+ configureContentDecodersAndCapture(httpClientH2cPrior, h2cRequest);
if (originalRequest.getBody() != null) {
h2cRequest.body(originalRequest.getBody());
}
@@ -1287,6 +1418,14 @@ private void samplePrepareRequest(Request request,
HTTP2Sampler sampler,
HTTPSampleResult result,
HttpClient client) throws IOException {
+ samplePrepareRequest(request, sampler, result, client, false);
+ }
+
+ private void samplePrepareRequest(Request request,
+ HTTP2Sampler sampler,
+ HTTPSampleResult result,
+ HttpClient client,
+ boolean areFollowingRedirect) throws IOException {
URL url = result.getURL();
lowLevelDebug("Preparing request: URL={}, method={}", url, result.getHTTPMethod());
@@ -1294,12 +1433,18 @@ private void samplePrepareRequest(Request request,
request.followRedirects(sampler.getAutoRedirects());
String method = result.getHTTPMethod();
request.method(method);
+ if (shouldAttachRequestBody(sampler, result, areFollowingRedirect)) {
+ // The h2c Upgrade dance sends this request as plain HTTP/1.1 first; attaching a body
+ // to it works but isn't well-supported across servers, so skip the upgrade attempt
+ // entirely for bodied cleartext requests (see resolveClientForRequest).
+ request.attribute(ATTR_SKIP_H2C_UPGRADE, Boolean.TRUE);
+ }
setHeaders(request, url, sampler.getHeaderManager());
ensureHostHeader(request, url);
addPreemptiveAuthorizationHeader(request, url, sampler.getAuthManager());
lowLevelDebug("Headers set, request URI: {}", request.getURI());
- configureContentDecoders(client, request);
+ configureContentDecodersAndCapture(client, request);
String ae = request.getHeaders() != null
? request.getHeaders().get(HttpHeader.ACCEPT_ENCODING)
@@ -1310,6 +1455,16 @@ private void samplePrepareRequest(Request request,
CookieManager cookieManager = sampler.getCookieManager();
if (cookieManager != null) {
result.setCookies(buildCookies(request, url, cookieManager));
+ } else {
+ // HttpClient4 reports whatever Cookie header actually went out, even when it wasn't
+ // built by a CookieManager (e.g. set directly via HeaderManager).
+ HttpFields headers = request.getHeaders();
+ if (headers != null) {
+ String cookieHeader = headers.get(HttpHeader.COOKIE);
+ if (cookieHeader != null && !cookieHeader.isEmpty()) {
+ result.setCookies(cookieHeader);
+ }
+ }
}
if (!sampler.getProxyHost().isEmpty()) {
@@ -1322,7 +1477,8 @@ private void samplePrepareRequest(Request request,
}
result.sampleStart();
- setBody(request, sampler, result);
+ setBody(request, sampler, result, areFollowingRedirect);
+ JmeterRequestHeadersSupport.prepareFromSampler(request, sampler.getUseKeepAlive());
initializeSentBytes(result, request);
}
@@ -1348,7 +1504,18 @@ private void postContentResponse(HTTP2Sampler sampler, Request request,
JettyCacheManager cacheManager)
throws IOException {
http1UpgradeRequired = contentResponse.getVersion() != HttpVersion.HTTP_2;
- result.setRequestHeaders(getSerializedRequestHeaders(request, true));
+ // When autoRedirects silently follows a redirect chain at the transport layer, contentResponse
+ // carries the LAST request Jetty actually sent - not the original one passed in here. Report
+ // headers/sentBytes for that effective request, matching what HttpClient4 shows for the same
+ // scenario, instead of the pre-redirect request's (possibly different host/method/headers).
+ Request effectiveRequest = contentResponse.getRequest() != null
+ ? contentResponse.getRequest()
+ : request;
+ result.setRequestHeaders(getSerializedRequestHeaders(effectiveRequest, true));
+ long headerBytes = estimateRequestHeaderBytes(effectiveRequest);
+ if (headerBytes > result.getSentBytes()) {
+ result.setSentBytes(headerBytes);
+ }
setResultContentResponse(result, contentResponse);
saveCookiesInCookieManager(contentResponse, request.getURI().toURL(),
sampler.getCookieManager());
@@ -1371,7 +1538,6 @@ public Request sampleAsync(HTTP2Sampler sampler,
lowLevelDebug("Request built: URI={}, method={}", request.getURI(), request.getMethod());
samplePrepareRequest(request, sampler, result, context.client);
listener.setRequest(request);
- listener.setFallbackHttp1Client(httpClientHttp1Only);
lowLevelDebug("Request prepared, ready to send");
return request;
@@ -1391,12 +1557,17 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result,
lowLevelDebug("=== HTTP2JettyClient.sample() called ===");
lowLevelDebug("Method: {}, URL: {}", result.getHTTPMethod(), result.getURL());
+ URL sampleUrl = result.getURL();
+ if (sampleUrl != null && "file".equalsIgnoreCase(sampleUrl.getProtocol())) {
+ return sampleLocalFile(sampler, result, sampleUrl, areFollowingRedirect, depth);
+ }
+
errorWhenNotSupportedMethod(result.getHTTPMethod());
setAuthManager(sampler);
RequestContext context = buildRequestContext(result, resolveClientForRequest(sampler, result));
Request request = context.request;
- samplePrepareRequest(request, sampler, result, context.client);
+ samplePrepareRequest(request, sampler, result, context.client, areFollowingRedirect);
JettyCacheManager cacheManager =
JettyCacheManager.fromCacheManager(sampler.getCacheManager());
@@ -1419,9 +1590,9 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result,
}
throw e;
} catch (ExecutionException e) {
- if (protocolErrorFallbackEnabled && enableHttp1
- && ProtocolErrorException.isProtocolError(e)) {
- LOG.warn("Protocol error during send(), retrying with HTTP/1.1 only");
+ Throwable cause = e.getCause();
+ if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) {
+ LOG.warn("Transport failure during send(), retrying with HTTP/1.1 only");
return retryWithHTTP11Only(sampler, result);
}
throw e;
@@ -1435,6 +1606,55 @@ public HTTPSampleResult sample(HTTP2Sampler sampler, HTTPSampleResult result,
return sampler.resultProcessing(areFollowingRedirect, depth, result);
}
+ /**
+ * Matches HttpClient4's {@code HTTPFileImpl}: {@code file://} samples always use GET, open the
+ * URL directly (Java's built-in {@code file} URL handler - relative paths resolve against the
+ * JVM's working directory, not via {@link org.apache.jmeter.services.FileServer}), and always
+ * report {@code text/html} as the content type regardless of the file's actual type, since this
+ * exists to test the HTML embedded-resource parser against local fixtures, not to serve
+ * arbitrary static files.
+ */
+ private HTTPSampleResult sampleLocalFile(HTTP2Sampler sampler, HTTPSampleResult result, URL url,
+ boolean areFollowingRedirect, int depth)
+ throws Exception {
+ result.setHTTPMethod(HTTPConstants.GET);
+ result.setURL(url);
+ result.setSampleLabel(url.toString());
+ result.sampleStart();
+
+ ByteArrayOutputStream output = new ByteArrayOutputStream(FILE_SAMPLE_BUFFER_SIZE);
+ long totalBytes = 0;
+ URLConnection connection = url.openConnection();
+ try (InputStream inputStream = connection.getInputStream()) {
+ byte[] buffer = new byte[FILE_SAMPLE_BUFFER_SIZE];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ if (totalBytes < MAX_FILE_SAMPLE_BYTES_TO_STORE) {
+ int toStore = (int) Math.min(bytesRead, MAX_FILE_SAMPLE_BYTES_TO_STORE - totalBytes);
+ output.write(buffer, 0, toStore);
+ }
+ totalBytes += bytesRead;
+ }
+ }
+
+ result.sampleEnd();
+ result.setResponseData(output.toByteArray());
+ result.setBodySize(totalBytes);
+ result.setResponseCodeOK();
+ result.setResponseMessageOK();
+ result.setSuccessful(true);
+ String contentType = "text/html";
+ String contentEncoding = sampler.getContentEncoding();
+ if (StringUtils.isNotBlank(contentEncoding)) {
+ contentType = contentType + "; charset=" + contentEncoding;
+ }
+ result.setContentType(contentType);
+ result.setEncodingAndType(contentType);
+
+ resetSamplerDataBeforeResultProcessing(result);
+ return sampler.resultProcessing(areFollowingRedirect, depth, result);
+ }
+
public HTTPSampleResult sampleFromListener(HTTP2Sampler sampler, HTTPSampleResult result,
boolean areFollowingRedirect, int depth,
HTTP2FutureResponseListener listener
@@ -1502,7 +1722,9 @@ public HTTPSampleResult sampleFromListener(HTTP2Sampler sampler, HTTPSampleResul
LOG.error("isProtocolError(cause): {}", isProtocolErrorCause);
LOG.error("isProtocolError(exception): {}", isProtocolErrorException);
- if ((isProtocolErrorCause || isProtocolErrorException) && protocolErrorFallbackEnabled) {
+ if ((isProtocolErrorCause || isProtocolErrorException) && protocolErrorFallbackEnabled
+ && !HpackFailureDetector.indicatesHpackFailure(e)
+ && !HpackFailureDetector.indicatesHpackFailure(cause)) {
LOG.warn("HTTP/2 protocol_error detected in sampleFromListener()! "
+ "Attempting fallback to HTTP/1.1");
LOG.warn("Error: {}", cause != null ? cause.getMessage() : e.getMessage());
@@ -1562,6 +1784,14 @@ public ContentResponse send(Request request, HTTP2FutureResponseListener listene
return getContent(listener, request);
} catch (TimeoutException e) {
if (http1UpgradeRequired && "http".equalsIgnoreCase(uri.getScheme())) {
+ try {
+ ContentResponse fallback = tryCleartextHttp11FallbackAfterH2cFailure(request, listener);
+ if (fallback != null) {
+ return fallback;
+ }
+ } catch (Exception fallbackException) {
+ LOG.error("HTTP/1.1 fallback after H2C timeout failed", fallbackException);
+ }
try {
LOG.warn("H2C upgrade timed out; retrying with prior knowledge");
return sendWithH2cPriorKnowledge(request);
@@ -1596,10 +1826,8 @@ public ContentResponse send(Request request, HTTP2FutureResponseListener listene
throw e;
}
}
- if ((cause instanceof ProtocolErrorException
- || ProtocolErrorException.isProtocolError(cause))
- && protocolErrorFallbackEnabled) {
- LOG.warn("HTTP/2 protocol_error detected in send()! Attempting fallback to HTTP/1.1");
+ if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) {
+ LOG.warn("Transport failure detected in send()! Attempting fallback to HTTP/1.1");
LOG.warn("Error details: message='{}', exception={}",
cause != null ? cause.getMessage() : e.getMessage(),
cause != null ? cause.getClass().getName() : "unknown");
@@ -1816,6 +2044,17 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request
response.getStatus(), response.getVersion(), elapsed, contentLength);
int headerCount = response.getHeaders() != null ? response.getHeaders().size() : 0;
lowLevelDebug("Response headers: {}", headerCount);
+ if (originalRequest != null
+ && shouldRetryAfterFailedH2cUpgrade(originalRequest, response)) {
+ lowLevelDebug("H2C upgrade did not negotiate HTTP/2; retrying with HTTP/1.1 for {}",
+ originalRequest.getURI());
+ markCleartextHttp1Only(originalRequest.getURI());
+ ContentResponse fallbackResponse = sendWithHTTP11Only(originalRequest, listener);
+ updateHttp1OnlyCache(originalRequest, fallbackResponse);
+ updateH2cCache(originalRequest, fallbackResponse);
+ updateAltSvcCache(originalRequest, fallbackResponse.getHeaders());
+ return fallbackResponse;
+ }
if (originalRequest != null && response.getVersion() == HttpVersion.HTTP_3) {
recordHttp3Success(originalRequest.getURI());
}
@@ -1830,6 +2069,21 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request
long endGet = System.currentTimeMillis();
long elapsed = endGet - getStart;
LOG.error("Request timeout after {}ms: {}", elapsed, e.getMessage());
+ if (originalRequest != null) {
+ try {
+ ContentResponse fallback =
+ tryCleartextHttp11FallbackAfterH2cFailure(originalRequest, listener);
+ if (fallback != null) {
+ updateHttp1OnlyCache(originalRequest, fallback);
+ updateH2cCache(originalRequest, fallback);
+ updateAltSvcCache(originalRequest, fallback.getHeaders());
+ return fallback;
+ }
+ } catch (Exception fallbackException) {
+ LOG.error("HTTP/1.1 fallback after H2C timeout in getContent() failed",
+ fallbackException);
+ }
+ }
throw new TimeoutException("The request took more than " + elapsed
+ " milliseconds to complete");
} catch (ExecutionException e) {
@@ -1861,10 +2115,8 @@ private ContentResponse getContent(HTTP2FutureResponseListener listener, Request
}
}
}
- if ((cause instanceof ProtocolErrorException
- || ProtocolErrorException.isProtocolError(cause))
- && protocolErrorFallbackEnabled) {
- LOG.warn("HTTP/2 protocol_error detected in getContent()! "
+ if (shouldFallbackToHttp11AfterTransportFailure(cause, e)) {
+ LOG.warn("Transport failure detected in getContent()! "
+ "Attempting fallback to HTTP/1.1");
LOG.warn("Error details: message='{}', exception={}",
cause != null ? cause.getMessage() : e.getMessage(),
@@ -2084,9 +2336,14 @@ private void configureContentDecoders(HttpClient client, Request request) {
} else if (addGzip && disableGzipDecoder) {
lowLevelDebug("Gzip decoder disabled by blazemeter.http.disableGzipDecoder");
}
- if (addDeflate && deflateDecoderFactory != null && !disableDeflateDecoder) {
- factories.put(deflateDecoderFactory);
- } else if (addDeflate && disableDeflateDecoder) {
+ // Not registering deflateDecoderFactory here on purpose. It decodes per-chunk as bytes
+ // arrive off the wire, so if the first bytes don't inflate as zlib-wrapped (RFC 1950) there's
+ // no clean way to rewind and retry as raw/headerless deflate (RFC 1951) - some servers send
+ // "Content-Encoding: deflate" as raw deflate despite the RFC implying zlib. getDecodedContent()
+ // below instead decodes the fully buffered response body, where retrying with a fresh
+ // Inflater is trivial, so all deflate decoding is funneled through decodeDeflate() there to
+ // match HttpClient4 (which also tries zlib first, then raw).
+ if (addDeflate && disableDeflateDecoder) {
lowLevelDebug("Deflate decoder disabled by blazemeter.http.disableDeflateDecoder");
}
@@ -2105,8 +2362,17 @@ private void configureContentDecoders(HttpClient client, Request request) {
}
}
+ private void configureContentDecodersAndCapture(HttpClient client, Request request) {
+ configureContentDecoders(client, request);
+ JmeterCompressionHeadersSupport.installCapture(request);
+ }
+
private HttpClient selectHttpClient(URI uri) {
if (uri != null && "http".equalsIgnoreCase(uri.getScheme())) {
+ if (enableHttp1 && isHttp1Only(uri)) {
+ lowLevelDebug("HTTP/1.1-only cache hit for cleartext origin {}", originKey(uri));
+ return httpClientHttp1Only;
+ }
if (!enableHttp2 && enableHttp1) {
return httpClientHttp1Only;
}
@@ -2354,23 +2620,34 @@ private void updateHttp1OnlyCache(Request request, Response response) {
return;
}
URI uri = request.getURI();
- if (uri == null || !"https".equalsIgnoreCase(uri.getScheme())) {
+ if (uri == null) {
return;
}
String origin = originKey(uri);
HttpVersion version = response.getVersion();
- if (version == HttpVersion.HTTP_1_1) {
- Http1OnlyEntry entry = new Http1OnlyEntry();
- entry.expiresAt = System.currentTimeMillis() + http1OnlyCooldownMs;
- HTTP1_ONLY_CACHE.put(origin, entry);
- lowLevelDebug("HTTP/1.1-only cache set for origin {} until {}", origin, entry.expiresAt);
- } else if (version != null) {
- if (HTTP1_ONLY_CACHE.remove(origin) != null) {
+ if ("https".equalsIgnoreCase(uri.getScheme())) {
+ if (version == HttpVersion.HTTP_1_1) {
+ markHttp1OnlyOrigin(origin);
+ } else if (version != null && HTTP1_ONLY_CACHE.remove(origin) != null) {
lowLevelDebug("HTTP/1.1-only cache cleared for origin {}", origin);
}
+ return;
+ }
+ // Cleartext origin that attempted an h2c upgrade but didn't get HTTP/2 back: cache it as
+ // HTTP/1.1-only too, so later requests to the same origin skip the futile upgrade attempt.
+ if ("http".equalsIgnoreCase(uri.getScheme()) && wasH2cUpgradeAttempt(request)
+ && version != HttpVersion.HTTP_2) {
+ markHttp1OnlyOrigin(origin);
}
}
+ private void markHttp1OnlyOrigin(String origin) {
+ Http1OnlyEntry entry = new Http1OnlyEntry();
+ entry.expiresAt = System.currentTimeMillis() + http1OnlyCooldownMs;
+ HTTP1_ONLY_CACHE.put(origin, entry);
+ lowLevelDebug("HTTP/1.1-only cache set for origin {} until {}", origin, entry.expiresAt);
+ }
+
private void updateH2cCache(Request request, Response response) {
if (!enableHttp2 || !h2cCacheEnabled || h2cCacheTtlMs <= 0) {
return;
@@ -2432,6 +2709,31 @@ RetryableRequestException findRetryableRequestException(Throwable cause) {
return null;
}
+ /**
+ * Unifies the two failure modes that warrant an HTTP/1.1 fallback: an explicit HTTP/2
+ * {@code protocol_error}, and a {@link ClosedChannelException} anywhere in the cause chain -
+ * some servers drop the connection outright instead of returning a clean protocol error when
+ * they don't like the request (e.g. a failed h2c upgrade attempt).
+ */
+ private boolean shouldFallbackToHttp11AfterTransportFailure(Throwable cause,
+ Throwable wrapped) {
+ if (!protocolErrorFallbackEnabled || !enableHttp1) {
+ return false;
+ }
+ return ProtocolErrorException.isProtocolError(wrapped)
+ || ProtocolErrorException.isProtocolError(cause)
+ || isClosedChannelFailure(cause != null ? cause : wrapped);
+ }
+
+ private static boolean isClosedChannelFailure(Throwable throwable) {
+ for (Throwable current = throwable; current != null; current = current.getCause()) {
+ if (current instanceof ClosedChannelException) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private ContentResponse retryAfterGoAway(Request originalRequest)
throws InterruptedException, TimeoutException, ExecutionException {
if (originalRequest == null) {
@@ -2511,7 +2813,8 @@ private Request cloneRequest(Request originalRequest, HttpClient client)
if (originalRequest.getBody() != null) {
request.body(originalRequest.getBody());
}
- configureContentDecoders(client, request);
+ SslClientCertAliasSupport.copyFromRequest(originalRequest, request);
+ configureContentDecodersAndCapture(client, request);
return request;
}
@@ -2541,7 +2844,9 @@ private void configureHttpClient(HttpClient client, ClientConnector connector) {
client.setDestinationIdleTimeout(idleTimeout);
}
client.setIdleTimeout(idleTimeout);
- addConnectionLogging(client);
+ if (LowLevelDebugLog.isEnabled()) {
+ addConnectionLogging(client);
+ }
}
private static void addConnectionLogging(HttpClient client) {
@@ -2589,6 +2894,9 @@ public void handshakeFailed(Event event, Throwable failure) {
}
private static void logAlpnLine(String message) {
+ if (!LowLevelDebugLog.isEnabled()) {
+ return;
+ }
try {
Path parent = ALPN_DEBUG_LOG_PATH.getParent();
if (parent != null) {
@@ -2888,6 +3196,10 @@ private RequestContext buildRequestContext(HTTPSampleResult result, HttpClient c
boolean http3Attempted = enableHttp3 && client == httpClient && shouldAttemptHttp3(uri);
request.attribute(ATTR_HTTP3_ATTEMPTED, http3Attempted);
request.attribute(ATTR_ORIGIN_KEY, originKey(uri));
+ if ("https".equalsIgnoreCase(uri.getScheme())) {
+ String clientCertAlias = JMeterSslAliasResolver.resolveForRequest();
+ SslClientCertAliasSupport.bindToRequest(request, clientCertAlias);
+ }
request.onRequestBegin(r -> result.connectEnd());
request.onRequestContent(
(r, c) -> result.setSentBytes(result.getSentBytes() + c.limit()));
@@ -2897,7 +3209,13 @@ private RequestContext buildRequestContext(HTTPSampleResult result, HttpClient c
private HttpClient resolveClientForRequest(HTTP2Sampler sampler, HTTPSampleResult result)
throws URISyntaxException {
- return selectHttpClient(result.getURL().toURI());
+ URI uri = result.getURL().toURI();
+ if ("http".equalsIgnoreCase(uri.getScheme())
+ && shouldAttachRequestBody(sampler, result, false)) {
+ lowLevelDebug("Cleartext request with body; using HTTP/1.1-only client for {}", uri);
+ return httpClientHttp1Only;
+ }
+ return selectHttpClient(uri);
}
private boolean requestAdvertisesEncoding(HTTP2Sampler sampler, String encoding) {
@@ -2983,8 +3301,9 @@ private void setHeaders(Request request, URL url, HeaderManager headerManager) {
// 1. The connection is already HTTP/2 (negotiated via ALPN)
// 2. Upgrade headers are for cleartext HTTP, not HTTPS
// 3. It violates the HTTP/2 protocol (RFC 7540)
- if (http1UpgradeRequired && !"https".equalsIgnoreCase(url.getProtocol())
- && !shouldUseH2cPriorKnowledge(request.getURI())) {
+ if (http1UpgradeRequired && enableHttp2 && !"https".equalsIgnoreCase(url.getProtocol())
+ && !shouldUseH2cPriorKnowledge(request.getURI())
+ && !Boolean.TRUE.equals(request.getAttributes().get(ATTR_SKIP_H2C_UPGRADE))) {
Mutable headers = ((Mutable) request.getHeaders());
addHeaderIfMissing(HttpHeader.UPGRADE, "h2c", headers);
addHeaderIfMissing(HttpHeader.HTTP2_SETTINGS, buildH2cSettingsHeaderValue(), headers);
@@ -3155,7 +3474,7 @@ private void filterInvalidHTTP2Headers(Request request) {
// For HTTPS connections, we assume HTTP/2 if ALPN negotiated it
// For HTTP connections, we check if upgrade headers are present
boolean isHTTP2 = "https".equalsIgnoreCase(request.getURI().getScheme())
- || (http1UpgradeRequired && headers.contains(HttpHeader.UPGRADE));
+ || (enableHttp2 && http1UpgradeRequired && headers.contains(HttpHeader.UPGRADE));
if (isHTTP2) {
// HTTP/2 does not support Connection header except for upgrade (which we handle separately)
@@ -3271,13 +3590,18 @@ private void addProxyIfEmpty(HttpClient target, String host, int port, boolean s
}
}
- private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult result)
+ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult result,
+ boolean areFollowingRedirect)
throws IOException {
+ if (!shouldAttachRequestBody(sampler, result, areFollowingRedirect)) {
+ result.setQueryString("");
+ return;
+ }
String contentEncoding = sampler.getContentEncoding();
String contentTypeHeader =
request.getHeaders() != null ? request.getHeaders().get(HTTPConstants.HEADER_CONTENT_TYPE)
: null;
- boolean hasContentTypeHeader = contentTypeHeader != null && contentTypeHeader.isEmpty();
+ boolean hasContentTypeHeader = StringUtils.isNotBlank(contentTypeHeader);
StringBuilder postBody = new StringBuilder();
if (sampler.getUseMultipart()) {
// In Jetty 12, MultiPartRequestContent API has changed significantly
@@ -3321,7 +3645,7 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res
if (StringUtils.isBlank(file.getParamName())) {
throw new IllegalStateException("Param name is blank");
}
- String fileName = Paths.get((file.getPath())).getFileName().toString();
+ String fileName = resolveHttpFile(file.getPath()).getName();
postBody.append(buildFilePartRequestBody(file, fileName, boundary));
}
postBody.append(MULTI_PART_SEPARATOR).append(boundary).append(MULTI_PART_SEPARATOR)
@@ -3346,48 +3670,60 @@ private void setBody(Request request, HTTP2Sampler sampler, HTTPSampleResult res
}
// In Jetty 12, PathRequestContent implements Request.Content directly
Request.Content requestContent =
- new PathRequestContent(mimeTypeFile, Path.of(file.getPath()));
+ new PathRequestContent(mimeTypeFile, resolveHttpFile(file.getPath()).toPath());
request.body(requestContent);
postBody.append("");
} else {
- if (!hasContentTypeHeader && ADD_CONTENT_TYPE_TO_POST_IF_MISSING) {
- HttpFields headers = request.getHeaders();
- if (headers instanceof HttpFields.Mutable) {
- ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE,
- HTTPConstants.APPLICATION_X_WWW_FORM_URLENCODED);
- }
- }
Charset contentCharset = buildCharsetOrDefault(contentEncoding, StandardCharsets.UTF_8);
if (sampler.getSendParameterValuesAsPostBody()) {
+ if (!hasContentTypeHeader) {
+ HttpFields headers = request.getHeaders();
+ if (headers instanceof HttpFields.Mutable) {
+ ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE,
+ "text/plain; charset=" + contentCharset.name());
+ }
+ }
for (JMeterProperty jMeterProperty : sampler.getArguments()) {
HTTPArgument arg = (HTTPArgument) jMeterProperty.getObjectValue();
postBody.append(arg.getEncodedValue(contentCharset.name()));
}
+ String bodyContentType = request.getHeaders() != null
+ ? request.getHeaders().get(HTTPConstants.HEADER_CONTENT_TYPE)
+ : null;
// In Jetty 12, StringRequestContent implements Request.Content directly
Request.Content requestContent =
- new StringRequestContent(contentTypeHeader, postBody.toString(),
- contentCharset);
+ new StringRequestContent(bodyContentType, postBody.toString(), contentCharset);
request.body(requestContent);
- } else if (isMethodWithBody(sampler.getMethod())) {
- Fields fields = new Fields();
- for (JMeterProperty p : sampler.getArguments()) {
- HTTPArgument arg = (HTTPArgument) p.getObjectValue();
- String parameterName = arg.getName();
- if (!arg.isSkippable(parameterName)) {
- String parameterValue = arg.getValue();
- if (!arg.isAlwaysEncoded()) {
- // The FormRequestContent always urlencodes both name and value, in this case the
- // value is already encoded by the user so is needed to decode the value now, so
- // that when the httpclient encodes it, we end up with the same value as the user
- // had entered.
- parameterName = URLDecoder.decode(parameterName, contentCharset.name());
- parameterValue = URLDecoder.decode(parameterValue, contentCharset.name());
+ } else {
+ if (!hasContentTypeHeader && ADD_CONTENT_TYPE_TO_POST_IF_MISSING
+ && isMethodWithBody(sampler.getMethod())) {
+ HttpFields headers = request.getHeaders();
+ if (headers instanceof HttpFields.Mutable) {
+ ((HttpFields.Mutable) headers).put(HTTPConstants.HEADER_CONTENT_TYPE,
+ HTTPConstants.APPLICATION_X_WWW_FORM_URLENCODED);
+ }
+ }
+ if (isMethodWithBody(sampler.getMethod())) {
+ Fields fields = new Fields();
+ for (JMeterProperty p : sampler.getArguments()) {
+ HTTPArgument arg = (HTTPArgument) p.getObjectValue();
+ String parameterName = arg.getName();
+ if (!arg.isSkippable(parameterName)) {
+ String parameterValue = arg.getValue();
+ if (!arg.isAlwaysEncoded()) {
+ // The FormRequestContent always urlencodes both name and value, in this case
+ // the value is already encoded by the user so is needed to decode the value
+ // now, so that when the httpclient encodes it, we end up with the same value
+ // as the user had entered.
+ parameterName = URLDecoder.decode(parameterName, contentCharset.name());
+ parameterValue = URLDecoder.decode(parameterValue, contentCharset.name());
+ }
+ fields.add(parameterName, parameterValue);
}
- fields.add(parameterName, parameterValue);
}
+ postBody.append(FormRequestContent.convert(fields));
+ request.body(new FormRequestContent(fields, contentCharset));
}
- postBody.append(FormRequestContent.convert(fields));
- request.body(new FormRequestContent(fields, contentCharset));
}
}
}
@@ -3436,7 +3772,8 @@ private String getSerializedRequestHeaders(Request request, boolean refresh) {
if (!refresh && cached instanceof String) {
return (String) cached;
}
- String serialized = buildHeadersString(request.getHeaders());
+ String serialized = buildHeadersString(
+ JmeterRequestHeadersSupport.headersForSampleResult(request));
request.attribute(ATTR_REQUEST_HEADERS_SERIALIZED, serialized);
return serialized;
}
@@ -3451,6 +3788,11 @@ private Charset buildCharsetOrDefault(String contentEncoding, Charset defaultCha
return !contentEncoding.isEmpty() ? Charset.forName(contentEncoding) : defaultCharset;
}
+ /** HttpClient4 writes raw argument values in multipart parts (not URL-encoded). */
+ private static String multipartArgumentValue(HTTPArgument arg) {
+ return arg.getValue();
+ }
+
private String buildArgumentPartRequestBody(HTTPArgument arg, Charset contentCharset,
String contentEncoding, String boundary)
throws UnsupportedEncodingException {
@@ -3458,20 +3800,29 @@ private String buildArgumentPartRequestBody(HTTPArgument arg, Charset contentCha
String contentType = arg.getContentType() + "; charset=" + contentCharset.name();
String encoding = StringUtils.isNotBlank(contentEncoding) ? contentEncoding : "8bit";
return buildPartBody(boundary, disposition, contentType, encoding,
- arg.getEncodedValue(contentCharset.name()));
+ multipartArgumentValue(arg));
}
private String buildPartBody(String boundary, String disposition, String contentType,
String encoding, String value) {
- return MULTI_PART_SEPARATOR + boundary + LINE_SEPARATOR +
- HttpFields.build()
- .add("Content-Disposition", "form-data; " + disposition)
- .add(HttpHeader.CONTENT_TYPE.toString(), contentType)
- .add("Content-Transfer-Encoding", encoding)
- .toString()
+ return MULTI_PART_SEPARATOR + boundary + LINE_SEPARATOR
+ + formatMultipartPartHeaders("form-data; " + disposition, contentType, encoding)
+ value + LINE_SEPARATOR;
}
+ /** HC4 canonical header casing; Jetty {@link HttpFields#toString()} lowercases names. */
+ private String formatMultipartPartHeaders(String disposition, String contentType,
+ String transferEncoding) {
+ StringBuilder headers = new StringBuilder();
+ headers.append("Content-Disposition: ").append(disposition).append(LINE_SEPARATOR);
+ headers.append("Content-Type: ").append(contentType).append(LINE_SEPARATOR);
+ if (transferEncoding != null && !transferEncoding.isEmpty()) {
+ headers.append("Content-Transfer-Encoding: ").append(transferEncoding)
+ .append(LINE_SEPARATOR);
+ }
+ return headers.toString();
+ }
+
private String buildFilePartRequestBody(HTTPFileArg file, String fileName, String boundary) {
String disposition = "name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"";
return buildPartBody(boundary, disposition, file.getMimeType(), "binary",
@@ -3490,6 +3841,26 @@ private String extractFileMimeType(boolean hasContentTypeHeader, HTTPFileArg fil
return ret == null ? DEFAULT_FILE_MIME_TYPE : ret;
}
+ /**
+ * Resolves a sampler file path the same way HttpClient4 does: relative to the running test
+ * plan's directory via {@link FileServer}, falling back to JMeter's {@code bin} directory for
+ * files bundled alongside JMeter itself.
+ */
+ private File resolveHttpFile(String path) throws IOException {
+ if (StringUtils.isBlank(path)) {
+ throw new IOException("Empty HTTP file path");
+ }
+ File resolved = FileServer.getFileServer().getResolvedFile(path);
+ if (resolved.isFile()) {
+ return resolved;
+ }
+ Path inBin = Paths.get(JMeterUtils.getJMeterBinDir(), path);
+ if (Files.isRegularFile(inBin)) {
+ return inBin.toFile();
+ }
+ throw new IOException("HTTP file not found: " + path);
+ }
+
/**
* Builds multipart/form-data body as bytes for Jetty 12.
* In Jetty 12, MultiPartRequestContent API changed, so we build the body manually.
@@ -3520,14 +3891,12 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary,
argContentType = argContentType + "; charset="
+ contentCharset.name().toLowerCase(Locale.ROOT);
- Mutable partHeaders = HttpFields.build()
- .add("Content-Disposition", "form-data; name=\"" + arg.getEncodedName() + "\"")
- .add(HttpHeader.CONTENT_TYPE, argContentType);
+ String partHeaders = formatMultipartPartHeaders(
+ "form-data; name=\"" + arg.getEncodedName() + "\"", argContentType, "8bit");
output.write(boundaryLine.getBytes(StandardCharsets.US_ASCII));
- output.write(partHeaders.toString().getBytes(StandardCharsets.US_ASCII));
- output.write(newLine.getBytes(StandardCharsets.US_ASCII));
- String argValue = arg.getEncodedValue(contentCharset.name());
+ output.write(partHeaders.getBytes(StandardCharsets.US_ASCII));
+ String argValue = multipartArgumentValue(arg);
output.write(argValue.getBytes(contentCharset));
output.write(newLine.getBytes(StandardCharsets.US_ASCII));
}
@@ -3538,22 +3907,19 @@ private byte[] buildMultipartBodyBytes(HTTP2Sampler sampler, String boundary,
if (StringUtils.isBlank(file.getParamName())) {
throw new IllegalStateException("Param name is blank");
}
- String fileName = Paths.get(file.getPath()).getFileName().toString();
+ File resolvedFile = resolveHttpFile(file.getPath());
+ String fileName = resolvedFile.getName();
String mimeTypeFile = extractFileMimeType(hasContentTypeHeader, file);
- // Build headers using HttpFields to match the format expected by tests
- // The test uses HttpFields.build().toString() which has a specific format
- Mutable partHeaders = HttpFields.build()
- .add("Content-Disposition",
- "form-data; name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"")
- .add(HttpHeader.CONTENT_TYPE, mimeTypeFile);
+ String partHeaders = formatMultipartPartHeaders(
+ "form-data; name=\"" + file.getParamName() + "\"; filename=\"" + fileName + "\"",
+ mimeTypeFile, "binary");
output.write(boundaryLine.getBytes(StandardCharsets.US_ASCII));
- output.write(partHeaders.toString().getBytes(StandardCharsets.US_ASCII));
- output.write(newLine.getBytes(StandardCharsets.US_ASCII));
+ output.write(partHeaders.getBytes(StandardCharsets.US_ASCII));
// Read and write file content
- try (InputStream fileStream = Files.newInputStream(Paths.get(file.getPath()))) {
+ try (InputStream fileStream = Files.newInputStream(resolvedFile.toPath())) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = fileStream.read(buffer)) != -1) {
@@ -3574,6 +3940,29 @@ private boolean isMethodWithBody(String method) {
return METHODS_WITH_BODY.contains(method);
}
+ /**
+ * Matches HttpClient4: entities are only attached for POST/PUT/PATCH, or GET/DELETE when
+ * {@code postBodyRaw} is enabled ({@code HttpGetWithEntity}).
+ */
+ private boolean shouldAttachRequestBody(HTTP2Sampler sampler, HTTPSampleResult result,
+ boolean areFollowingRedirect) {
+ String method = resolveRequestMethod(sampler, result);
+ if (areFollowingRedirect && !isMethodWithBody(method)) {
+ return false;
+ }
+ if (isMethodWithBody(method)) {
+ return true;
+ }
+ return sampler.getSendParameterValuesAsPostBody();
+ }
+
+ private String resolveRequestMethod(HTTP2Sampler sampler, HTTPSampleResult result) {
+ if (result != null && StringUtils.isNotBlank(result.getHTTPMethod())) {
+ return result.getHTTPMethod();
+ }
+ return sampler.getMethod();
+ }
+
private boolean isSupportedMethod(String method) {
return SUPPORTED_METHODS.contains(method);
}
@@ -3584,6 +3973,11 @@ private String buildHeadersString(HttpFields headers) {
} else {
String ret = HttpFields.build(headers).remove(HTTPConstants.HEADER_COOKIE).toString()
.replace("\r\n", "\n");
+ // When Cookie was the only header, removing it leaves an empty string; ret.length() - 1
+ // would then be -1, which substring() rejects.
+ if (ret.isEmpty()) {
+ return "";
+ }
return ret.substring(0,
ret.length() - 1); // removing final separator not included in jmeter headers
}
@@ -3629,7 +4023,12 @@ private void setResultContentResponse(HTTPSampleResult result,
result.setSuccessful(
contentResponse.getStatus() >= 200 && contentResponse.getStatus() <= 399);
result.setResponseHeaders(extractResponseHeaders(contentResponse, responseMessage));
- if (result.isRedirect()) {
+ // Use the RFC 9110-correct redirect check (see Rfc9110Redirects), not result.isRedirect():
+ // JMeter 5.6.3's version misses 307 for non-GET/HEAD methods, which would otherwise leave
+ // redirectLocation unset and break HTTP2Sampler.followRedirects()/resultProcessing() for
+ // that case.
+ if (Rfc9110Redirects.useLegacyMethodHandling() ? result.isRedirect()
+ : Rfc9110Redirects.isRedirect(result.getResponseCode())) {
result.setRedirectLocation(extractRedirectLocation(contentResponse));
}
@@ -3637,9 +4036,11 @@ private void setResultContentResponse(HTTPSampleResult result,
result.setURL(contentResponse.getRequest().getURI().toURL());
}
+ HttpFields sampleResultHeaders =
+ JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse);
long headerBytes =
(long) result.getResponseHeaders().length() // condensed length (without \r)
- + (long) contentResponse.getHeaders().asString().length() // Add \r for each header
+ + (long) sampleResultHeaders.asString().length() // Add \r for each header
+ 1L // Add \r for initial header
+ 2L; // final \r\n before data
result.setHeadersSize((int) headerBytes);
@@ -3647,8 +4048,9 @@ private void setResultContentResponse(HTTPSampleResult result,
private String extractResponseHeaders(ContentResponse contentResponse,
String message) {
+ HttpFields headers = JmeterCompressionHeadersSupport.headersForSampleResult(contentResponse);
return contentResponse.getVersion() + " " + contentResponse.getStatus() + " " + message + "\n"
- + buildHeadersString(contentResponse.getHeaders());
+ + buildHeadersString(headers);
}
private String extractRedirectLocation(ContentResponse contentResponse) {
@@ -3738,6 +4140,8 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) {
if (contentEncoding == null || contentEncoding.trim().isEmpty()) {
return content;
}
+ JmeterCompressionHeadersSupport.captureIfCompressed(
+ contentResponse.getRequest(), contentResponse.getHeaders());
String encodingToken = normalizeEncodingToken(contentEncoding);
if (encodingToken.isEmpty()) {
return content;
@@ -3747,7 +4151,11 @@ private byte[] maybeDecodeCompressedContent(ContentResponse contentResponse) {
// should have handled it and re-decoding only adds CPU/alloc pressure.
boolean skipRedundantManualDecode = Boolean.parseBoolean(
System.getProperty(PROP_SKIP_REDUNDANT_MANUAL_DECODE, "true"));
+ // deflate is excluded from this fast path: it is never registered as a per-request Jetty
+ // decoder (see configureContentDecoders() above), so it must always go through decodeDeflate()
+ // below, which is the only place that tries both deflate variants.
if (skipRedundantManualDecode
+ && !"deflate".equals(encodingToken)
&& requestAdvertisedEncoding(contentResponse.getRequest(), encodingToken)) {
return content;
}
@@ -3819,15 +4227,33 @@ private byte[] decodeGzip(byte[] content, String contentEncoding) {
}
}
+ /**
+ * "Content-Encoding: deflate" is ambiguous in practice: the RFC implies zlib-wrapped deflate
+ * (RFC 1950), but plenty of real servers send raw/headerless deflate (RFC 1951) under the same
+ * header. Try zlib first, then raw, before giving up - matching HttpClient4's behavior.
+ */
private byte[] decodeDeflate(byte[] content, String contentEncoding) {
- try (InputStream input = new InflaterInputStream(new ByteArrayInputStream(content));
+ byte[] zlibDecoded = tryInflateDeflate(content, false);
+ if (zlibDecoded != null) {
+ return zlibDecoded;
+ }
+ byte[] rawDecoded = tryInflateDeflate(content, true);
+ if (rawDecoded != null) {
+ return rawDecoded;
+ }
+ lowLevelDebug("Failed to decode deflate content ({}), keeping original bytes",
+ contentEncoding);
+ return content;
+ }
+
+ private byte[] tryInflateDeflate(byte[] content, boolean nowrap) {
+ try (InflaterInputStream input = new InflaterInputStream(
+ new ByteArrayInputStream(content), new Inflater(nowrap));
ByteArrayOutputStream output = new ByteArrayOutputStream(content.length)) {
copy(input, output);
return output.toByteArray();
} catch (IOException e) {
- lowLevelDebug("Failed to decode deflate content ({}), keeping original bytes",
- contentEncoding, e);
- return content;
+ return null;
}
}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java b/src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java
new file mode 100644
index 00000000..f3bf1092
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/HpackFailureDetector.java
@@ -0,0 +1,43 @@
+package com.blazemeter.jmeter.http2.core;
+
+import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug;
+
+import java.util.Locale;
+
+/**
+ * Detects HPACK-related failures from Jetty/JMeter surfaced errors (messages and causes).
+ */
+public final class HpackFailureDetector {
+
+ private HpackFailureDetector() {
+ }
+
+ public static boolean indicatesHpackFailure(Throwable failure) {
+ for (Throwable current = failure; current != null; current = current.getCause()) {
+ if (isHpackRelated(current)) {
+ lowLevelDebug("HPACK-related failure detected: {}: {}",
+ current.getClass().getName(), current.getMessage());
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static boolean isHpackRelated(Throwable failure) {
+ if (failure.getClass().getName().contains("HpackException")) {
+ return true;
+ }
+ String message = failure.getMessage();
+ if (message == null || message.isEmpty()) {
+ return false;
+ }
+ String lower = message.toLowerCase(Locale.ROOT);
+ if (lower.contains("invalid_hpack")) {
+ return true;
+ }
+ if (lower.contains("header size") && lower.contains(">")) {
+ return true;
+ }
+ return lower.contains("hpack") && lower.contains("header");
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java
index 10aa0050..c4caec94 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterJettySslContextFactory.java
@@ -1,31 +1,56 @@
package com.blazemeter.jmeter.http2.core;
+import static com.blazemeter.jmeter.http2.core.LowLevelDebugLog.lowLevelDebug;
+
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.Socket;
import java.security.KeyStore;
import java.security.Principal;
import java.security.PrivateKey;
+import java.security.cert.CRL;
import java.security.cert.X509Certificate;
+import java.util.Collection;
+import java.util.Map;
import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLEngine;
+import javax.net.ssl.TrustManager;
import javax.net.ssl.X509ExtendedKeyManager;
import javax.net.ssl.X509KeyManager;
+import org.apache.jmeter.threads.JMeterContextService;
+import org.apache.jmeter.threads.JMeterVariables;
import org.apache.jmeter.util.JsseSSLManager;
import org.apache.jmeter.util.SSLManager;
import org.apache.jmeter.util.keystore.JmeterKeyStore;
+import org.eclipse.jetty.io.ssl.SslClientConnectionFactory;
import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class JMeterJettySslContextFactory extends SslContextFactory.Client
+ implements SslClientConnectionFactory.SslEngineFactory {
-public class JMeterJettySslContextFactory extends SslContextFactory.Client {
+ private static final Logger LOG = LoggerFactory.getLogger(JMeterJettySslContextFactory.class);
private final JmeterKeyStore keys;
public JMeterJettySslContextFactory() {
setTrustAll(true);
+ setValidatePeerCerts(false);
String keyStorePath = System.getProperty("javax.net.ssl.keyStore");
if (keyStorePath != null && !keyStorePath.isEmpty()) {
- setKeyStorePath("file://" + keyStorePath);
- keys = getKeyStore((JsseSSLManager) SSLManager.getInstance());
+ if (SslStorePathResolver.isFileBasedStoreLocation(keyStorePath)) {
+ String jettyKeyStoreUri = SslStorePathResolver.toJettyFileUri(keyStorePath);
+ String keyStoreType = SslStorePathResolver.resolveKeyStoreType(keyStorePath);
+ lowLevelDebug(
+ "SSL keyStore path resolved: javax.net.ssl.keyStore='{}' -> jettyUri='{}'",
+ keyStorePath, jettyKeyStoreUri);
+ lowLevelDebug(
+ "SSL keyStore type resolved: javax.net.ssl.keyStoreType='{}' -> jettyType='{}'",
+ System.getProperty("javax.net.ssl.keyStoreType"), keyStoreType);
+ configureKeyStorePathForJetty(keyStorePath, jettyKeyStoreUri, keyStoreType);
+ }
+ keys = loadJMeterKeyStore(keyStorePath);
/*
we need to set password after getting keystore since getKeystore may ask the user for the
password.
@@ -37,7 +62,17 @@ public JMeterJettySslContextFactory() {
String truststore = System.getProperty("javax.net.ssl.trustStore");
if (truststore != null && !truststore.isEmpty()) {
- setTrustStorePath("file://" + truststore);
+ if (SslStorePathResolver.isFileBasedStoreLocation(truststore)) {
+ String jettyTrustStoreUri = SslStorePathResolver.toJettyFileUri(truststore);
+ String trustStoreType = SslStorePathResolver.resolveTrustStoreType(truststore);
+ lowLevelDebug(
+ "SSL trustStore path resolved: javax.net.ssl.trustStore='{}' -> jettyUri='{}'",
+ truststore, jettyTrustStoreUri);
+ lowLevelDebug(
+ "SSL trustStore type resolved: javax.net.ssl.trustStoreType='{}' -> jettyType='{}'",
+ System.getProperty("javax.net.ssl.trustStoreType"), trustStoreType);
+ configureTrustStorePathForJetty(truststore, jettyTrustStoreUri, trustStoreType);
+ }
getTrustStore((JsseSSLManager) SSLManager.getInstance());
/*
we need to set password after getting truststore since getTrustStore may ask the user for the
@@ -47,6 +82,42 @@ public JMeterJettySslContextFactory() {
}
}
+ private JmeterKeyStore loadJMeterKeyStore(String keyStorePath) {
+ try {
+ return getKeyStore((JsseSSLManager) SSLManager.getInstance());
+ } catch (RuntimeException e) {
+ LOG.warn("Could not load JMeter keyStore from '{}': {}. "
+ + "Client certificate alias selection may be unavailable.",
+ keyStorePath, e.getMessage());
+ lowLevelDebug("Could not load JMeter keyStore from '{}'", keyStorePath, e);
+ return null;
+ }
+ }
+
+ void configureKeyStorePathForJetty(String originalPath, String jettyUri, String storeType) {
+ try {
+ setKeyStorePath(jettyUri);
+ setKeyStoreType(storeType);
+ } catch (RuntimeException e) {
+ LOG.warn("Could not set Jetty keyStore path for '{}': {}. "
+ + "Client certificate authentication may not work.",
+ originalPath, e.getMessage());
+ lowLevelDebug("Could not set Jetty keyStore path for '{}'", originalPath, e);
+ }
+ }
+
+ void configureTrustStorePathForJetty(String originalPath, String jettyUri, String storeType) {
+ try {
+ setTrustStorePath(jettyUri);
+ setTrustStoreType(storeType);
+ } catch (RuntimeException e) {
+ LOG.warn("Could not set Jetty trustStore path for '{}': {}. "
+ + "Trust-all SSL configuration will still be used.",
+ originalPath, e.getMessage());
+ lowLevelDebug("Could not set Jetty trustStore path for '{}'", originalPath, e);
+ }
+ }
+
private JmeterKeyStore getKeyStore(JsseSSLManager sslManager) {
try {
Method keystoreMethod = SSLManager.class.getDeclaredMethod("getKeyStore");
@@ -77,6 +148,32 @@ protected void checkTrustAll() {
protected void checkEndPointIdentificationAlgorithm() {
}
+ // JMeter HTTP uses CustomX509TrustManager which does not validate server certificates.
+ // Jetty still runs PKIX when a keyStore is configured unless trust managers are overridden.
+ @Override
+ protected TrustManager[] getTrustManagers(KeyStore trustStore,
+ Collection extends CRL> crls) throws Exception {
+ if (isTrustAll()) {
+ lowLevelDebug("SSL trust managers: using TRUST_ALL_CERTS (JMeter HTTP parity)");
+ return TRUST_ALL_CERTS;
+ }
+ return super.getTrustManagers(trustStore, crls);
+ }
+
+ @Override
+ public SSLEngine newSslEngine(String host, int port, Map context) {
+ SSLEngine engine = super.newSSLEngine(host, port);
+ bindAliasFromContext(engine, context);
+ return engine;
+ }
+
+ private static void bindAliasFromContext(SSLEngine engine, Map context) {
+ String alias = SslClientCertAliasContext.readAlias(context);
+ if (alias != null) {
+ SslClientCertAliasContext.bindEngine(engine, alias);
+ }
+ }
+
// Overwritten to provide jmeter SSLManager configured keyManagers
@Override
protected KeyManager[] getKeyManagers(KeyStore keyStore) throws Exception {
@@ -126,12 +223,33 @@ public PrivateKey getPrivateKey(String alias) {
@Override
public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) {
- return store.getAlias();
+ return resolveClientAlias(null);
}
+ @Override
public String chooseEngineClientAlias(String[] keyType, Principal[] issuers,
SSLEngine engine) {
- return store.getAlias();
+ return resolveClientAlias(engine);
+ }
+
+ private String resolveClientAlias(SSLEngine engine) {
+ String boundAlias = SslClientCertAliasContext.resolveFromEngine(engine);
+ if (boundAlias != null) {
+ return boundAlias;
+ }
+ JMeterVariables variables = JMeterContextService.getContext().getVariables();
+ if (variables != null) {
+ return store.getAlias();
+ }
+ return firstConfiguredAlias();
+ }
+
+ private String firstConfiguredAlias() {
+ String[] aliases = store.getClientAliases("RSA", null);
+ if (aliases == null || aliases.length == 0) {
+ aliases = store.getClientAliases("EC", null);
+ }
+ return aliases != null && aliases.length > 0 ? aliases[0] : null;
}
@Override
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java
new file mode 100644
index 00000000..93a868c0
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/JMeterSslAliasResolver.java
@@ -0,0 +1,39 @@
+package com.blazemeter.jmeter.http2.core;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import org.apache.jmeter.util.SSLManager;
+import org.apache.jmeter.util.keystore.JmeterKeyStore;
+
+/**
+ * Resolves the client certificate alias on the current JMeter thread, where
+ * {@link JMeterContextService} variables are available.
+ */
+public final class JMeterSslAliasResolver {
+
+ private JMeterSslAliasResolver() {
+ }
+
+ /**
+ * Returns the alias to use for the next HTTPS client authentication, or {@code null} when no
+ * client keystore is configured.
+ */
+ public static String resolveForRequest() {
+ String keyStorePath = System.getProperty(SSLManager.JAVAX_NET_SSL_KEY_STORE);
+ if (keyStorePath == null || keyStorePath.isEmpty()) {
+ return null;
+ }
+ return getKeyStore().getAlias();
+ }
+
+ private static JmeterKeyStore getKeyStore() {
+ try {
+ Method keystoreMethod = SSLManager.class.getDeclaredMethod("getKeyStore");
+ keystoreMethod.setAccessible(true);
+ return (JmeterKeyStore) keystoreMethod.invoke(SSLManager.getInstance());
+ } catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
+ throw new IllegalStateException("Could not access JMeter keystore", e);
+ }
+ }
+
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java b/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java
index 43ead3c7..4d2d72be 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/JettyCacheManager.java
@@ -17,11 +17,12 @@
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicHttpResponse;
import org.apache.jmeter.protocol.http.control.CacheManager;
+import org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl;
import org.apache.jmeter.protocol.http.sampler.HTTPHC4Impl.HttpDelete;
import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult;
+import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase;
import org.apache.jmeter.protocol.http.sampler.HttpWebdav;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
-import org.apache.jmeter.util.JMeterUtils;
import org.eclipse.jetty.client.ContentResponse;
import org.eclipse.jetty.client.Request;
import org.eclipse.jetty.http.HttpFields;
@@ -94,35 +95,29 @@ public boolean inCache(URL url, HttpFields headers) {
}
public HTTPSampleResult buildCachedSampleResult(HTTPSampleResult res) {
- CachedResourceMode cachedResourceMode = CachedResourceMode.valueOf(
- JMeterUtils.getPropDefault("cache_manager.cached_resource_mode",
- CachedResourceMode.RETURN_NO_SAMPLE.toString()));
- switch (cachedResourceMode) {
- case RETURN_NO_SAMPLE:
- return null;
- case RETURN_200_CACHE:
- res.sampleEnd();
- res.setResponseCodeOK();
- res.setResponseMessage(
- JMeterUtils.getPropDefault("RETURN_200_CACHE.message", "(ex cache)"));
- res.setSuccessful(true);
- return res;
- case RETURN_CUSTOM_STATUS:
- res.sampleEnd();
- res.setResponseCode(JMeterUtils.getProperty("RETURN_CUSTOM_STATUS.code"));
- res.setResponseMessage(
- JMeterUtils.getPropDefault("RETURN_CUSTOM_STATUS.message", "(ex cache)"));
- res.setSuccessful(true);
- return res;
- default:
- throw new IllegalStateException("Unknown CACHED_RESOURCE_MODE");
+ // Match HttpClient4: HTTPAbstractImpl snapshots cache_manager.cached_resource_mode once.
+ return CachedResourceResultBridge.forCachedResource(res);
+ }
+
+ private static final class CachedResourceResultBridge extends HTTPHC4Impl {
+ private static final CachedResourceResultBridge INSTANCE =
+ new CachedResourceResultBridge(new BridgeSampler());
+
+ private CachedResourceResultBridge(HTTPSamplerBase sampler) {
+ super(sampler);
+ }
+
+ private static HTTPSampleResult forCachedResource(HTTPSampleResult res) {
+ return INSTANCE.updateSampleResultForResourceInCache(res);
}
}
- private enum CachedResourceMode {
- RETURN_200_CACHE,
- RETURN_NO_SAMPLE,
- RETURN_CUSTOM_STATUS
+ private static final class BridgeSampler extends HTTPSamplerBase {
+ @Override
+ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect,
+ int depth) {
+ throw new UnsupportedOperationException("cache bridge");
+ }
}
public void saveDetails(ContentResponse contentResponse, HTTPSampleResult result) {
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java
new file mode 100644
index 00000000..b936947d
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterCompressionHeadersSupport.java
@@ -0,0 +1,144 @@
+package com.blazemeter.jmeter.http2.core;
+
+import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.Request;
+import org.eclipse.jetty.client.Response;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.http.HttpFields;
+import org.eclipse.jetty.http.HttpHeader;
+
+/**
+ * Preserves wire {@code Content-Encoding}, {@code Content-Length} and {@code Content-MD5}
+ * for compressed responses, matching Apache JMeter {@code HTTPHC4Impl} behaviour after
+ * HttpClient {@code ResponseContentEncoding} decodes the body (Bug 59401).
+ */
+final class JmeterCompressionHeadersSupport {
+
+ static final String REQUEST_ATTR_WIRE_COMPRESSION_HEADERS =
+ "bzm.jmeter.wireCompressionHeaders";
+
+ private static final String[] HEADERS_TO_SAVE = {
+ HTTPConstants.HEADER_CONTENT_LENGTH,
+ HTTPConstants.HEADER_CONTENT_ENCODING,
+ "Content-MD5"
+ };
+
+ private JmeterCompressionHeadersSupport() {
+ }
+
+ static void installCapture(Request request) {
+ if (request == null) {
+ return;
+ }
+ request.onResponseHeader(JmeterCompressionHeadersSupport::onResponseHeader);
+ request.onResponseHeaders(JmeterCompressionHeadersSupport::onResponseHeaders);
+ }
+
+ private static boolean onResponseHeader(Response response, HttpField field) {
+ if (response != null && field != null && HttpHeader.CONTENT_ENCODING.is(field.getName())) {
+ Request request = response.getRequest();
+ if (request != null) {
+ mergeSavedHeader(request, field.getName(), field.getValue());
+ }
+ }
+ return true;
+ }
+
+ private static void onResponseHeaders(Response response) {
+ if (response != null && response.getRequest() != null && response.getHeaders() != null) {
+ captureIfCompressed(response.getRequest(), response.getHeaders());
+ }
+ }
+
+ private static void mergeSavedHeader(Request request, String name, String value) {
+ if (request == null || name == null || value == null) {
+ return;
+ }
+ HttpFields.Mutable saved = getOrCreateSaved(request);
+ saved.put(name, value);
+ request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved);
+ }
+
+ private static HttpFields.Mutable getOrCreateSaved(Request request) {
+ Object existing = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS);
+ if (existing instanceof HttpFields.Mutable) {
+ return (HttpFields.Mutable) existing;
+ }
+ if (existing instanceof HttpFields) {
+ HttpFields.Mutable copy = HttpFields.build((HttpFields) existing);
+ request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, copy);
+ return copy;
+ }
+ return HttpFields.build();
+ }
+
+ static void captureIfCompressed(Request request, HttpFields headers) {
+ if (request == null || headers == null) {
+ return;
+ }
+ String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING);
+ if (contentEncoding == null || contentEncoding.trim().isEmpty()) {
+ return;
+ }
+ HttpFields.Mutable saved = getOrCreateSaved(request);
+ for (String name : HEADERS_TO_SAVE) {
+ String value = headers.get(name);
+ if (value != null) {
+ saved.put(name, value);
+ }
+ }
+ if (saved.size() > 0) {
+ request.attribute(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS, saved);
+ }
+ }
+
+ static HttpFields headersForSampleResult(ContentResponse contentResponse) {
+ if (contentResponse == null || contentResponse.getHeaders() == null) {
+ return contentResponse != null ? contentResponse.getHeaders() : HttpFields.EMPTY;
+ }
+ HttpFields current = contentResponse.getHeaders();
+ Request request = contentResponse.getRequest();
+ if (request == null) {
+ return current;
+ }
+ Object savedAttr = request.getAttributes().get(REQUEST_ATTR_WIRE_COMPRESSION_HEADERS);
+ if (!(savedAttr instanceof HttpFields)) {
+ return current;
+ }
+ HttpFields saved = (HttpFields) savedAttr;
+ if (saved.size() == 0) {
+ return current;
+ }
+ HttpFields.Mutable merged = HttpFields.build(current);
+ // Jetty removes Content-Encoding but often leaves an updated Content-Length (decoded size).
+ // Match HTTPHC4Impl: restore all wire compression headers when encoding was stripped.
+ if (!hasContentEncoding(current) && hasContentEncoding(saved)) {
+ for (String name : HEADERS_TO_SAVE) {
+ String value = saved.get(name);
+ if (value != null) {
+ merged.put(name, value);
+ }
+ }
+ } else {
+ for (String name : HEADERS_TO_SAVE) {
+ if (merged.contains(name)) {
+ continue;
+ }
+ String value = saved.get(name);
+ if (value != null) {
+ merged.put(name, value);
+ }
+ }
+ }
+ return merged;
+ }
+
+ private static boolean hasContentEncoding(HttpFields headers) {
+ if (headers == null) {
+ return false;
+ }
+ String contentEncoding = headers.get(HttpHeader.CONTENT_ENCODING);
+ return contentEncoding != null && !contentEncoding.trim().isEmpty();
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java
new file mode 100644
index 00000000..a6a663c3
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterHttpClientExceptionMapper.java
@@ -0,0 +1,104 @@
+package com.blazemeter.jmeter.http2.core;
+
+import java.net.ConnectException;
+import java.net.InetAddress;
+import java.net.URL;
+import java.net.UnknownHostException;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+import org.apache.http.HttpHost;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.conn.HttpHostConnectException;
+
+/**
+ * Maps Jetty client failures to the exception types JMeter {@code HTTPHC4Impl} reports in
+ * {@code HTTPSampleResult} for semantic parity.
+ */
+public final class JmeterHttpClientExceptionMapper {
+
+ private JmeterHttpClientExceptionMapper() {
+ }
+
+ public static Throwable forSampleResult(Throwable thrown, boolean autoRedirects) {
+ return forSampleResult(thrown, autoRedirects, null);
+ }
+
+ public static Throwable forSampleResult(Throwable thrown, boolean autoRedirects, URL url) {
+ if (thrown == null) {
+ return null;
+ }
+ Throwable root = unwrap(thrown);
+ if (autoRedirects && isAutoRedirectProtocolFailure(root)) {
+ return new ClientProtocolException();
+ }
+ Throwable connectMapped = mapConnectFailure(root, url);
+ if (connectMapped != null) {
+ return connectMapped;
+ }
+ return thrown;
+ }
+
+ private static Throwable unwrap(Throwable throwable) {
+ Throwable current = throwable;
+ while (current instanceof ExecutionException && current.getCause() != null) {
+ current = current.getCause();
+ }
+ return current;
+ }
+
+ private static boolean isAutoRedirectProtocolFailure(Throwable throwable) {
+ if (throwable == null) {
+ return false;
+ }
+ if (!"HttpResponseException".equals(throwable.getClass().getSimpleName())) {
+ return false;
+ }
+ String message = throwable.getMessage();
+ return message != null && message.contains("Max redirects exceeded");
+ }
+
+ private static Throwable mapConnectFailure(Throwable root, URL url) {
+ if (root instanceof HttpHostConnectException) {
+ return root;
+ }
+ if (root instanceof ConnectException) {
+ HttpHost host = toHttpHost(url);
+ ConnectException cause = normalizeConnectCause((ConnectException) root);
+ if (host != null) {
+ try {
+ return new HttpHostConnectException(cause, host,
+ InetAddress.getAllByName(host.getHostName()));
+ } catch (UnknownHostException ignored) {
+ // Fall back to host-only message formatting.
+ }
+ }
+ return new HttpHostConnectException(cause, host);
+ }
+ return null;
+ }
+
+ private static ConnectException normalizeConnectCause(ConnectException root) {
+ String message = root.getMessage();
+ if (message != null && message.toLowerCase(Locale.ROOT).contains("getsockopt")) {
+ ConnectException normalized = new ConnectException("Connection refused: connect");
+ normalized.initCause(root);
+ return normalized;
+ }
+ return root;
+ }
+
+ private static HttpHost toHttpHost(URL url) {
+ if (url == null || url.getHost() == null || url.getHost().isEmpty()) {
+ return null;
+ }
+ int port = url.getPort();
+ if (port < 0) {
+ port = url.getDefaultPort();
+ }
+ String scheme = url.getProtocol();
+ if (scheme == null || scheme.isEmpty()) {
+ return new HttpHost(url.getHost(), port);
+ }
+ return new HttpHost(url.getHost(), port, scheme);
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java
new file mode 100644
index 00000000..fd3b4f7c
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/JmeterRequestHeadersSupport.java
@@ -0,0 +1,142 @@
+package com.blazemeter.jmeter.http2.core;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.apache.jmeter.util.JMeterUtils;
+import org.eclipse.jetty.client.Request;
+import org.eclipse.jetty.http.HttpFields;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpVersion;
+
+/**
+ * Preserves JMeter-visible request headers in {@code HTTPSampleResult#getRequestHeaders()}
+ * when Jetty omits or strips them on the wire.
+ *
+ *
Counterpart of {@link JmeterCompressionHeadersSupport} for outbound request headers.
+ */
+final class JmeterRequestHeadersSupport {
+
+ static final String ATTR_USE_KEEPALIVE = "bzm.useKeepAlive";
+
+ static final String DEFAULT_USER_AGENT_PROPERTY =
+ "httpclient4.default_user_agent_disabled";
+
+ private static final String DEFAULT_USER_AGENT = buildDefaultUserAgent();
+
+ private JmeterRequestHeadersSupport() {
+ }
+
+ /**
+ * Applies sampler-driven request headers before send, matching {@code HTTPHC4Impl#setupRequest}.
+ */
+ static void prepareFromSampler(Request request, boolean useKeepAlive) {
+ if (request == null) {
+ return;
+ }
+ request.attribute(ATTR_USE_KEEPALIVE, useKeepAlive);
+ applyConnectionHeader(request, useKeepAlive);
+ applyDefaultUserAgentHeader(request);
+ }
+
+ /**
+ * Copies sampler header settings to a cloned request (for example HTTP/1.1 fallback).
+ */
+ static void copySamplerHeaderState(Request from, Request to) {
+ if (from == null || to == null) {
+ return;
+ }
+ Object useKeepAlive = from.getAttributes().get(ATTR_USE_KEEPALIVE);
+ if (useKeepAlive != null) {
+ to.attribute(ATTR_USE_KEEPALIVE, useKeepAlive);
+ }
+ }
+
+ /**
+ * Request headers to record in the sample after Jetty may have adjusted the live fields.
+ */
+ static HttpFields headersForSampleResult(Request request) {
+ if (request == null) {
+ return HttpFields.EMPTY;
+ }
+ HttpFields.Mutable merged = HttpFields.build(request.getHeaders());
+ restoreConnectionHeaderForSample(request, merged);
+ return merged;
+ }
+
+ private static void applyConnectionHeader(Request request, boolean useKeepAlive) {
+ if (!shouldSendConnectionHeader(request)) {
+ return;
+ }
+ HttpFields.Mutable mutableHeaders = mutableHeaders(request);
+ if (mutableHeaders == null || mutableHeaders.contains(HttpHeader.CONNECTION)) {
+ return;
+ }
+ if (useKeepAlive) {
+ mutableHeaders.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE);
+ } else {
+ mutableHeaders.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE);
+ }
+ }
+
+
+ /**
+ * Matches {@code HTTPHC4Impl}: when no {@code User-Agent} is configured, send a plugin default
+ * unless {@code httpclient4.default_user_agent_disabled=true}.
+ */
+ private static void applyDefaultUserAgentHeader(Request request) {
+ if (isDefaultUserAgentDisabled()) {
+ return;
+ }
+ HttpFields.Mutable mutableHeaders = mutableHeaders(request);
+ if (mutableHeaders == null || mutableHeaders.contains(HttpHeader.USER_AGENT)) {
+ return;
+ }
+ mutableHeaders.put(HttpHeader.USER_AGENT, DEFAULT_USER_AGENT);
+ }
+
+ static boolean isDefaultUserAgentDisabled() {
+ return JMeterUtils.getPropDefault(DEFAULT_USER_AGENT_PROPERTY, false);
+ }
+
+ static String defaultUserAgent() {
+ return DEFAULT_USER_AGENT;
+ }
+
+ private static String buildDefaultUserAgent() {
+ Package pkg = JmeterRequestHeadersSupport.class.getPackage();
+ String version = pkg != null ? pkg.getImplementationVersion() : null;
+ if (StringUtils.isBlank(version)) {
+ return "BlazeMeter HTTP";
+ }
+ return "BlazeMeter HTTP/" + version;
+ }
+
+ private static void restoreConnectionHeaderForSample(
+ Request request, HttpFields.Mutable headers) {
+ Object useKeepAlive = request.getAttributes().get(ATTR_USE_KEEPALIVE);
+ if (useKeepAlive == null || !shouldSendConnectionHeader(request)) {
+ return;
+ }
+ if (Boolean.TRUE.equals(useKeepAlive)) {
+ headers.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.KEEP_ALIVE);
+ } else {
+ headers.put(HTTPConstants.HEADER_CONNECTION, HTTPConstants.CONNECTION_CLOSE);
+ }
+ }
+
+ private static boolean shouldSendConnectionHeader(Request request) {
+ if (request != null && request.getVersion() == HttpVersion.HTTP_2) {
+ return false;
+ }
+ HttpFields headers = request.getHeaders();
+ return headers == null || !headers.contains(HttpHeader.CONNECTION);
+ }
+
+ private static HttpFields.Mutable mutableHeaders(Request request) {
+ HttpFields headers = request.getHeaders();
+ if (headers instanceof HttpFields.Mutable) {
+ return (HttpFields.Mutable) headers;
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java
new file mode 100644
index 00000000..e8bd01ec
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasContext.java
@@ -0,0 +1,48 @@
+package com.blazemeter.jmeter.http2.core;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.net.ssl.SSLEngine;
+
+/**
+ * Holds the client certificate alias resolved on the JMeter thread and bound to the
+ * {@link SSLEngine} used during the async Jetty TLS handshake.
+ */
+final class SslClientCertAliasContext {
+
+ static final String CONNECTION_CONTEXT_KEY = "bzm.jmeter.ssl.clientCertAlias";
+ static final String REQUEST_ATTRIBUTE = CONNECTION_CONTEXT_KEY;
+
+ private static final Map ENGINE_ALIASES = new ConcurrentHashMap<>();
+
+ private SslClientCertAliasContext() {
+ }
+
+ static void bindEngine(SSLEngine engine, String alias) {
+ if (engine != null && alias != null && !alias.isEmpty()) {
+ ENGINE_ALIASES.put(engine, alias);
+ }
+ }
+
+ static String resolveFromEngine(SSLEngine engine) {
+ if (engine == null) {
+ return null;
+ }
+ return ENGINE_ALIASES.get(engine);
+ }
+
+ static void clearEngine(SSLEngine engine) {
+ if (engine != null) {
+ ENGINE_ALIASES.remove(engine);
+ }
+ }
+
+ static String readAlias(Map context) {
+ if (context == null) {
+ return null;
+ }
+ Object alias = context.get(CONNECTION_CONTEXT_KEY);
+ return alias instanceof String ? (String) alias : null;
+ }
+
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java
new file mode 100644
index 00000000..462ab2f1
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasSupport.java
@@ -0,0 +1,35 @@
+package com.blazemeter.jmeter.http2.core;
+
+import org.eclipse.jetty.client.Request;
+
+/**
+ * Binds a client certificate alias resolved on the JMeter thread to a Jetty {@link Request}.
+ */
+public final class SslClientCertAliasSupport {
+
+ private SslClientCertAliasSupport() {
+ }
+
+ public static void bindToRequest(Request request, String alias) {
+ if (request == null || alias == null || alias.isEmpty()) {
+ return;
+ }
+ request.attribute(SslClientCertAliasContext.REQUEST_ATTRIBUTE, alias);
+ request.tag(new SslClientCertAliasTag(alias));
+ }
+
+ public static void copyFromRequest(Request source, Request target) {
+ if (source == null || target == null) {
+ return;
+ }
+ Object tag = source.getTag();
+ if (tag != null) {
+ target.tag(tag);
+ }
+ Object alias = source.getAttributes().get(SslClientCertAliasContext.REQUEST_ATTRIBUTE);
+ if (alias != null) {
+ target.attribute(SslClientCertAliasContext.REQUEST_ATTRIBUTE, alias);
+ }
+ }
+
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java
new file mode 100644
index 00000000..ac93f721
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslClientCertAliasTag.java
@@ -0,0 +1,58 @@
+package com.blazemeter.jmeter.http2.core;
+
+import java.io.IOException;
+import java.util.Objects;
+import org.eclipse.jetty.io.ClientConnectionFactory;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.EndPoint;
+
+/**
+ * Request tag that routes HTTPS connections through a per-alias destination and injects the
+ * resolved client certificate alias into the Jetty connection context for TLS handshakes.
+ */
+final class SslClientCertAliasTag implements ClientConnectionFactory.Decorator {
+
+ private final String alias;
+
+ SslClientCertAliasTag(String alias) {
+ this.alias = Objects.requireNonNull(alias, "alias");
+ }
+
+ String getAlias() {
+ return alias;
+ }
+
+ @Override
+ public ClientConnectionFactory apply(ClientConnectionFactory factory) {
+ return new ClientConnectionFactory() {
+ @Override
+ public Connection newConnection(EndPoint endPoint, java.util.Map context)
+ throws IOException {
+ context.put(SslClientCertAliasContext.CONNECTION_CONTEXT_KEY, alias);
+ return factory.newConnection(endPoint, context);
+ }
+ };
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (this == other) {
+ return true;
+ }
+ if (!(other instanceof SslClientCertAliasTag)) {
+ return false;
+ }
+ return alias.equals(((SslClientCertAliasTag) other).alias);
+ }
+
+ @Override
+ public int hashCode() {
+ return alias.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "SslClientCertAliasTag[" + alias + "]";
+ }
+
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java b/src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java
new file mode 100644
index 00000000..f298b449
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/SslStorePathResolver.java
@@ -0,0 +1,77 @@
+package com.blazemeter.jmeter.http2.core;
+
+import java.io.File;
+import java.util.Locale;
+
+/**
+ * Converts {@code javax.net.ssl.*} store paths to Jetty {@code file:} URIs.
+ */
+public final class SslStorePathResolver {
+
+ /** JSSE / JMeter sentinel for non-file keystores (e.g. PKCS#11). */
+ public static final String NON_FILE_KEYSTORE_LOCATION = "NONE";
+
+ private static final String KEY_STORE_TYPE_PROPERTY = "javax.net.ssl.keyStoreType";
+ private static final String TRUST_STORE_TYPE_PROPERTY = "javax.net.ssl.trustStoreType";
+ private static final String PKCS12 = "pkcs12";
+ private static final String JKS = "JKS";
+
+ private SslStorePathResolver() {
+ // Utility class.
+ }
+
+ /**
+ * Whether the property points at a filesystem keystore (not PKCS#11 {@code NONE}).
+ */
+ public static boolean isFileBasedStoreLocation(final String storePath) {
+ return storePath != null
+ && !storePath.isEmpty()
+ && !NON_FILE_KEYSTORE_LOCATION.equalsIgnoreCase(storePath);
+ }
+
+ /**
+ * Same as JMeter ({@code new File(path)}), then {@link File#toURI()} for Jetty.
+ *
+ * @param storePath filesystem path from {@code javax.net.ssl.keyStore} or trustStore
+ * @return Jetty-compatible {@code file:} URI
+ */
+ public static String toJettyFileUri(final String storePath) {
+ return new File(storePath).getAbsoluteFile().toURI().toString();
+ }
+
+ /**
+ * Resolves the Jetty key store type from {@link #KEY_STORE_TYPE_PROPERTY}, matching
+ * {@link org.apache.jmeter.util.SSLManager} when unset (.p12 → PKCS12, else JKS).
+ *
+ * @param keyStorePath filesystem path from {@code javax.net.ssl.keyStore}
+ * @return type string for {@link org.eclipse.jetty.util.ssl.SslContextFactory#setKeyStoreType}
+ */
+ public static String resolveKeyStoreType(final String keyStorePath) {
+ return resolveStoreType(KEY_STORE_TYPE_PROPERTY, keyStorePath, false);
+ }
+
+ /**
+ * Resolves the Jetty trust store type from {@link #TRUST_STORE_TYPE_PROPERTY}, or by file
+ * extension when unset (.p12 / .pfx → PKCS12, else JKS).
+ *
+ * @param trustStorePath filesystem path from {@code javax.net.ssl.trustStore}
+ * @return type string for {@link org.eclipse.jetty.util.ssl.SslContextFactory#setTrustStoreType}
+ */
+ public static String resolveTrustStoreType(final String trustStorePath) {
+ return resolveStoreType(TRUST_STORE_TYPE_PROPERTY, trustStorePath, true);
+ }
+
+ private static String resolveStoreType(final String typeProperty, final String storePath,
+ final boolean inferPfxAsPkcs12) {
+ String explicit = System.getProperty(typeProperty);
+ if (explicit != null && !explicit.isEmpty()) {
+ return explicit;
+ }
+ String lower = storePath.toLowerCase(Locale.ENGLISH);
+ if (lower.endsWith(".p12") || (inferPfxAsPkcs12 && lower.endsWith(".pfx"))) {
+ return PKCS12;
+ }
+ return JKS;
+ }
+
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java
new file mode 100644
index 00000000..027153c3
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/CustomWwwAuthenticationProtocolHandler.java
@@ -0,0 +1,379 @@
+package com.blazemeter.jmeter.http2.core.jetty;
+
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jetty.client.Authentication;
+import org.eclipse.jetty.client.Authentication.HeaderInfo;
+import org.eclipse.jetty.client.AuthenticationProtocolHandler;
+import org.eclipse.jetty.client.Connection;
+import org.eclipse.jetty.client.ContentResponse;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.HttpRequestException;
+import org.eclipse.jetty.client.ProtocolHandler;
+import org.eclipse.jetty.client.Request;
+import org.eclipse.jetty.client.Response;
+import org.eclipse.jetty.client.Result;
+import org.eclipse.jetty.client.RetainingResponseListener;
+import org.eclipse.jetty.client.WWWAuthenticationProtocolHandler;
+import org.eclipse.jetty.client.internal.HttpContentResponse;
+import org.eclipse.jetty.client.transport.HttpConversation;
+import org.eclipse.jetty.client.transport.HttpRequest;
+import org.eclipse.jetty.client.transport.ResponseListeners;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.http.QuotedCSV;
+import org.eclipse.jetty.util.NanoTime;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Custom replacement for Jetty's {@link WWWAuthenticationProtocolHandler}.
+ *
+ *
Why Jetty's default is too strict for load-testing clients
+ *
+ *
Jetty's handler assumes that every HTTP 401 is an HTTP Authentication challenge
+ * (Basic, Digest, etc.) in the sense of RFC 7235 / RFC 9110. Its {@code accept()} matches on
+ * status 401 alone (before response headers are available). Later, if {@code WWW-Authenticate} is
+ * missing or unparseable, it fails the exchange with {@code HttpResponseException}:
+ * {@code "HTTP protocol violation: Authentication challenge without WWW-Authenticate header"}.
+ *
+ *
That reading of 401 is too narrow. A service is not required to use the
+ * {@code WWW-Authenticate} / {@code Authorization} HTTP Auth framework. It may return 401 simply
+ * to mean "not authorized" while using another mechanism entirely, for example:
+ *
+ *
Bearer / JWT (or other) tokens in {@code Authorization} or a custom header
+ *
API keys, cookies / session, OAuth / OIDC flows
+ *
Application-level errors with a JSON/XML body and no challenge header
+ *
+ * In those cases there is no HTTP Auth challenge to advertise, so omitting
+ * {@code WWW-Authenticate} is normal. Treating it as a protocol violation turns a usable 401
+ * sample into a Non-HTTP error in the sampler.
+ *
+ *
Parity with JMeter HTTP (HttpClient4)
+ *
+ *
Apache JMeter's HttpClient4 path does not fail the exchange when 401 lacks
+ * {@code WWW-Authenticate}: the sampler receives a normal 401 result. This handler restores that
+ * behavior for the BlazeMeter HTTP (Jetty) client.
+ *
+ *
What this changes vs Jetty's original
+ *
+ *
+ *
401 without a usable {@code WWW-Authenticate} challenge: forward the response to
+ * the application (same outcome as HttpClient4).
+ *
401 with a valid challenge and matching credentials in the
+ * {@link org.eclipse.jetty.client.AuthenticationStore}: keep Jetty-style challenge-response
+ * Basic/Digest retry.
+ *
+ *
+ *
Why a full custom handler (not a small subclass override)
+ *
+ *
The protocol-violation branch lives in Jetty's private inner class
+ * {@code AuthenticationProtocolHandler.AuthenticationListener#onComplete}. There is no protected
+ * hook to override only the "401 without {@code WWW-Authenticate}" path, and helpers such as
+ * {@code forwardSuccessComplete} are private to that listener. Extending
+ * {@link WWWAuthenticationProtocolHandler} therefore cannot change that behavior without
+ * reimplementing {@link #getResponseListener()}. This class replaces the stock handler and keeps
+ * challenge-response Basic/Digest when a real challenge is present.
+ *
+ *
Install via {@link #install(HttpClient)} after {@link HttpClient#start()} so it replaces the
+ * handler Jetty registers in {@code doStart()}.
+ */
+public final class CustomWwwAuthenticationProtocolHandler implements ProtocolHandler {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(CustomWwwAuthenticationProtocolHandler.class);
+ private static final String ATTRIBUTE =
+ CustomWwwAuthenticationProtocolHandler.class.getName() + ".attribute";
+ private static final Pattern CHALLENGE_PATTERN = Pattern.compile(
+ "(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)"
+ + "|(?:(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s+)?"
+ + "(?:(?[a-zA-Z0-9\\-._~+/]+=*)"
+ + "|(?[!#$%&'*+\\-.^_`|~0-9A-Za-z]+)\\s*=\\s*(?:(?.*)))");
+
+ private final HttpClient client;
+ private final int maxContentLength;
+
+ public CustomWwwAuthenticationProtocolHandler(HttpClient client) {
+ this(client, AuthenticationProtocolHandler.DEFAULT_MAX_CONTENT_LENGTH);
+ }
+
+ public CustomWwwAuthenticationProtocolHandler(HttpClient client, int maxContentLength) {
+ this.client = client;
+ this.maxContentLength = maxContentLength;
+ }
+
+ /**
+ * Removes Jetty's default www-authenticate handler and installs this one. Must be called after
+ * {@link HttpClient#start()}.
+ */
+ public static void install(HttpClient httpClient) {
+ if (httpClient == null) {
+ return;
+ }
+ httpClient.getProtocolHandlers().remove(WWWAuthenticationProtocolHandler.NAME);
+ httpClient.getProtocolHandlers()
+ .put(new CustomWwwAuthenticationProtocolHandler(httpClient));
+ }
+
+ @Override
+ public String getName() {
+ return WWWAuthenticationProtocolHandler.NAME;
+ }
+
+ @Override
+ public boolean accept(Request request, Response response) {
+ return response.getStatus() == HttpStatus.UNAUTHORIZED_401;
+ }
+
+ @Override
+ public Response.Listener getResponseListener() {
+ return new AuthenticationListener();
+ }
+
+ private List getHeaderInfo(String header) {
+ List headerInfos = new ArrayList<>();
+ for (String value : new QuotedCSV(true, header)) {
+ Matcher m = CHALLENGE_PATTERN.matcher(value);
+ if (!m.matches()) {
+ continue;
+ }
+ if (m.group("schemeOnly") != null) {
+ headerInfos.add(new HeaderInfo(HttpHeader.AUTHORIZATION, m.group(1), new HashMap<>()));
+ continue;
+ }
+ if (m.group("scheme") != null) {
+ headerInfos.add(
+ new HeaderInfo(HttpHeader.AUTHORIZATION, m.group("scheme"), new HashMap<>()));
+ }
+ if (headerInfos.isEmpty()) {
+ throw new IllegalArgumentException("Parameters without auth-scheme");
+ }
+ Map authParams = headerInfos.get(headerInfos.size() - 1).getParameters();
+ if (m.group("paramName") != null) {
+ String paramVal = QuotedCSV.unquote(m.group("paramValue"));
+ authParams.put(m.group("paramName"), paramVal);
+ } else if (m.group("token68") != null) {
+ if (!authParams.isEmpty()) {
+ throw new IllegalArgumentException("token68 after auth-params");
+ }
+ authParams.put("base64", m.group("token68"));
+ }
+ }
+ return headerInfos;
+ }
+
+ private class AuthenticationListener extends RetainingResponseListener {
+ private AuthenticationListener() {
+ super(maxContentLength);
+ }
+
+ @Override
+ public void onSuccess(Response response) {
+ super.onSuccess(response);
+ Request request = response.getRequest();
+ if (request.getBody() != null) {
+ request.abort(new HttpRequestException(
+ "Aborting request after receiving a %d response".formatted(response.getStatus()),
+ request));
+ }
+ }
+
+ @Override
+ public void onComplete(Result result) {
+ HttpRequest request = (HttpRequest) result.getRequest();
+ ContentResponse response =
+ new HttpContentResponse(result.getResponse(), getContent(), getMediaType(),
+ getEncoding());
+
+ HttpConversation conversation = request.getConversation();
+ if (conversation.getAttribute(ATTRIBUTE) != null) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Bad credentials for {}", request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ List headerInfos = parseAuthenticateHeader(response);
+ if (headerInfos.isEmpty()) {
+ // JMeter HttpClient4 parity: tolerate 401 without WWW-Authenticate.
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("401 without WWW-Authenticate for {}; returning response to application",
+ request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ Authentication authentication = null;
+ HeaderInfo headerInfo = null;
+ URI authURI = resolveURI(request, request.getURI());
+ for (HeaderInfo element : headerInfos) {
+ authentication = client.getAuthenticationStore()
+ .findAuthentication(element.getType(), authURI, element.getRealm());
+ if (authentication != null) {
+ headerInfo = element;
+ break;
+ }
+ }
+ if (authentication == null) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("No authentication available for {}", request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ Request.Content content = request.getBody();
+ if (content != null && !content.rewind()) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Request content not reproducible for {}", request);
+ }
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ try {
+ Authentication.Result authnResult =
+ authentication.authenticate(request, response, headerInfo, conversation);
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Authentication result {}", authnResult);
+ }
+ if (authnResult == null) {
+ forwardSuccessComplete(request, response);
+ return;
+ }
+
+ conversation.setAttribute(ATTRIBUTE, true);
+
+ Request newRequest = copyRequest(request, request.getURI());
+ if (HttpMethod.CONNECT.is(newRequest.getMethod())) {
+ newRequest.path(request.getPath());
+ }
+
+ long timeoutNanoTime = request.getTimeoutNanoTime();
+ if (timeoutNanoTime < Long.MAX_VALUE) {
+ long newTimeout = NanoTime.until(timeoutNanoTime);
+ if (newTimeout > 0) {
+ newRequest.timeout(newTimeout, TimeUnit.NANOSECONDS);
+ } else {
+ TimeoutException failure = new TimeoutException(
+ "Total timeout " + request.getConversation().getTimeout() + " ms elapsed");
+ forwardFailureComplete(request, failure, response, failure);
+ return;
+ }
+ }
+
+ authnResult.apply(newRequest);
+ copyIfAbsent(request, newRequest, HttpHeader.AUTHORIZATION);
+ copyIfAbsent(request, newRequest, HttpHeader.PROXY_AUTHORIZATION);
+
+ AfterAuthenticationListener listener = new AfterAuthenticationListener(authnResult);
+ Connection connection =
+ (Connection) request.getAttributes().get(Connection.class.getName());
+ if (connection != null) {
+ connection.send(newRequest, listener);
+ } else {
+ newRequest.send(listener);
+ }
+ } catch (Throwable ex) {
+ if (LOG.isDebugEnabled()) {
+ LOG.atDebug().setCause(ex).log("Authentication failed");
+ }
+ forwardFailureComplete(request, null, response, ex);
+ }
+ }
+
+ private URI resolveURI(HttpRequest request, URI uri) {
+ if (uri != null) {
+ return uri;
+ }
+ String target = request.getScheme() + "://" + request.getHost();
+ int port = request.getPort();
+ if (port > 0) {
+ target += ":" + port;
+ }
+ return URI.create(target);
+ }
+
+ private void copyIfAbsent(HttpRequest oldRequest, Request newRequest, HttpHeader header) {
+ HttpField field = oldRequest.getHeaders().getField(header);
+ if (field != null && !newRequest.getHeaders().contains(header)) {
+ newRequest.headers(headers -> headers.put(field));
+ }
+ }
+
+ private void forwardSuccessComplete(HttpRequest request, Response response) {
+ HttpConversation conversation = request.getConversation();
+ conversation.updateResponseListeners(null);
+ ResponseListeners responseListeners = conversation.getResponseListeners();
+ responseListeners.emitSuccessComplete(new Result(request, response));
+ }
+
+ private void forwardFailureComplete(HttpRequest request, Throwable requestFailure,
+ Response response, Throwable responseFailure) {
+ HttpConversation conversation = request.getConversation();
+ conversation.updateResponseListeners(null);
+ ResponseListeners responseListeners = conversation.getResponseListeners();
+ if (responseFailure == null) {
+ responseListeners.emitSuccess(response);
+ } else {
+ responseListeners.emitFailure(response, responseFailure);
+ }
+ responseListeners.notifyComplete(
+ new Result(request, requestFailure, response, responseFailure));
+ }
+
+ private List parseAuthenticateHeader(Response response) {
+ List result = new ArrayList<>();
+ List values = response.getHeaders().getValuesList(HttpHeader.WWW_AUTHENTICATE);
+ for (String value : values) {
+ try {
+ result.addAll(getHeaderInfo(value));
+ } catch (IllegalArgumentException e) {
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("Failed to parse authentication header", e);
+ }
+ }
+ }
+ return result;
+ }
+ }
+
+ private Request copyRequest(Request request, URI uri) {
+ try {
+ Method copyRequest =
+ HttpClient.class.getDeclaredMethod("copyRequest", Request.class, URI.class);
+ copyRequest.setAccessible(true);
+ return (Request) copyRequest.invoke(client, request, uri);
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unable to copy Jetty request for authentication retry", e);
+ }
+ }
+
+ private class AfterAuthenticationListener implements Response.Listener {
+ private final Authentication.Result authenticationResult;
+
+ private AfterAuthenticationListener(Authentication.Result authenticationResult) {
+ this.authenticationResult = authenticationResult;
+ }
+
+ @Override
+ public void onSuccess(Response response) {
+ int status = response.getStatus();
+ if (HttpStatus.isSuccess(status) || HttpStatus.isRedirectionWithLocation(status)) {
+ client.getAuthenticationStore().addAuthenticationResult(authenticationResult);
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java
index b642ade8..3a7c0b11 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java
@@ -10,7 +10,7 @@
import org.eclipse.jetty.client.transport.HttpClientConnectionFactory;
import org.eclipse.jetty.client.transport.HttpDestination;
import org.eclipse.jetty.http2.client.HTTP2Client;
-import org.eclipse.jetty.http2.client.HTTP2ClientConnectionFactory;
+import org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer;
import org.eclipse.jetty.http2.client.transport.internal.HttpConnectionOverHTTP2;
import org.eclipse.jetty.io.ClientConnectionFactory;
import org.eclipse.jetty.io.EndPoint;
@@ -22,7 +22,7 @@
*/
public class CustomClientConnectionFactoryOverHTTP2 extends ContainerLifeCycle
implements ClientConnectionFactory, HttpClient.Aware {
- private final ClientConnectionFactory factory = new HTTP2ClientConnectionFactory();
+ private final ClientConnectionFactory factory = new CustomHTTP2ClientConnectionFactory();
private final HTTP2Client http2Client;
public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) {
@@ -32,15 +32,7 @@ public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) {
@Override
public void setHttpClient(HttpClient httpClient) {
- try {
- java.lang.reflect.Method configureMethod = org.eclipse.jetty.http2.client.transport
- .HttpClientTransportOverHTTP2.class.getDeclaredMethod(
- "configure", HttpClient.class, HTTP2Client.class);
- configureMethod.setAccessible(true);
- configureMethod.invoke(null, httpClient, http2Client);
- } catch (ReflectiveOperationException e) {
- throw new IllegalStateException("Unable to configure HTTP/2 transport", e);
- }
+ CustomHttp2ClientConfigurer.configure(httpClient, http2Client);
}
@Override
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java
new file mode 100644
index 00000000..d8245afd
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java
@@ -0,0 +1,164 @@
+package com.blazemeter.jmeter.http2.core.jetty.custom.http2;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jetty.http2.FlowControlStrategy;
+import org.eclipse.jetty.http2.HTTP2Connection;
+import org.eclipse.jetty.http2.HTTP2Session;
+import org.eclipse.jetty.http2.api.Session;
+import org.eclipse.jetty.http2.client.CustomHttp2SessionContainerAccessor;
+import org.eclipse.jetty.http2.client.HTTP2Client;
+import org.eclipse.jetty.http2.client.internal.HTTP2ClientSession;
+import org.eclipse.jetty.http2.frames.Frame;
+import org.eclipse.jetty.http2.frames.PrefaceFrame;
+import org.eclipse.jetty.http2.frames.SettingsFrame;
+import org.eclipse.jetty.http2.frames.WindowUpdateFrame;
+import org.eclipse.jetty.http2.generator.Generator;
+import org.eclipse.jetty.http2.hpack.HpackContext;
+import org.eclipse.jetty.io.ByteBufferPool;
+import org.eclipse.jetty.io.ClientConnectionFactory;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.EndPoint;
+import org.eclipse.jetty.util.Callback;
+import org.eclipse.jetty.util.Promise;
+
+/**
+ * Jetty 12.1.11 {@code HTTP2ClientConnectionFactory} variant that installs
+ * {@link CustomParser} and {@link org.eclipse.jetty.http2.hpack.CustomHpackDecoder}
+ * for Firefox-inspired soft header handling.
+ */
+public class CustomHTTP2ClientConnectionFactory implements ClientConnectionFactory {
+
+ @Override
+ public Connection newConnection(EndPoint endPoint, Map context) {
+ HTTP2Client client = (HTTP2Client) context.get(HTTP2Client.CONTEXT_KEY);
+ ByteBufferPool bufferPool = client.getByteBufferPool();
+ Session.Listener listener =
+ (Session.Listener) context.get(HTTP2Client.SESSION_LISTENER_CONTEXT_KEY);
+ @SuppressWarnings("unchecked")
+ Promise sessionPromise =
+ (Promise) context.get(HTTP2Client.SESSION_PROMISE_CONTEXT_KEY);
+
+ Generator generator = new Generator(bufferPool, client.isUseOutputDirectByteBuffers(),
+ client.getMaxHeaderBlockFragment());
+ generator.getHpackEncoder().setMaxHeaderListSize(client.getMaxRequestHeadersSize());
+
+ FlowControlStrategy flowControl = client.getFlowControlStrategyFactory()
+ .newFlowControlStrategy();
+
+ CustomParser parser = new CustomParser(bufferPool, client.getMaxResponseHeadersSize());
+ parser.setMaxSettingsKeys(client.getMaxSettingsKeys());
+
+ HTTP2ClientSession session = new HTTP2ClientSession(client.getScheduler(), endPoint, parser,
+ generator, listener, flowControl);
+ session.setMaxLocalStreams(client.getMaxLocalStreams());
+ session.setMaxRemoteStreams(client.getMaxConcurrentPushedStreams());
+ session.setMaxEncoderTableCapacity(client.getMaxEncoderTableCapacity());
+ long streamIdleTimeout = client.getStreamIdleTimeout();
+ if (streamIdleTimeout > 0) {
+ session.setStreamIdleTimeout(streamIdleTimeout);
+ }
+
+ HTTP2ClientConnection connection =
+ new HTTP2ClientConnection(client, endPoint, session, sessionPromise, listener);
+ context.put(HTTP2Connection.class.getName(), connection);
+ connection.addEventListener(CustomHttp2SessionContainerAccessor.getSessionContainer(client));
+ client.getEventListeners().forEach(session::addEventListener);
+ parser.init(connection);
+
+ return customize(connection, context);
+ }
+
+ private static class HTTP2ClientConnection extends HTTP2Connection implements Callback {
+ private final HTTP2Client client;
+ private final Promise promise;
+ private final Session.Listener listener;
+
+ private HTTP2ClientConnection(HTTP2Client client, EndPoint endpoint,
+ HTTP2ClientSession session, Promise sessionPromise,
+ Session.Listener listener) {
+ super(client.getByteBufferPool(), client.getExecutor(), endpoint, session,
+ client.getInputBufferSize(), -1);
+ this.client = client;
+ this.promise = sessionPromise;
+ this.listener = listener;
+ setUseInputDirectByteBuffers(client.isUseInputDirectByteBuffers());
+ setUseOutputDirectByteBuffers(client.isUseOutputDirectByteBuffers());
+ }
+
+ @Override
+ public void onOpen() {
+ HTTP2Session session = getSession();
+ session.notifyLifeCycleOpen();
+
+ Map settings = listener.onPreface(session);
+ settings = settings == null ? new HashMap<>() : new HashMap<>(settings);
+
+ settings.compute(SettingsFrame.HEADER_TABLE_SIZE, (k, v) -> {
+ if (v == null) {
+ v = client.getMaxDecoderTableCapacity();
+ if (v == HpackContext.DEFAULT_MAX_TABLE_CAPACITY) {
+ v = null;
+ }
+ }
+ return v;
+ });
+ settings.computeIfAbsent(SettingsFrame.MAX_CONCURRENT_STREAMS,
+ k -> client.getMaxConcurrentPushedStreams());
+ settings.compute(SettingsFrame.INITIAL_WINDOW_SIZE, (k, v) -> {
+ if (v == null) {
+ v = client.getInitialStreamRecvWindow();
+ if (v == FlowControlStrategy.DEFAULT_WINDOW_SIZE) {
+ v = null;
+ }
+ }
+ return v;
+ });
+ settings.compute(SettingsFrame.MAX_FRAME_SIZE, (k, v) -> {
+ if (v == null) {
+ v = client.getMaxFrameSize();
+ if (v == Frame.DEFAULT_MAX_SIZE) {
+ v = null;
+ }
+ }
+ return v;
+ });
+ settings.compute(SettingsFrame.MAX_HEADER_LIST_SIZE, (k, v) -> {
+ if (v == null) {
+ v = client.getMaxResponseHeadersSize();
+ if (v <= 0) {
+ v = null;
+ }
+ }
+ return v;
+ });
+
+ PrefaceFrame prefaceFrame = new PrefaceFrame();
+ SettingsFrame settingsFrame = new SettingsFrame(settings, false);
+
+ int windowDelta =
+ client.getInitialSessionRecvWindow() - FlowControlStrategy.DEFAULT_WINDOW_SIZE;
+ session.updateRecvWindow(windowDelta);
+ if (windowDelta > 0) {
+ session.frames(null,
+ List.of(prefaceFrame, settingsFrame, new WindowUpdateFrame(0, windowDelta)), this);
+ } else {
+ session.frames(null, List.of(prefaceFrame, settingsFrame), this);
+ }
+ }
+
+ @Override
+ public void succeeded() {
+ super.onOpen();
+ promise.succeeded(getSession());
+ produce();
+ }
+
+ @Override
+ public void failed(Throwable ex) {
+ close();
+ promise.failed(ex);
+ }
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java
new file mode 100644
index 00000000..cc8e58b7
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java
@@ -0,0 +1,118 @@
+package com.blazemeter.jmeter.http2.core.jetty.custom.http2;
+
+import org.eclipse.jetty.http.HttpHeader;
+import org.eclipse.jetty.http.HttpTokens;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Firefox-inspired HTTP/2 header softening for load-testing clients.
+ *
+ *
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 context)
+ throws IOException {
+ return connectionFactory.newConnection(endPoint, context);
+ }
+}
diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java
new file mode 100644
index 00000000..add71eee
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java
@@ -0,0 +1,37 @@
+package com.blazemeter.jmeter.http2.core.jetty.custom.http2;
+
+import java.lang.reflect.Field;
+import org.eclipse.jetty.http2.hpack.CustomHpackDecoder;
+import org.eclipse.jetty.http2.parser.Parser;
+import org.eclipse.jetty.io.ByteBufferPool;
+import org.eclipse.jetty.io.RateControl;
+
+/**
+ * Jetty HTTP/2 {@link Parser} that installs {@link CustomHpackDecoder} before {@link #init}.
+ *
+ *
{@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/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java
index 529c2708..b1941550 100644
--- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java
+++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java
@@ -6,11 +6,15 @@
import com.blazemeter.jmeter.http2.core.HTTP2ClientProfileConfig;
import com.blazemeter.jmeter.http2.core.HTTP2FutureResponseListener;
import com.blazemeter.jmeter.http2.core.HTTP2JettyClient;
+import com.blazemeter.jmeter.http2.core.HpackFailureDetector;
+import com.blazemeter.jmeter.http2.core.JmeterHttpClientExceptionMapper;
import com.blazemeter.jmeter.http2.core.ProtocolErrorException;
import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties;
+import com.blazemeter.jmeter.http2.util.Rfc9110Redirects;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.helger.commons.annotation.VisibleForTesting;
+import java.io.IOException;
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
@@ -115,33 +119,6 @@ protected Map childValue(
private static final String USER_AGENT = "User-Agent"; // $NON-NLS-1$
private static final boolean USE_JAVA_REGEX = !getPropDefault(
"jmeter.regex.engine", "oro").equalsIgnoreCase("oro");
- private static final String RESPONSE_PARSERS = // list of parsers
- JMeterUtils.getProperty("HTTPResponse.parsers"); //$NON-NLS-1$
-
- static {
- String[] parsers =
- JOrphanUtils.split(RESPONSE_PARSERS, " ", true); // returns empty array for null
- for (final String parser : parsers) {
- String classname = JMeterUtils.getProperty(parser + ".className"); //$NON-NLS-1$
- if (classname == null) {
- LOG.error("Cannot find .className property for {}, ensure you set property: '{}.className'",
- parser, parser);
- continue;
- }
- String typeList = JMeterUtils.getProperty(parser + ".types"); //$NON-NLS-1$
- if (typeList != null) {
- String[] types = JOrphanUtils.split(typeList, " ", true);
- for (final String type : types) {
- registerParser(type, classname);
- }
- } else {
- LOG.warn(
- "Cannot find .types property for {}, as a consequence parser " +
- "will not be used, to make it usable, define property:'{}.types'",
- parser, parser);
- }
- }
- }
private final transient Callable clientFactory;
private final boolean dumpAtThreadEnd =
@@ -478,7 +455,9 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
LOG.debug("isProtocolError(cause): {}", isProtocolErrorCause);
LOG.debug("isProtocolError(exception): {}", isProtocolErrorException);
- if (isProtocolErrorCause || isProtocolErrorException) {
+ if ((isProtocolErrorCause || isProtocolErrorException)
+ && !HpackFailureDetector.indicatesHpackFailure(e)
+ && !HpackFailureDetector.indicatesHpackFailure(cause)) {
boolean fallbackEnabled = isProtocolErrorFallbackEnabled();
if (!fallbackEnabled) {
LOG.warn("HTTP/2 protocol_error detected and fallback is DISABLED. "
@@ -626,7 +605,9 @@ private HTTPSampleResult buildErrorResult(Exception e, HTTPSampleResult result)
result.sampleEnd();
}
}
- return errorResult(e, result);
+ return errorResult(
+ JmeterHttpClientExceptionMapper.forSampleResult(e, getAutoRedirects(), result.getURL()),
+ result);
}
/**
@@ -655,6 +636,58 @@ private void copyJettyProtocolSettingsToEmbeddedSampler(HTTP2Sampler embedded) {
embedded.setHttp1UpgradeEnabled(isHttp1UpgradeEnabled());
}
+ /**
+ * Builds an embedded-resource child sampler for a {@code file://} URL discovered by the HTML
+ * parser (e.g. a relative {@code href}/{@code src} resolved against a {@code file://} parent).
+ * {@code setImageParser} is enabled for {@code .html}/{@code .htm} targets so nested embedded
+ * resources (e.g. an iframe pointing at another local HTML file) are themselves parsed and
+ * downloaded, matching how a real HTTP-embedded HTML resource would recurse.
+ */
+ private HTTP2Sampler newFileEmbeddedSampler(URL url) {
+ HTTP2Sampler fileSampler = new HTTP2Sampler();
+ copyJettyProtocolSettingsToEmbeddedSampler(fileSampler);
+ String path = url.getPath();
+ boolean htmlResource = path != null
+ && (path.endsWith(".html") || path.endsWith(".htm"));
+ fileSampler.setImageParser(htmlResource);
+ fileSampler.setMethod(HTTPConstants.GET);
+ fileSampler.setProtocol(url.getProtocol());
+ fileSampler.setDomain(url.getHost());
+ fileSampler.setPort(url.getPort());
+ if (url.getQuery() == null) {
+ fileSampler.setPath(url.getPath());
+ } else {
+ fileSampler.setPath(url.getPath() + url.getQuery());
+ }
+ fileSampler.setHeaderManager(getHeaderManager());
+ fileSampler.setCookieManager(getCookieManager());
+ return fileSampler;
+ }
+
+ /** {@code file://} URLs have no HTTP-style path to label sub-results with; build one instead. */
+ private static String formatFileEmbeddedLabel(URL url, int index) {
+ String path = url.getPath();
+ if (path != null && path.startsWith("/")) {
+ path = path.substring(1);
+ }
+ return url.getProtocol() + ":" + path + "-" + index;
+ }
+
+ private static void relabelFileEmbeddedChildren(HTTPSampleResult parent) {
+ int childIndex = 0;
+ for (SampleResult child : parent.getSubResults()) {
+ if (child instanceof HTTPSampleResult) {
+ HTTPSampleResult httpChild = (HTTPSampleResult) child;
+ if (httpChild.getURL() != null
+ && "file".equalsIgnoreCase(httpChild.getURL().getProtocol())) {
+ httpChild.setSampleLabel(
+ formatFileEmbeddedLabel(httpChild.getURL(), childIndex++));
+ relabelFileEmbeddedChildren(httpChild);
+ }
+ }
+ }
+ }
+
private HTTP2JettyClient buildClient() throws Exception {
HTTP2ClientKey connectionKey = buildConnectionKey();
HTTP2JettyClient client = new HTTP2JettyClient(isHttp1UpgradeEnabled(),
@@ -726,18 +759,169 @@ private HTTP2JettyClient getClient() throws Exception {
: buildClient();
}
+ /**
+ * JMeter 5.6.3's {@code HTTPSampleResult.isRedirect()} only recognizes a {@code 307} as a
+ * redirect for GET/HEAD, so a POST/PUT/PATCH/DELETE + 307 is returned as-is by the inherited
+ * {@code resultProcessing()} - {@link #followRedirects} below is never even reached. See
+ * {@link Rfc9110Redirects} for the fix this ports (apache/jmeter PR #6658) and why it's applied
+ * this way instead of overriding {@code HTTPSampleResult.isRedirect()} directly. This drives
+ * the follow-up itself only for that one gap, then hands off to the inherited
+ * {@code resultProcessing()} (marking {@code areFollowingRedirect=true}) for everything else
+ * (embedded resources, etc.) exactly as it would normally run. Falls back to the unfixed
+ * inherited behavior when {@link Rfc9110Redirects#useLegacyMethodHandling()}.
+ */
+ @Override
public HTTPSampleResult resultProcessing(final boolean pAreFollowingRedirect,
final int frameDepth, final HTTPSampleResult pRes) {
+ if (!Rfc9110Redirects.useLegacyMethodHandling()
+ && !pAreFollowingRedirect
+ && !pRes.isRedirect()
+ && Rfc9110Redirects.isRedirect(pRes.getResponseCode())
+ && getFollowRedirects()) {
+ HTTPSampleResult followed = followRedirects(pRes, frameDepth);
+ return super.resultProcessing(true, frameDepth, followed);
+ }
return super.resultProcessing(pAreFollowingRedirect, frameDepth, pRes);
}
+ /**
+ * Ported from {@code HTTPSamplerBase.followRedirects} (JMeter 5.6.3) with the fix from
+ * apache/jmeter PR #6658 (see {@link Rfc9110Redirects} and {@link #resultProcessing}):
+ * {@code computeMethodForRedirect} now takes the response code into account so 307/308
+ * preserve the original method (RFC 9110 sections 15.4.8/15.4.9), instead of always rewriting
+ * to GET like 301/302/303 do. Uses {@link Rfc9110Redirects#isRedirect} instead of
+ * {@code HTTPSampleResult.isRedirect()} to decide whether to keep following a redirect chain,
+ * for the same reason {@link #resultProcessing} avoids relying on that method for 307. Falls
+ * back to the inherited, unfixed JMeter behavior when
+ * {@link Rfc9110Redirects#useLegacyMethodHandling()}.
+ */
+ @Override
+ protected HTTPSampleResult followRedirects(HTTPSampleResult res, int frameDepth) {
+ if (Rfc9110Redirects.useLegacyMethodHandling()) {
+ return super.followRedirects(res, frameDepth);
+ }
+ HTTPSampleResult totalRes = new HTTPSampleResult(res);
+ totalRes.addRawSubResult(res);
+ HTTPSampleResult lastRes = res;
+
+ int redirect;
+ for (redirect = 0; redirect < MAX_REDIRECTS; redirect++) {
+ boolean invalidRedirectUrl = false;
+ String location = lastRes.getRedirectLocation();
+ if (JMeterUtils.getPropDefault("httpsampler.redirect.removeslashdotdot", true)) {
+ location = ConversionUtils.removeSlashDotDot(location);
+ }
+ location = encodeSpaces(location);
+ String method = computeMethodForRedirect(lastRes.getHTTPMethod(), lastRes.getResponseCode());
+
+ try {
+ URL url = ConversionUtils.makeRelativeURL(lastRes.getURL(), location);
+ url = ConversionUtils.sanitizeUrl(url).toURL();
+ HTTPSampleResult tempRes = sample(url, method, true, frameDepth);
+ if (tempRes != null) {
+ lastRes = tempRes;
+ } else {
+ break;
+ }
+ } catch (MalformedURLException | URISyntaxException e) {
+ errorResult(e, lastRes);
+ invalidRedirectUrl = true;
+ }
+ if (lastRes.getSubResults() != null && lastRes.getSubResults().length > 0) {
+ for (SampleResult sub : lastRes.getSubResults()) {
+ totalRes.addSubResult(sub);
+ }
+ } else if (!invalidRedirectUrl) {
+ totalRes.addSubResult(lastRes);
+ }
+
+ if (!Rfc9110Redirects.isRedirect(lastRes.getResponseCode())) {
+ break;
+ }
+ }
+ if (redirect >= MAX_REDIRECTS) {
+ lastRes = errorResult(
+ new IOException("Exceeded maximum number of redirects: " + MAX_REDIRECTS),
+ new HTTPSampleResult(lastRes));
+ totalRes.addSubResult(lastRes);
+ }
+
+ totalRes.setSampleLabel(totalRes.getSampleLabel() + "->" + lastRes.getSampleLabel());
+ totalRes.setURL(lastRes.getURL());
+ totalRes.setHTTPMethod(lastRes.getHTTPMethod());
+ totalRes.setQueryString(lastRes.getQueryString());
+ totalRes.setRequestHeaders(lastRes.getRequestHeaders());
+ totalRes.setResponseData(lastRes.getResponseData());
+ totalRes.setResponseCode(lastRes.getResponseCode());
+ totalRes.setSuccessful(lastRes.isSuccessful());
+ totalRes.setResponseMessage(lastRes.getResponseMessage());
+ totalRes.setDataType(lastRes.getDataType());
+ totalRes.setResponseHeaders(lastRes.getResponseHeaders());
+ totalRes.setContentType(lastRes.getContentType());
+ totalRes.setDataEncoding(lastRes.getDataEncodingNoDefault());
+ return totalRes;
+ }
+
+ private String computeMethodForRedirect(String initialMethod, String responseCode) {
+ if (HTTPConstants.SC_TEMPORARY_REDIRECT.equals(responseCode)
+ || HTTPConstants.SC_PERMANENT_REDIRECT.equals(responseCode)) {
+ return initialMethod;
+ }
+ if (!HTTPConstants.HEAD.equalsIgnoreCase(initialMethod)) {
+ return HTTPConstants.GET;
+ }
+ return initialMethod;
+ }
+
static void registerParser(String contentType, String className) {
LOG.info("Parser for {} is {}", contentType, className);
PARSERS_FOR_CONTENT_TYPE.put(contentType, className);
}
+ /**
+ * JMeter batch runs configure HTML parsers via {@code -q jmeter-batch.properties}, loaded after
+ * this class may already have been initialized - so register parsers lazily on first use
+ * instead of in a static block, to read whatever properties are actually in effect by then.
+ */
+ private static void ensureResponseParsersLoaded() {
+ if (!PARSERS_FOR_CONTENT_TYPE.isEmpty()) {
+ return;
+ }
+ synchronized (PARSERS_FOR_CONTENT_TYPE) {
+ if (PARSERS_FOR_CONTENT_TYPE.isEmpty()) {
+ loadResponseParsersFromProperties();
+ }
+ }
+ }
+
+ private static void loadResponseParsersFromProperties() {
+ String responseParsers = JMeterUtils.getProperty("HTTPResponse.parsers"); //$NON-NLS-1$
+ String[] parsers = JOrphanUtils.split(responseParsers, " ", true); // empty array for null
+ for (final String parser : parsers) {
+ String classname = JMeterUtils.getProperty(parser + ".className"); //$NON-NLS-1$
+ if (classname == null) {
+ LOG.error("Cannot find .className property for {}, ensure you set property: '{}.className'",
+ parser, parser);
+ continue;
+ }
+ String typeList = JMeterUtils.getProperty(parser + ".types"); //$NON-NLS-1$
+ if (typeList != null) {
+ String[] types = JOrphanUtils.split(typeList, " ", true);
+ for (final String type : types) {
+ registerParser(type, classname);
+ }
+ } else {
+ LOG.warn(
+ "Cannot find .types property for {}, as a consequence parser "
+ + "will not be used, to make it usable, define property:'{}.types'",
+ parser, parser);
+ }
+ }
+ }
+
private LinkExtractorParser getParser(HTTPSampleResult res)
throws LinkExtractorParseException {
+ ensureResponseParsersLoaded();
String parserClassName =
PARSERS_FOR_CONTENT_TYPE.get(res.getMediaType());
if (!StringUtils.isEmpty(parserClassName)) {
@@ -755,13 +939,15 @@ private String getUserAgent(HTTPSampleResult sampleResult) {
// see HTTPJavaImpl#getConnectionHeaders
//': ' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders
final String userAgentPrefix = USER_AGENT + ": ";
- String userAgentHdr = res.substring(
- index + userAgentPrefix.length(),
- res.indexOf(
- '\n',
- // '\n' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders
- index + userAgentPrefix.length() + 1));
- return userAgentHdr.trim();
+ int valueStart = index + userAgentPrefix.length();
+ // '\n' is used by JMeter to fill-in requestHeaders, see getConnectionHeaders. When
+ // User-Agent is the last header, there's no trailing '\n' and indexOf returns -1; fall
+ // back to the end of the string instead of feeding -1 into substring().
+ int lineEnd = res.indexOf('\n', valueStart);
+ if (lineEnd < 0) {
+ lineEnd = res.length();
+ }
+ return res.substring(valueStart, lineEnd).trim();
} else {
if (LOG.isDebugEnabled()) {
LOG.debug("No user agent extracted from requestHeaders:{}", res);
@@ -928,6 +1114,7 @@ protected HTTPSampleResult downloadPageResources(final HTTPSampleResult pRes,
setSyncRequest(!isConcurrentDwn); // Change default from main request based on sub request
+ int fileEmbeddedIndex = 0;
while (urls.hasNext()) {
Object binURL = urls.next(); // See catch clause below
try {
@@ -960,6 +1147,20 @@ protected HTTPSampleResult downloadPageResources(final HTTPSampleResult pRes,
continue;
}
+ if ("file".equalsIgnoreCase(url.getProtocol())) {
+ HTTP2Sampler fileSampler = newFileEmbeddedSampler(url);
+ HTTPSampleResult binRes =
+ fileSampler.sample(url, HTTPConstants.GET, false, frameDepth + 1);
+ if (binRes != null) {
+ binRes.setSampleLabel(formatFileEmbeddedLabel(url, fileEmbeddedIndex++));
+ relabelFileEmbeddedChildren(binRes);
+ }
+ subres.addSubResult(binRes);
+ setParentSampleSuccess(subres,
+ subres.isSuccessful() && (binRes == null || binRes.isSuccessful()));
+ continue;
+ }
+
HTTP2Sampler h2s = new HTTP2Sampler();
copyJettyProtocolSettingsToEmbeddedSampler(h2s);
h2s.setMethod("GET");
diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java
new file mode 100644
index 00000000..e5d2b59c
--- /dev/null
+++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/JmxBlazeMeterHttpMigrator.java
@@ -0,0 +1,155 @@
+package com.blazemeter.jmeter.http2.sampler;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase;
+import org.apache.jmeter.save.SaveService;
+import org.apache.jmeter.testelement.TestElement;
+import org.apache.jorphan.collections.HashTree;
+import org.apache.jorphan.collections.ListedHashTree;
+
+/**
+ * Headless migration of JMeter test plans: replaces stock HTTP Request samplers with
+ * {@link HTTP2Sampler} while preserving child elements (assertions, timers, etc.).
+ */
+public final class JmxBlazeMeterHttpMigrator {
+
+ private JmxBlazeMeterHttpMigrator() {
+ }
+
+ public static HashTree loadTree(File jmxFile) throws IOException {
+ return SaveService.loadTree(jmxFile);
+ }
+
+ public static void saveTree(HashTree tree, File jmxFile) throws IOException {
+ try (OutputStream out = java.nio.file.Files.newOutputStream(jmxFile.toPath())) {
+ SaveService.saveTree(tree, out);
+ }
+ }
+
+ /**
+ * @return number of HTTP Request samplers replaced
+ */
+ public static int migrateTree(HashTree tree) {
+ return migrateTreeWithDetails(tree).getReplacedCount();
+ }
+
+ public static MigrationResult migrateTreeWithDetails(HashTree tree) {
+ MigrationResult result = new MigrationResult();
+ migrateInPlace(tree, result);
+ return result;
+ }
+
+ public static HashTree migrateCopy(HashTree source) {
+ return migrateCopy(source, new MigrationResult());
+ }
+
+ public static HashTree migrateCopy(HashTree source, MigrationResult result) {
+ // Declared as HashTree (not ListedHashTree) so add(Object) binds to the stable base-class
+ // method: JMeter 5.6.3's ListedHashTree added a covariant add(Object) override that older
+ // jorphan releases (e.g. 5.5) don't have, which would throw NoSuchMethodError at runtime
+ // if this class were compiled against a jar with the override and loaded into an older one.
+ HashTree copy = new ListedHashTree();
+ for (Object key : source.list()) {
+ Object newKey = maybeReplaceSampler(key, result);
+ HashTree sub = source.getTree(key);
+ if (sub != null && !sub.isEmpty()) {
+ copy.add(newKey, migrateCopy(sub, result));
+ } else {
+ copy.add(newKey);
+ }
+ }
+ return copy;
+ }
+
+ public static File migrateFile(File sourceJmx, File targetJmx) throws IOException {
+ HashTree tree = loadTree(sourceJmx);
+ migrateTree(tree);
+ saveTree(tree, targetJmx);
+ return targetJmx;
+ }
+
+ public static int countMigratableSamplers(HashTree tree) {
+ int count = 0;
+ for (Object key : tree.list()) {
+ if (key instanceof TestElement
+ && HttpSamplerToBlazeMeterHttpMigrator.isMigratableApacheHttpSampler(
+ (TestElement) key)) {
+ count++;
+ }
+ HashTree sub = tree.getTree(key);
+ if (sub != null && !sub.isEmpty()) {
+ count += countMigratableSamplers(sub);
+ }
+ }
+ return count;
+ }
+
+ public static int countHttp2Samplers(HashTree tree) {
+ int count = 0;
+ for (Object key : tree.list()) {
+ if (key instanceof HTTP2Sampler) {
+ count++;
+ }
+ HashTree sub = tree.getTree(key);
+ if (sub != null && !sub.isEmpty()) {
+ count += countHttp2Samplers(sub);
+ }
+ }
+ return count;
+ }
+
+ private static void migrateInPlace(HashTree tree, MigrationResult result) {
+ List