From c679dc154f66d33520682ff51422be9eea85630e Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Wed, 22 Jul 2026 13:10:49 +0200
Subject: [PATCH 01/19] Add support for HTTP Client 5 (`HTTPHC5Impl`)
---
src/protocol/http/build.gradle.kts | 1 +
.../protocol/http/sampler/HTTPHC5Impl.java | 438 ++++++++++++++++++
.../http/sampler/HTTPSamplerFactory.java | 11 +-
.../http/sampler/TestHTTPSamplerFactory.java | 45 ++
4 files changed, 493 insertions(+), 2 deletions(-)
create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java
diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts
index af6b3482f5d..f2f5bfaa9f8 100644
--- a/src/protocol/http/build.gradle.kts
+++ b/src/protocol/http/build.gradle.kts
@@ -61,6 +61,7 @@ dependencies {
exclude("com.google.code.findbugs", "jsr305")
}
implementation("dnsjava:dnsjava")
+ implementation("org.apache.httpcomponents.client5:httpclient5")
implementation("org.apache.httpcomponents:httpmime")
implementation("org.apache.httpcomponents:httpcore")
implementation("org.brotli:dec")
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
new file mode 100644
index 00000000000..773adfd6ab1
--- /dev/null
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -0,0 +1,438 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jmeter.protocol.http.sampler;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
+import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.Header;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.NameValuePair;
+import org.apache.hc.core5.http.io.entity.FileEntity;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.BasicNameValuePair;
+import org.apache.hc.core5.util.Timeout;
+import org.apache.jmeter.protocol.http.control.CacheManager;
+import org.apache.jmeter.protocol.http.control.CookieManager;
+import org.apache.jmeter.protocol.http.control.HeaderManager;
+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.CollectionProperty;
+import org.apache.jmeter.testelement.property.JMeterProperty;
+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.jorphan.util.JOrphanUtils;
+import org.apache.jorphan.util.StringUtilities;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * HTTP Sampler using Apache HttpClient 5.x.
+ */
+public class HTTPHC5Impl extends HTTPHCAbstractImpl {
+
+ private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class);
+
+ private static final ThreadLocal
Retry handling
-By default retry has been set to 0 for both HttpClient4 and Java implementations, meaning no retry is attempted.
+By default retry has been set to 0 for HttpClient4, HttpClient5 and Java implementations, meaning no retry is attempted.
For HttpClient4, the retry count can be overridden by setting the relevant JMeter property, for example:
@@ -3623,7 +3624,7 @@ By default, a Graphite implementation is provided.
DNS Cache Manager is designed for using in the root of Thread Group or Test Plan. Do not place it as child element of particular HTTP Sampler
- DNS Cache Manager works only with HTTP requests using HTTPClient4 implementation.
+ DNS Cache Manager works only with HTTP requests using HTTPClient4 or HTTPClient5 implementation.
The DNS Cache Manager element allows to test applications, which have several servers behind load balancers (CDN, etc.),
when user receives content from different IP's. By default JMeter uses JVM DNS cache. That's why
only one server from the cluster receives load. DNS Cache Manager resolves names for each thread separately each iteration and
@@ -3649,7 +3650,7 @@ By default, a Graphite implementation is provided.
The IP address for the test server will be looked up by using the custom DNS resolver. When none is given, the
system DNS resolver will be used.
-
Now you can use www.example.com in your HTTPClient4 samplers and the requests will be made against
+
Now you can use www.example.com in your HTTPClient4 or HTTPClient5 samplers and the requests will be made against
a123.another.example.org with all headers set to www.example.com.
@@ -3683,14 +3684,14 @@ transmits the login information when it encounters this type of page.
The Authorization headers may not be shown in the Tree View Listener "Request" tab.
The Java implementation does pre-emptive authentication, but it does not
return the Authorization header when JMeter fetches the headers.
-The HttpComponents (HC 4.5.X) implementation defaults to pre-emptive since 3.2 and the header will be shown.
-To disable this, set the values as below, in which case authentication will only be performed in response to a challenge.
+The HttpComponents HC 4.5.X and HC 5.X implementations use pre-emptive Basic authentication and the header will be shown.
+To disable this for HttpClient4, set the value below, in which case authentication will only be performed in response to a challenge.
In the file jmeter.properties set httpclient4.auth.preemptive=false
-Note: the above settings only apply to the HttpClient sampler.
+Note: the above setting applies only to HttpClient4.
When looking for a match against a URL, JMeter checks each entry in turn, and stops when it finds the first match.
@@ -3719,6 +3720,7 @@ information for the user named, "jmeter".
Java
BASIC
HttpClient 4
BASIC, DIGEST and Kerberos
+
HttpClient 5
BASIC and DIGEST
@@ -3912,7 +3914,7 @@ All port values are treated equally; a sampler that does not specify a port will
Port the web server is listening to.Connection Timeout. Number of milliseconds to wait for a connection to open.Response Timeout. Number of milliseconds to wait for a response.
- Java, HttpClient4.
+ Java, HttpClient4, HttpClient5.
If not specified the default depends on the value of the JMeter property
jmeter.httpsampler, failing that, the Java implementation is used.HTTP or HTTPS.
diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml
index a40ae5027a6..1b5f23217b9 100644
--- a/xdocs/usermanual/properties_reference.xml
+++ b/xdocs/usermanual/properties_reference.xml
@@ -796,6 +796,8 @@ JMETER-SERVER
HTTPSampler2
HttpClient4
Use Apache HTTPClient version 4
+
HttpClient5
+
Use Apache HTTPClient version 5
Defaults to: HttpClient4
From 7556679197f73ea1ebb2be2b9a8af9710dd68cc5 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Thu, 23 Jul 2026 14:53:45 +0200
Subject: [PATCH 07/19] Add HTTP/2 support in `HTTPHC5Impl` with configurable
HTTP version handling
---
bin/jmeter.properties | 5 +-
src/bom-thirdparty/build.gradle.kts | 1 +
.../jmeter/resources/messages.properties | 1 +
.../jmeter/resources/messages_de.properties | 1 +
.../jmeter/resources/messages_es.properties | 1 +
.../jmeter/resources/messages_fr.properties | 1 +
.../jmeter/resources/messages_ja.properties | 1 +
.../jmeter/resources/messages_ko.properties | 1 +
.../jmeter/resources/messages_no.properties | 1 +
.../jmeter/resources/messages_pl.properties | 1 +
.../resources/messages_pt_BR.properties | 1 +
.../jmeter/resources/messages_tr.properties | 1 +
.../resources/messages_zh_CN.properties | 1 +
.../resources/messages_zh_TW.properties | 1 +
src/protocol/http/build.gradle.kts | 1 +
.../http/config/gui/HttpDefaultsGui.java | 5 +
.../http/control/gui/HttpTestSampleGui.java | 7 +
.../protocol/http/sampler/HTTPHC5Impl.java | 165 ++++++++++++++++--
.../http/sampler/HTTPSamplerBase.java | 10 ++
.../http/sampler/HTTPSamplerBaseSchema.kt | 3 +
xdocs/usermanual/component_reference.xml | 3 +
xdocs/usermanual/properties_reference.xml | 5 +-
22 files changed, 198 insertions(+), 19 deletions(-)
diff --git a/bin/jmeter.properties b/bin/jmeter.properties
index dd4dd7eaacd..91c948e429c 100644
--- a/bin/jmeter.properties
+++ b/bin/jmeter.properties
@@ -378,8 +378,9 @@ remote_hosts=127.0.0.1
#httpclient.timeout=0
# 0 == no timeout
-# Set the http version (defaults to 1.1)
-#httpclient.version=1.1 (or use the parameter http.protocol.version)
+# Set the default HTTP version for HttpClient5 samplers when HTTP Version is empty
+# Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1)
+#httpclient.version=HTTP/1.1
# Define characters per second > 0 to emulate slow connections
#httpclient.socket.http.cps=0
diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts
index ffd0b4073f5..7b9b81a0845 100644
--- a/src/bom-thirdparty/build.gradle.kts
+++ b/src/bom-thirdparty/build.gradle.kts
@@ -107,6 +107,7 @@ dependencies {
because("User might still rely on commons-text")
}
api("org.apache.httpcomponents.client5:httpclient5:5.5.1")
+ api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4")
api("org.apache.httpcomponents:httpasyncclient:4.1.5")
api("org.apache.httpcomponents:httpclient:4.5.14")
api("org.apache.httpcomponents:httpcore-nio:4.4.16")
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties
index 3137d879e74..3ba59ea9f1f 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages.properties
@@ -473,6 +473,7 @@ html_assertion_title=HTML Assertion
html_extractor_title=CSS Selector Extractor
html_extractor_type=CSS Selector Extractor Implementation
http_implementation=Implementation:
+http_version=HTTP Version:
html_report=Generate HTML report
http_response_code=HTTP response code
http_url_rewriting_modifier_title=HTTP URL Re-writing Modifier
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties
index f1278670119..17ff43ab91f 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_de.properties
@@ -16,6 +16,7 @@
#
about=Über Apache JMeter
+http_version=HTTP-Version\:
add=Hinzufügen
add_as_child=Als ein Kind hinzufügen
add_parameter=Variable hinzufügen
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties
index 8fe4fb5a53d..dcb50db6a35 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_es.properties
@@ -273,6 +273,7 @@ html_assertion_file=Escribir el reporte JTidy en fichero
html_assertion_label=Aserción HTML
html_assertion_title=Aserción HTML
http_implementation=Implementación HTTP\:
+http_version=Versión HTTP\:
http_response_code=código de respuesta HTTP
http_url_rewriting_modifier_title=Modificador de re-escritura HTTP URL
http_user_parameter_modifier=Modificador de Parámetro de Usuario HTTP
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties
index 044d7ba8351..209f0a84645 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_fr.properties
@@ -467,6 +467,7 @@ html_assertion_title=Assertion HTML
html_extractor_title=Extracteur par Sélecteur CSS
html_extractor_type=Implémentation de l'extracteur Sélecteur CSS
http_implementation=Implémentation \:
+http_version=Version HTTP \:
html_report=Générer le rapport HTML
http_response_code=Code de réponse HTTP
http_url_rewriting_modifier_title=Transcripteur d'URL HTTP
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties
index 561489d9e29..6fe16f42798 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ja.properties
@@ -176,6 +176,7 @@ grouping_store_first_only=各グループの最初のサンプラーだけ保存
header_manager_title=HTTP ヘッダマネージャ
headers_stored=ヘッダーマネージャに保存されているヘッダ
help=ヘルプ
+http_version=HTTP バージョン\:
http_response_code=HTTP応答コード
http_url_rewriting_modifier_title=HTTP URL-Rewriting 修飾子
http_user_parameter_modifier=HTTPユーザーパラメータの変更
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties
index 8b963980d24..1de63f00cd6 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_ko.properties
@@ -471,6 +471,7 @@ html_assertion_title=HTML Assertion
html_extractor_title=CSS Selector 추출기
html_extractor_type=CSS Selector 추출기 구현
http_implementation=구현\:
+http_version=HTTP 버전\:
html_report=HTML 보고서 생성
http_response_code=HTTP 응답 코드
http_url_rewriting_modifier_title=HTTP URL Re-writing Modifier
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties
index a6daacc3059..160b0288aa1 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_no.properties
@@ -71,6 +71,7 @@ graph_results_deviation=Avvik
graph_results_title=Graf resultater
headers_stored=Headere lagret hos headermanager
help=Hjelp
+http_version=HTTP-versjon\:
infinite=Uendelig
interleave_control_title=Vekslende kontroller
iterator_num=Løkketeller\:
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties
index fb5aedd2f24..9d81a10d112 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pl.properties
@@ -16,6 +16,7 @@
#
about=O programie Apache JMeter
+http_version=Wersja HTTP\:
action_check_message=Aktualnie jest przeprowadzany test, zatrzymaj albo wyłącz test, aby uruchomić to polecenie
action_check_title=Test uruchomiony
active_total_threads_tooltip=Uruchamianie wątków / całkowita liczba wątków do uruchomienia
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties
index e9960bf8719..e278975db23 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_pt_BR.properties
@@ -261,6 +261,7 @@ html_assertion_file=Escrever relatório do JTidy em arquivo
html_assertion_label=Asserção HTML
html_assertion_title=Asserção HTML
http_implementation=Implementação\:
+http_version=Versão HTTP\:
http_response_code=Código da Resposta HTTP
http_url_rewriting_modifier_title=Modificador de Re-escrita de URL HTTP
http_user_parameter_modifier=Modificador de Parâmetros HTTP do Usuário
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties
index 352f8bd7e7a..a18b1fffbf4 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_tr.properties
@@ -246,6 +246,7 @@ html_assertion_file=JTidy raporunu dosyaya yaz
html_assertion_label=HTML Doğrulama
html_assertion_title=HTML Doğrulama
http_implementation=Uygulaması\:
+http_version=HTTP sürümü\:
http_response_code=HTTP cevap kodu
http_url_rewriting_modifier_title=HTTP URL Yeniden Yazma Niteleyicisi
http_user_parameter_modifier=HTTP Kullanıcı Parametresi Niteleyicisi
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties
index 1763f2be645..253e35f83ed 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_CN.properties
@@ -433,6 +433,7 @@ html_assertion_title=HTML断言
html_extractor_title=CSS/JQuery提取器
html_extractor_type=CSS 选择器提取器实现
http_implementation=实现:
+http_version=HTTP 版本:
http_response_code=HTTP响应代码
http_url_rewriting_modifier_title=HTTP URL 重写修饰符
http_user_parameter_modifier=HTTP 用户参数修饰符
diff --git a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties
index 93107f3298c..49012192320 100644
--- a/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties
+++ b/src/core/src/main/resources/org/apache/jmeter/resources/messages_zh_TW.properties
@@ -206,6 +206,7 @@ headers_stored=標頭管理員中儲存的標頭資料
help=輔助說明
html_assertion_label=HTML 驗證
html_assertion_title=HTML 驗證
+http_version=HTTP 版本:
http_response_code=HTTP 回應代碼
http_url_rewriting_modifier_title=HTTP URL 重導修飾詞
http_user_parameter_modifier=HTTP 使用者參數修飾詞
diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts
index f2f5bfaa9f8..5946f9280db 100644
--- a/src/protocol/http/build.gradle.kts
+++ b/src/protocol/http/build.gradle.kts
@@ -62,6 +62,7 @@ dependencies {
}
implementation("dnsjava:dnsjava")
implementation("org.apache.httpcomponents.client5:httpclient5")
+ implementation("org.apache.httpcomponents.core5:httpcore5-h2")
implementation("org.apache.httpcomponents:httpmime")
implementation("org.apache.httpcomponents:httpcore")
implementation("org.brotli:dec")
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java
index 8b49bcbb4ec..79534daa33a 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java
@@ -79,6 +79,7 @@ public class HttpDefaultsGui extends AbstractConfigGui {
private JTextField proxyUser;
private JPasswordField proxyPass;
private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations());
+ private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"});
private JTextField connectTimeOut;
private JTextField responseTimeOut;
@@ -152,6 +153,7 @@ public void modifyTestElement(TestElement config) {
}
config.set(httpSchema.getImplementation(), String.valueOf(httpImplementation.getSelectedItem()));
+ config.set(httpSchema.getHttpVersion(), String.valueOf(httpVersion.getSelectedItem()));
}
@Override
@@ -169,6 +171,7 @@ public void configure(TestElement el) {
HTTPSamplerBaseSchema httpSchema = HTTPSamplerBaseSchema.INSTANCE;
sourceIpType.setSelectedIndex(samplerBase.get(httpSchema.getIpSourceType()));
httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation()));
+ httpVersion.setSelectedItem(samplerBase.getString(httpSchema.getHttpVersion()));
}
private void init() { // WARNING: called from ctor so must not be overridden (i.e. must be private or final)
@@ -329,6 +332,8 @@ protected final JPanel getImplementationPanel(){
implPanel.add(new JLabel(JMeterUtils.getResString("http_implementation"))); // $NON-NLS-1$
httpImplementation.addItem("");// $NON-NLS-1$
implPanel.add(httpImplementation);
+ implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$
+ implPanel.add(httpVersion);
return implPanel;
}
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java
index 77863a286bd..5ce2acc909e 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java
@@ -81,6 +81,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui {
private JTextField proxyUser;
private JPasswordField proxyPass;
private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations());
+ private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"});
private JTextField connectTimeOut;
private JTextField responseTimeOut;
@@ -135,6 +136,7 @@ public void configure(TestElement element) {
if (!isAJP) {
sourceIpType.setSelectedIndex(samplerBase.getIpSourceType());
httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation()));
+ httpVersion.setSelectedItem(samplerBase.getHttpVersion());
}
}
@@ -179,6 +181,9 @@ public void modifyTestElement(TestElement sampler) {
String selectedImplementation = String.valueOf(httpImplementation.getSelectedItem());
samplerBase.set(httpSchema.getImplementation(),
StringUtilities.isBlank(selectedImplementation) ? null : selectedImplementation);
+ String selectedHttpVersion = String.valueOf(httpVersion.getSelectedItem());
+ samplerBase.set(httpSchema.getHttpVersion(),
+ StringUtilities.isBlank(selectedHttpVersion) ? null : selectedHttpVersion);
}
}
@@ -349,6 +354,8 @@ protected final JPanel getImplementationPanel(){
implPanel.add(new JLabel(JMeterUtils.getResString("http_implementation"))); // $NON-NLS-1$
httpImplementation.addItem("");// $NON-NLS-1$
implPanel.add(httpImplementation);
+ implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$
+ implPanel.add(httpVersion);
return implPanel;
}
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
index 586cae3e1b0..43d82c33305 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -26,19 +26,28 @@
import java.net.URLDecoder;
import java.net.UnknownHostException;
import java.nio.charset.Charset;
+import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeoutException;
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.classic.ExecChain;
import org.apache.hc.client5.http.classic.ExecChainHandler;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.config.TlsConfig;
import org.apache.hc.client5.http.entity.BrotliInputStreamFactory;
import org.apache.hc.client5.http.entity.DecompressingEntity;
import org.apache.hc.client5.http.entity.DeflateInputStreamFactory;
@@ -46,12 +55,18 @@
import org.apache.hc.client5.http.entity.InputStreamFactory;
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.H2AsyncClientBuilder;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner;
import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.client5.http.ssl.TrustAllStrategy;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
@@ -59,14 +74,21 @@
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpVersion;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.config.Lookup;
import org.apache.hc.core5.http.config.RegistryBuilder;
+import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.FileEntity;
import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
import org.apache.hc.core5.http.message.BasicHeaderValueParser;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.http.message.ParserCursor;
+import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.ssl.SSLContexts;
import org.apache.hc.core5.util.Timeout;
import org.apache.jmeter.protocol.http.control.AuthManager;
import org.apache.jmeter.protocol.http.control.Authorization;
@@ -95,6 +117,9 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl {
private static final ThreadLocal> HTTP_CLIENTS =
ThreadLocal.withInitial(HashMap::new);
+ private static final ThreadLocal> HTTP_2_CLIENTS =
+ ThreadLocal.withInitial(HashMap::new);
+
private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING,
HttpHeaders.CONTENT_MD5};
@@ -105,6 +130,8 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl {
.register("deflate", DeflateInputStreamFactory.getInstance())
.build();
+ private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy();
+
private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> {
HttpClientContext context = scope.clientContext;
RequestConfig requestConfig = context.getRequestConfig();
@@ -146,6 +173,19 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl {
private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest;
+ @SuppressWarnings("deprecation") // buildAsync is unavailable before HttpClient 5.5
+ private static TlsStrategy createHttp2TlsStrategy() {
+ try {
+ SSLContext sslContext = SSLContexts.custom().loadTrustMaterial(null, TrustAllStrategy.INSTANCE).build();
+ return ClientTlsStrategyBuilder.create()
+ .setSslContext(sslContext)
+ .setHostnameVerifier(NoopHostnameVerifier.INSTANCE)
+ .build();
+ } catch (GeneralSecurityException e) {
+ throw new IllegalStateException("Could not create HTTP/2 TLS strategy", e);
+ }
+ }
+
protected HTTPHC5Impl(HTTPSamplerBase testElement) {
super(testElement);
}
@@ -171,7 +211,9 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
currentRequest = request;
HttpClientKey clientKey = createHttpClientKey(url);
HttpClientContext context = createHttpClientContext(url, clientKey, request);
- response = getClient(clientKey).executeOpen(null, request, context);
+ response = clientKey.httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2
+ ? executeHttp2(getHttp2Client(clientKey), request, context)
+ : getClient(clientKey).executeOpen(null, request, context);
result.sampleEnd();
currentRequest = null;
@@ -209,6 +251,7 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre
private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request,
HTTPSampleResult result, boolean areFollowingRedirect) throws IOException {
+ HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION);
RequestConfig.Builder config = RequestConfig.custom()
.setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect);
int responseTimeout = getResponseTimeout();
@@ -216,9 +259,13 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht
config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout));
}
request.setConfig(config.build());
- request.setHeader(HTTPConstants.HEADER_CONNECTION,
- getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE);
- setConnectionHeaders(request, getHeaderManager());
+ if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) {
+ request.setHeader(HTTPConstants.HEADER_CONNECTION,
+ getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE);
+ } else {
+ request.setVersion(HttpVersion.HTTP_2);
+ }
+ setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy);
CacheManager cacheManager = getCacheManager();
if (cacheManager != null) {
cacheManager.setHeaders(url, request);
@@ -317,7 +364,7 @@ private static String getEntityPreview(HttpEntity entity, String contentEncoding
}
private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request,
- HeaderManager headerManager) {
+ HeaderManager headerManager, HttpVersionPolicy httpVersionPolicy) {
if (headerManager == null) {
return;
}
@@ -328,7 +375,9 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth
for (JMeterProperty property : headers) {
org.apache.jmeter.protocol.http.control.Header header =
(org.apache.jmeter.protocol.http.control.Header) property.getObjectValue();
- if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName())) {
+ if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName())
+ && (httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_2
+ || !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(header.getName()))) {
request.addHeader(header.getName(), header.getValue());
}
}
@@ -451,9 +500,17 @@ private static CloseableHttpClient getClient(HttpClientKey key) {
return clients.computeIfAbsent(key, HTTPHC5Impl::createClient);
}
+ private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) {
+ Map clients = HTTP_2_CLIENTS.get();
+ return clients.computeIfAbsent(key, HTTPHC5Impl::createHttp2Client);
+ }
+
private static CloseableHttpClient createClient(HttpClientKey key) {
org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries();
PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create();
+ connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom()
+ .setVersionPolicy(key.httpVersionPolicy)
+ .build());
if (key.dnsCacheManager != null) {
connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager));
}
@@ -464,7 +521,72 @@ private static CloseableHttpClient createClient(HttpClientKey key) {
.build());
}
builder.setConnectionManager(connectionManagerBuilder.build());
- builder.setRoutePlanner(new DefaultRoutePlanner(null) {
+ builder.setRoutePlanner(createRoutePlanner(key));
+ return builder.disableContentCompression()
+ .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING)
+ .build();
+ }
+
+ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) {
+ H2AsyncClientBuilder builder = HttpAsyncClients.customHttp2()
+ .disableAutomaticRetries()
+ .setTlsStrategy(HTTP_2_TLS_STRATEGY)
+ .setRoutePlanner(createRoutePlanner(key));
+ if (key.dnsCacheManager != null) {
+ builder.setDnsResolver(createDnsResolver(key.dnsCacheManager));
+ }
+ if (key.connectTimeout > 0) {
+ builder.setDefaultConnectionConfig(ConnectionConfig.custom()
+ .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout))
+ .build());
+ }
+ CloseableHttpAsyncClient asyncClient = builder.build();
+ asyncClient.start();
+ return asyncClient;
+ }
+
+ @SuppressWarnings("deprecation") // SimpleHttpRequest.copy is required for HttpClient 5.3 compatibility
+ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client,
+ org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context)
+ throws IOException {
+ SimpleHttpRequest asyncRequest = SimpleHttpRequest.copy(request);
+ asyncRequest.setConfig(request.getConfig());
+ HttpEntity requestEntity = request.getEntity();
+ if (requestEntity != null) {
+ asyncRequest.setBody(EntityUtils.toByteArray(requestEntity),
+ requestEntity.getContentType() == null ? ContentType.DEFAULT_BINARY
+ : ContentType.parse(requestEntity.getContentType()));
+ }
+ Future responseFuture = client.execute(asyncRequest, context, null);
+ try {
+ return createClassicResponse(responseFuture.get(1, java.util.concurrent.TimeUnit.MINUTES));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while executing HTTP/2 request", e);
+ } catch (TimeoutException e) {
+ responseFuture.cancel(true);
+ throw new IOException("Timed out while executing HTTP/2 request", e);
+ } catch (ExecutionException e) {
+ throw new IOException("Could not execute HTTP/2 request", e.getCause());
+ }
+ }
+
+ private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyncResponse) {
+ BasicClassicHttpResponse response = new BasicClassicHttpResponse(asyncResponse.getCode(),
+ asyncResponse.getReasonPhrase());
+ response.setVersion(asyncResponse.getVersion());
+ for (Header header : asyncResponse.getHeaders()) {
+ response.addHeader(header);
+ }
+ byte[] responseBody = asyncResponse.getBodyBytes();
+ if (responseBody != null) {
+ response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType()));
+ }
+ return response;
+ }
+
+ private static DefaultRoutePlanner createRoutePlanner(HttpClientKey key) {
+ return new DefaultRoutePlanner(null) {
@Override
protected HttpHost determineProxy(HttpHost target, org.apache.hc.core5.http.protocol.HttpContext context) {
return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null;
@@ -475,10 +597,7 @@ protected InetAddress determineLocalAddress(HttpHost firstHop,
org.apache.hc.core5.http.protocol.HttpContext context) {
return key.localAddress;
}
- });
- return builder.disableContentCompression()
- .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING)
- .build();
+ };
}
private static org.apache.hc.client5.http.DnsResolver createDnsResolver(DNSCacheManager dnsCacheManager) {
@@ -507,13 +626,21 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException {
InetAddress localAddress = getIpSourceAddress();
boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort);
boolean useStaticProxy = isStaticProxy(url.getHost());
+ HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION);
if (!useDynamicProxy) {
proxyScheme = PROXY_SCHEME;
proxyHost = PROXY_HOST;
proxyPort = PROXY_PORT;
}
return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy,
- proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress);
+ proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress,
+ httpVersionPolicy);
+ }
+
+ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion) {
+ String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion;
+ return "HTTP/2".equals(httpVersion) || "2".equals(httpVersion)
+ ? HttpVersionPolicy.FORCE_HTTP_2 : HttpVersionPolicy.FORCE_HTTP_1;
}
@Override
@@ -542,6 +669,11 @@ private static void closeThreadLocalClients() {
JOrphanUtils.closeQuietly(client);
}
clients.clear();
+ Map http2Clients = HTTP_2_CLIENTS.get();
+ for (CloseableHttpAsyncClient client : http2Clients.values()) {
+ JOrphanUtils.closeQuietly(client);
+ }
+ http2Clients.clear();
}
@Override
@@ -566,10 +698,11 @@ private static final class HttpClientKey {
private final int connectTimeout;
private final DNSCacheManager dnsCacheManager;
private final InetAddress localAddress;
+ private final HttpVersionPolicy httpVersionPolicy;
private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme,
String proxyHost, int proxyPort, String proxyUser, String proxyPass, int connectTimeout, DNSCacheManager dnsCacheManager,
- InetAddress localAddress) {
+ InetAddress localAddress, HttpVersionPolicy httpVersionPolicy) {
this.protocol = protocol;
this.authority = authority;
this.hasProxy = hasProxy;
@@ -581,6 +714,7 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin
this.connectTimeout = connectTimeout;
this.dnsCacheManager = dnsCacheManager;
this.localAddress = localAddress;
+ this.httpVersionPolicy = httpVersionPolicy;
}
@Override
@@ -595,13 +729,14 @@ public boolean equals(Object object) {
&& Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority)
&& Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost)
&& Objects.equals(proxyUser, other.proxyUser) && Objects.equals(proxyPass, other.proxyPass)
- && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress);
+ && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress)
+ && httpVersionPolicy == other.httpVersionPolicy;
}
@Override
public int hashCode() {
return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager,
- localAddress);
+ localAddress, httpVersionPolicy);
}
}
}
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java
index 3105d0cf1ec..ee307623677 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBase.java
@@ -160,6 +160,8 @@ public abstract class HTTPSamplerBase extends AbstractSampler
public static final String IMPLEMENTATION = "HTTPSampler.implementation"; // $NON-NLS-1$
+ public static final String HTTP_VERSION = "HTTPSampler.httpVersion"; // $NON-NLS-1$
+
public static final String PATH = "HTTPSampler.path"; // $NON-NLS-1$
public static final String FOLLOW_REDIRECTS = HTTPSamplerBaseSchema.INSTANCE.getFollowRedirects().getName();
@@ -640,6 +642,14 @@ public String getImplementation() {
return get(getSchema().getImplementation());
}
+ public void setHttpVersion(String value) {
+ set(getSchema().getHttpVersion(), value);
+ }
+
+ public String getHttpVersion() {
+ return get(getSchema().getHttpVersion());
+ }
+
public boolean useMD5() {
return get(getSchema().getStoreAsMD5());
}
diff --git a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt
index 89bb2f58e28..84698fff881 100644
--- a/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt
+++ b/src/protocol/http/src/main/kotlin/org/apache/jmeter/protocol/http/sampler/HTTPSamplerBaseSchema.kt
@@ -87,6 +87,9 @@ public abstract class HTTPSamplerBaseSchema : TestElementSchema() {
public val implementation: StringPropertyDescriptor
by string("HTTPSampler.implementation")
+ public val httpVersion: StringPropertyDescriptor
+ by string("HTTPSampler.httpVersion")
+
public val connectTimeout: IntegerPropertyDescriptor
by int("HTTPSampler.connect_timeout")
diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml
index 6c70ff36de1..92fd13c699b 100644
--- a/xdocs/usermanual/component_reference.xml
+++ b/xdocs/usermanual/component_reference.xml
@@ -245,6 +245,9 @@ https.default.protocol=SSLv3
Java, HttpClient4, HttpClient5.
If not specified (and not defined by HTTP Request Defaults), the default depends on the value of the JMeter property
jmeter.httpsampler, failing that, the HttpClient4 implementation is used.
+ HTTP/1.1 or HTTP/2. Applies only to the
+ HttpClient5 implementation. An empty value uses the httpclient.version property, which
+ defaults to HTTP/1.1.HTTP, HTTPS or FILE. Default: HTTPGET, POST, HEAD, TRACE,
OPTIONS, PUT, DELETE, PATCH (not supported for
diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml
index 1b5f23217b9..1a584be6f1d 100644
--- a/xdocs/usermanual/properties_reference.xml
+++ b/xdocs/usermanual/properties_reference.xml
@@ -442,8 +442,9 @@ JMETER-SERVER
Defaults to: 0
- Set the http version.
- Defaults to: 1.1 (or use the parameter http.protocol.version)
+ Set the default HTTP version for HttpClient5 samplers with an empty HTTP Version value.
+ Valid values are HTTP/1.1 and HTTP/2.
+ Defaults to: HTTP/1.1
Set characters per second to a value greater then zero to emulate slow connections.
From cf1b9cfe686631fce46c4b123cc304f4487ab91b Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Thu, 23 Jul 2026 16:15:25 +0200
Subject: [PATCH 08/19] Add unit tests for `HTTPHC5Impl` features including
HTTP/2, caching, proxies, and authentication
---
.../protocol/http/sampler/HTTPHC5Impl.java | 16 +-
.../http/sampler/TestHTTPHC5Features.java | 276 ++++++++++++++++++
2 files changed, 287 insertions(+), 5 deletions(-)
create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
index 43d82c33305..458ea7f4151 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -56,12 +56,13 @@
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
-import org.apache.hc.client5.http.impl.async.H2AsyncClientBuilder;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder;
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
@@ -528,18 +529,23 @@ private static CloseableHttpClient createClient(HttpClientKey key) {
}
private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) {
- H2AsyncClientBuilder builder = HttpAsyncClients.customHttp2()
+ HttpAsyncClientBuilder builder = HttpAsyncClients.custom()
.disableAutomaticRetries()
- .setTlsStrategy(HTTP_2_TLS_STRATEGY)
.setRoutePlanner(createRoutePlanner(key));
+ PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create();
+ connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY);
+ connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom()
+ .setVersionPolicy(key.httpVersionPolicy)
+ .build());
if (key.dnsCacheManager != null) {
- builder.setDnsResolver(createDnsResolver(key.dnsCacheManager));
+ connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager));
}
if (key.connectTimeout > 0) {
- builder.setDefaultConnectionConfig(ConnectionConfig.custom()
+ connectionManagerBuilder.setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout))
.build());
}
+ builder.setConnectionManager(connectionManagerBuilder.build());
CloseableHttpAsyncClient asyncClient = builder.build();
asyncClient.start();
return asyncClient;
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
new file mode 100644
index 00000000000..c69dc254a37
--- /dev/null
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
@@ -0,0 +1,276 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jmeter.protocol.http.sampler;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.io.ByteArrayInputStream;
+import java.io.DataInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.jmeter.protocol.http.control.AuthManager;
+import org.apache.jmeter.protocol.http.control.CacheManager;
+import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.junit.jupiter.api.Test;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.client.WireMock;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+
+class TestHTTPHC5Features {
+
+ @Test
+ void usesHttpClientVersionWhenSamplerVersionIsEmpty() {
+ assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2"));
+ }
+
+ @Test
+ void usesSamplerHttpVersionWhenSpecified() {
+ assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/1.1", "HTTP/2"));
+ assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1"));
+ }
+
+ @Test
+ void defaultsToHttp11ForUnsupportedHttpVersion() {
+ assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/3", "HTTP/2"));
+ }
+
+ @Test
+ void doesNotRequireProtocolUpgradeConfiguration() throws Exception {
+ try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) {
+ assertFalse(new String(classFile.readAllBytes(), StandardCharsets.ISO_8859_1)
+ .contains("setProtocolUpgradeEnabled"));
+ }
+ }
+
+ @Test
+ void doesNotRequireHttpAsyncClassicAdapter() throws Exception {
+ try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) {
+ assertFalse(hasMethodReference(classFile.readAllBytes(),
+ "org/apache/hc/client5/http/impl/async/HttpAsyncClients", "classic"));
+ }
+ }
+
+ @Test
+ void usesHttp2WhenSelected() throws Exception {
+ WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig()
+ .dynamicHttpsPort()
+ .http2TlsDisabled(false));
+ try {
+ server.start();
+ server.stubFor(get(urlEqualTo("/http2")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL("https://localhost:" + server.httpsPort() + "/http2"), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length()));
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void usesHttp2WithProxy() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2proxy")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+ sampler.setProxyHost("localhost");
+ sampler.setProxyPortInt(Integer.toString(server.port()));
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2proxy")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void usesHttp11WhenSelected() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http11")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http11")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertEquals("HTTP/1.1", result.getResponseHeaders().substring(0, "HTTP/1.1".length()));
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void sendsConditionalRequestForCachedResource() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/cache"))
+ .willReturn(aResponse().withHeader("ETag", "cache-tag").withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setCacheManager(new CacheManager());
+ URL url = new URL(server.url("/cache"));
+
+ assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode());
+ assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode());
+
+ server.verify(1, getRequestedFor(urlEqualTo("/cache"))
+ .withHeader("If-None-Match", WireMock.equalTo("cache-tag")));
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void sendsBasicCredentialsFromAuthorizationManager() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/auth"))
+ .withHeader("Authorization", WireMock.equalTo("Basic dXNlcjpwYXNz"))
+ .willReturn(aResponse().withStatus(200)));
+ server.stubFor(get(urlEqualTo("/auth")).atPriority(10)
+ .willReturn(aResponse().withHeader("WWW-Authenticate", "Basic realm=\"test\"").withStatus(401)));
+ AuthManager authManager = new AuthManager();
+ authManager.set(-1, server.url("/"), "user", "pass", "", "", AuthManager.Mechanism.BASIC);
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setAuthManager(authManager);
+
+ assertEquals("200", sampler.sample(new URL(server.url("/auth")), HTTPConstants.GET, false, 1).getResponseCode());
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void authenticatesWithConfiguredProxyCredentials() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/proxy"))
+ .withHeader("Proxy-Authorization", WireMock.equalTo("Basic dXNlcjpwYXNz"))
+ .willReturn(aResponse().withStatus(200)));
+ server.stubFor(get(urlEqualTo("/proxy")).atPriority(10)
+ .willReturn(aResponse().withHeader("Proxy-Authenticate", "Basic realm=\"proxy\"").withStatus(407)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setProxyHost("localhost");
+ sampler.setProxyPortInt(Integer.toString(server.port()));
+ sampler.setProxyUser("user");
+ sampler.setProxyPass("pass");
+
+ assertEquals("200", sampler.sample(new URL(server.url("/proxy")), HTTPConstants.GET, false, 1).getResponseCode());
+ } finally {
+ server.stop();
+ }
+ }
+
+ private static HTTPSamplerBase newSampler() {
+ return HTTPSamplerFactory.newInstance("HttpClient5");
+ }
+
+ private static WireMockServer createServer() {
+ return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ }
+
+ private static boolean hasMethodReference(byte[] classBytes, String className, String methodName) throws IOException {
+ try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(classBytes))) {
+ input.readInt();
+ input.readUnsignedShort();
+ input.readUnsignedShort();
+ int constantPoolCount = input.readUnsignedShort();
+ int[] tags = new int[constantPoolCount];
+ int[] firstReferences = new int[constantPoolCount];
+ int[] secondReferences = new int[constantPoolCount];
+ String[] utf8Values = new String[constantPoolCount];
+ int i = 1;
+ while (i < constantPoolCount) {
+ tags[i] = input.readUnsignedByte();
+ switch (tags[i]) {
+ case 1:
+ utf8Values[i] = input.readUTF();
+ break;
+ case 3:
+ case 4:
+ input.readInt();
+ break;
+ case 5:
+ case 6:
+ input.readLong();
+ i++;
+ break;
+ case 7:
+ case 8:
+ case 16:
+ case 19:
+ case 20:
+ firstReferences[i] = input.readUnsignedShort();
+ break;
+ case 9:
+ case 10:
+ case 11:
+ case 12:
+ case 17:
+ case 18:
+ firstReferences[i] = input.readUnsignedShort();
+ secondReferences[i] = input.readUnsignedShort();
+ break;
+ case 15:
+ input.readUnsignedByte();
+ firstReferences[i] = input.readUnsignedShort();
+ break;
+ default:
+ throw new IOException("Unknown class-file constant-pool tag " + tags[i]);
+ }
+ i++;
+ }
+ for (int methodReferenceIndex = 1; methodReferenceIndex < constantPoolCount; methodReferenceIndex++) {
+ if (tags[methodReferenceIndex] != 10) {
+ continue;
+ }
+ int classIndex = firstReferences[methodReferenceIndex];
+ int nameAndTypeIndex = secondReferences[methodReferenceIndex];
+ String referencedClass = utf8Values[firstReferences[classIndex]];
+ String referencedMethod = utf8Values[firstReferences[nameAndTypeIndex]];
+ if (className.equals(referencedClass) && methodName.equals(referencedMethod)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
From 6b6d09d1dfb72d1eb8e07512748ac39d600442d8 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Thu, 23 Jul 2026 17:06:48 +0200
Subject: [PATCH 09/19] Add HTTP/2 support to `HTTPJavaImpl` with configurable
version handling, caching, proxies, and user authentication
---
bin/jmeter.properties | 2 +-
.../protocol/http/control/CacheManager.java | 22 +
.../protocol/http/sampler/HTTPJavaImpl.java | 422 ++++++++++++++++++
.../http/sampler/TestHTTPJavaFeatures.java | 100 +++++
xdocs/usermanual/component_reference.xml | 4 +-
xdocs/usermanual/properties_reference.xml | 2 +-
6 files changed, 548 insertions(+), 4 deletions(-)
create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
diff --git a/bin/jmeter.properties b/bin/jmeter.properties
index 91c948e429c..8820915bb25 100644
--- a/bin/jmeter.properties
+++ b/bin/jmeter.properties
@@ -378,7 +378,7 @@ remote_hosts=127.0.0.1
#httpclient.timeout=0
# 0 == no timeout
-# Set the default HTTP version for HttpClient5 samplers when HTTP Version is empty
+# Set the default HTTP version for HttpClient5 and Java samplers when HTTP Version is empty
# Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1)
#httpclient.version=HTTP/1.1
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java
index 9e8a2b5ce55..32509ab81f8 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/CacheManager.java
@@ -267,6 +267,28 @@ public void saveDetails(org.apache.hc.core5.http.ClassicHttpResponse response, H
}
}
+ /**
+ * Save the Last-Modified, Etag, and Expires headers if the result is
+ * cacheable. Version for Java HttpClient implementation.
+ *
+ * @param response response to extract header information from
+ * @param res result to decide if result is cacheable
+ */
+ public void saveDetails(java.net.http.HttpResponse> response, HTTPSampleResult res) {
+ final String varyHeader = response.headers().firstValue(HTTPConstants.VARY).orElse(null);
+ if (isCacheable(res, varyHeader)) {
+ String lastModified = response.headers().firstValue(HTTPConstants.LAST_MODIFIED).orElse(null);
+ String expires = response.headers().firstValue(HTTPConstants.EXPIRES).orElse(null);
+ String etag = response.headers().firstValue(HTTPConstants.ETAG).orElse(null);
+ String cacheControl = response.headers().firstValue(HTTPConstants.CACHE_CONTROL).orElse(null);
+ String date = response.headers().firstValue(HTTPConstants.DATE).orElse(null);
+ if (anyNotBlank(lastModified, expires, etag, cacheControl)) {
+ setCache(lastModified, cacheControl, expires, etag,
+ res.getUrlAsString(), date, getVaryHeader(varyHeader, asHeaders(res.getRequestHeaders())));
+ }
+ }
+ }
+
private static String getHeader(org.apache.hc.core5.http.HttpMessage message, String name) {
org.apache.hc.core5.http.Header header = message.getFirstHeader(name);
return header == null ? null : header.getValue();
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
index 527ed485aad..54e13a06d5b 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
@@ -17,22 +17,36 @@
package org.apache.jmeter.protocol.http.sampler;
+import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
+import java.net.Authenticator;
import java.net.BindException;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
+import java.net.PasswordAuthentication;
import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.URI;
import java.net.URL;
import java.net.URLConnection;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.function.Predicate;
import java.util.zip.GZIPInputStream;
+import javax.net.ssl.SSLContext;
+
import org.apache.jmeter.protocol.http.control.AuthManager;
import org.apache.jmeter.protocol.http.control.Authorization;
import org.apache.jmeter.protocol.http.control.CacheManager;
@@ -44,6 +58,7 @@
import org.apache.jmeter.testelement.property.CollectionProperty;
import org.apache.jmeter.testelement.property.JMeterProperty;
import org.apache.jmeter.util.JMeterUtils;
+import org.apache.jmeter.util.JsseSSLManager;
import org.apache.jmeter.util.SSLManager;
import org.apache.jorphan.io.CountingInputStream;
import org.apache.jorphan.util.StringUtilities;
@@ -59,8 +74,23 @@ public class HTTPJavaImpl extends HTTPAbstractImpl {
private static final boolean OBEY_CONTENT_LENGTH =
JMeterUtils.getPropDefault("httpsampler.obey_contentlength", false); // $NON-NLS-1$
+ private static final String DEFAULT_HTTP_VERSION =
+ JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$
+
+ private static final ThreadLocal> HTTP_2_CLIENTS =
+ ThreadLocal.withInitial(HashMap::new);
+
private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class);
+ static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) {
+ String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion;
+ return "HTTP/2".equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion); // $NON-NLS-1$ $NON-NLS-2$
+ }
+
+ private boolean isHttp2() {
+ return isHttp2(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION);
+ }
+
private static final int MAX_CONN_RETRIES =
JMeterUtils.getPropDefault("http.java.sampler.retries" // $NON-NLS-1$
,0); // Maximum connection retries
@@ -501,6 +531,9 @@ private static Map setConnectionAuthorization(HttpURLConnection
*/
@Override
protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) {
+ if (isHttp2()) {
+ return sampleHttp2(url, method, areFollowingRedirect, frameDepth);
+ }
HttpURLConnection conn = null;
String urlStr = url.toString();
@@ -726,4 +759,393 @@ public boolean interrupt() {
}
return conn != null;
}
+
+ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowingRedirect, int frameDepth) {
+ if (log.isDebugEnabled()) {
+ log.debug("Start : sampleHttp2 {}, method {}, followingRedirect {}, depth {}",
+ url, method, areFollowingRedirect, frameDepth);
+ }
+
+ HTTPSampleResult res = new HTTPSampleResult();
+ configureSampleLabel(res, url);
+ res.setURL(url);
+ res.setHTTPMethod(method);
+
+ res.sampleStart();
+
+ final CacheManager cacheManager = getCacheManager();
+ if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) {
+ if (cacheManager.inCache(url, getHeaders(getHeaderManager()))) {
+ return updateSampleResultForResourceInCache(res);
+ }
+ }
+
+ try {
+ CapturingHttpURLConnection capturingConn = new CapturingHttpURLConnection(url, method);
+
+ setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager());
+ String cookies = setConnectionCookie(capturingConn, url, getCookieManager());
+ Map securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager());
+
+ byte[] requestBodyBytes = new byte[0];
+ if (method.equals(HTTPConstants.POST)) {
+ setPostHeaders(capturingConn);
+ String postBody = sendPostData(capturingConn);
+ res.setQueryString(postBody);
+ requestBodyBytes = capturingConn.getCapturedBytes();
+ } else if (method.equals(HTTPConstants.PUT)) {
+ setPutHeaders(capturingConn);
+ String putBody = sendPutData(capturingConn);
+ res.setQueryString(putBody);
+ requestBodyBytes = capturingConn.getCapturedBytes();
+ }
+
+ res.setRequestHeaders(getAllHeadersExceptCookie(capturingConn, securityHeaders));
+ if (StringUtilities.isNotEmpty(cookies)) {
+ res.setCookies(cookies);
+ } else {
+ res.setCookies(getOnlyCookieFromHeaders(capturingConn, securityHeaders));
+ }
+
+ URI uri = url.toURI();
+ HttpRequest.Builder reqBuilder = HttpRequest.newBuilder(uri);
+
+ if (method.equalsIgnoreCase(HTTPConstants.POST) || method.equalsIgnoreCase(HTTPConstants.PUT)
+ || method.equalsIgnoreCase(HTTPConstants.PATCH)) {
+ reqBuilder.method(method, HttpRequest.BodyPublishers.ofByteArray(requestBodyBytes));
+ } else if (method.equalsIgnoreCase(HTTPConstants.GET)) {
+ reqBuilder.GET();
+ } else if (method.equalsIgnoreCase(HTTPConstants.DELETE)) {
+ reqBuilder.DELETE();
+ } else {
+ reqBuilder.method(method, HttpRequest.BodyPublishers.noBody());
+ }
+
+ int rto = getResponseTimeout();
+ if (rto > 0) {
+ reqBuilder.timeout(Duration.ofMillis(rto));
+ }
+
+ Map> props = capturingConn.getRequestProperties();
+ for (Map.Entry> entry : props.entrySet()) {
+ String headerName = entry.getKey();
+ if (headerName == null || isRestrictedHeader(headerName)) {
+ continue;
+ }
+ for (String value : entry.getValue()) {
+ reqBuilder.header(headerName, value);
+ }
+ }
+
+ HttpClient client = getHttpClient(url);
+ HttpRequest httpRequest = reqBuilder.build();
+
+ HttpResponse response = client.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
+ res.latencyEnd();
+
+ byte[] responseData = readResponse(response, res);
+
+ res.sampleEnd();
+
+ res.setResponseData(responseData);
+
+ int statusCode = response.statusCode();
+ res.setResponseCode(Integer.toString(statusCode));
+ res.setSuccessful(isSuccessCode(statusCode));
+ res.setResponseMessage(""); // $NON-NLS-1$
+
+ String responseHeaders = getResponseHeaders(response);
+ res.setResponseHeaders(responseHeaders);
+
+ String ct = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_TYPE).orElse(null);
+ if (ct != null) {
+ res.setContentType(ct);
+ res.setEncodingAndType(ct);
+ }
+
+ if (res.isRedirect()) {
+ String location = response.headers().firstValue(HTTPConstants.HEADER_LOCATION).orElse(null);
+ if (location != null) {
+ res.setRedirectLocation(location);
+ }
+ }
+
+ res.setHeadersSize(
+ responseHeaders.length()
+ + StringUtilities.count(responseHeaders, '\n')
+ + 2);
+
+ if (getAutoRedirects()) {
+ res.setURL(response.uri().toURL());
+ }
+
+ saveConnectionCookies(response, url, getCookieManager());
+
+ if (cacheManager != null) {
+ cacheManager.saveDetails(response, res);
+ }
+
+ res = resultProcessing(areFollowingRedirect, frameDepth, res);
+
+ log.debug("End : sampleHttp2");
+ return res;
+ } catch (Exception e) {
+ if (res.getEndTime() == 0) {
+ res.sampleEnd();
+ }
+ return errorResult(e, res);
+ }
+ }
+
+ private byte[] readResponse(HttpResponse response, SampleResult res) throws IOException {
+ InputStream in = response.body();
+ if (in == null) {
+ return NULL_BA;
+ }
+
+ boolean gzipped = response.headers().firstValue(HTTPConstants.HEADER_CONTENT_ENCODING)
+ .map(HTTPConstants.ENCODING_GZIP::equalsIgnoreCase)
+ .orElse(false);
+
+ long contentLength = response.headers().firstValueAsLong(HTTPConstants.HEADER_CONTENT_LENGTH).orElse(-1L);
+
+ if (contentLength == 0 && OBEY_CONTENT_LENGTH) {
+ log.info("Content-Length: 0, not reading http-body");
+ res.setResponseHeaders(getResponseHeaders(response));
+ res.latencyEnd();
+ return NULL_BA;
+ }
+
+ CountingInputStream instream = new CountingInputStream(in);
+ InputStream stream = gzipped ? new GZIPInputStream(instream) : instream;
+
+ try {
+ byte[] responseData = readResponse(res, stream, contentLength);
+ res.setBodySize(instream.getBytesRead());
+ return responseData;
+ } finally {
+ instream.close();
+ }
+ }
+
+ private static String getResponseHeaders(HttpResponse> response) {
+ StringBuilder headerBuf = new StringBuilder();
+ String versionStr = (response.version() == HttpClient.Version.HTTP_2) ? "HTTP/2" : "HTTP/1.1"; // $NON-NLS-1$ $NON-NLS-2$
+ headerBuf.append(versionStr).append(" ").append(response.statusCode()).append("\n"); // $NON-NLS-1$ $NON-NLS-2$
+
+ response.headers().map().forEach((key, values) -> {
+ if (key != null) {
+ for (String val : values) {
+ headerBuf.append(key).append(": ").append(val).append("\n"); // $NON-NLS-1$ $NON-NLS-2$
+ }
+ }
+ });
+ return headerBuf.toString();
+ }
+
+ private static void saveConnectionCookies(HttpResponse> response, URL u, CookieManager cookieManager) {
+ if (cookieManager != null) {
+ List setCookies = response.headers().allValues(HTTPConstants.HEADER_SET_COOKIE);
+ for (String setCookie : setCookies) {
+ cookieManager.addCookieFromHeader(setCookie, u);
+ }
+ }
+ }
+
+ private static boolean isRestrictedHeader(String name) {
+ return HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(name)
+ || HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(name)
+ || "Host".equalsIgnoreCase(name) // $NON-NLS-1$
+ || "Expect".equalsIgnoreCase(name) // $NON-NLS-1$
+ || "Upgrade".equalsIgnoreCase(name); // $NON-NLS-1$
+ }
+
+ private HttpClient getHttpClient(URL url) {
+ int connectTimeout = getConnectTimeout();
+ String proxyHost = getProxyHost();
+ int proxyPort = getProxyPortInt();
+ String proxyUser = getProxyUser();
+ String proxyPass = getProxyPass();
+ boolean autoRedirects = getAutoRedirects();
+ SSLContext sslContext = null;
+
+ if (HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(url.getProtocol())) {
+ try {
+ SSLManager sslmgr = SSLManager.getInstance();
+ if (sslmgr instanceof JsseSSLManager jsseSSLManager) {
+ sslContext = jsseSSLManager.getContext();
+ }
+ } catch (Exception e) {
+ log.warn("Problem getting SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$
+ }
+ }
+
+ HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort,
+ proxyUser, proxyPass, autoRedirects, sslContext);
+
+ return HTTP_2_CLIENTS.get().computeIfAbsent(key, HTTPJavaImpl::createHttpClient);
+ }
+
+ private static HttpClient createHttpClient(HttpClientKey key) {
+ HttpClient.Builder builder = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_2)
+ .followRedirects(key.autoRedirects ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER);
+
+ if (key.connectTimeout > 0) {
+ builder.connectTimeout(Duration.ofMillis(key.connectTimeout));
+ }
+
+ if (StringUtilities.isNotEmpty(key.proxyHost) && key.proxyPort > 0) {
+ builder.proxy(ProxySelector.of(new InetSocketAddress(key.proxyHost, key.proxyPort)));
+ if (StringUtilities.isNotEmpty(key.proxyUser)) {
+ builder.authenticator(new Authenticator() {
+ @Override
+ protected PasswordAuthentication getPasswordAuthentication() {
+ if (getRequestorType() == RequestorType.PROXY) {
+ return new PasswordAuthentication(key.proxyUser,
+ key.proxyPass != null ? key.proxyPass.toCharArray() : new char[0]);
+ }
+ return super.getPasswordAuthentication();
+ }
+ });
+ }
+ }
+
+ if (key.sslContext != null) {
+ builder.sslContext(key.sslContext);
+ }
+
+ return builder.build();
+ }
+
+ private static class HttpClientKey {
+ private final int connectTimeout;
+ private final String proxyHost;
+ private final int proxyPort;
+ private final String proxyUser;
+ private final String proxyPass;
+ private final boolean autoRedirects;
+ private final SSLContext sslContext;
+
+ HttpClientKey(int connectTimeout, String proxyHost, int proxyPort,
+ String proxyUser, String proxyPass, boolean autoRedirects,
+ SSLContext sslContext) {
+ this.connectTimeout = connectTimeout;
+ this.proxyHost = proxyHost;
+ this.proxyPort = proxyPort;
+ this.proxyUser = proxyUser;
+ this.proxyPass = proxyPass;
+ this.autoRedirects = autoRedirects;
+ this.sslContext = sslContext;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof HttpClientKey that)) {
+ return false;
+ }
+ return connectTimeout == that.connectTimeout
+ && proxyPort == that.proxyPort
+ && autoRedirects == that.autoRedirects
+ && Objects.equals(proxyHost, that.proxyHost)
+ && Objects.equals(proxyUser, that.proxyUser)
+ && Objects.equals(proxyPass, that.proxyPass)
+ && Objects.equals(sslContext, that.sslContext);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, sslContext);
+ }
+ }
+
+ private static class CapturingHttpURLConnection extends HttpURLConnection {
+ private final Map> requestProperties = new LinkedHashMap<>();
+ private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+ CapturingHttpURLConnection(URL url, String method) {
+ super(url);
+ this.method = method;
+ }
+
+ @Override
+ public void setRequestProperty(String key, String value) {
+ if (key == null) {
+ return;
+ }
+ List list = new ArrayList<>();
+ list.add(value);
+ requestProperties.put(key, list);
+ }
+
+ @Override
+ public void addRequestProperty(String key, String value) {
+ if (key == null) {
+ return;
+ }
+ requestProperties.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
+ }
+
+ @Override
+ public String getRequestProperty(String key) {
+ if (key == null) {
+ return null;
+ }
+ List values = requestProperties.get(key);
+ if (values == null || values.isEmpty()) {
+ for (Map.Entry> entry : requestProperties.entrySet()) {
+ if (key.equalsIgnoreCase(entry.getKey())) {
+ values = entry.getValue();
+ break;
+ }
+ }
+ }
+ return (values != null && !values.isEmpty()) ? values.get(0) : null;
+ }
+
+ @Override
+ public Map> getRequestProperties() {
+ return Collections.unmodifiableMap(requestProperties);
+ }
+
+ @Override
+ public java.io.OutputStream getOutputStream() throws IOException {
+ return outputStream;
+ }
+
+ byte[] getCapturedBytes() {
+ return outputStream.toByteArray();
+ }
+
+ @Override
+ public void connect() throws IOException {
+ }
+
+ @Override
+ public void disconnect() {
+ }
+
+ @Override
+ public boolean usingProxy() {
+ return false;
+ }
+
+ @Override
+ public String getHeaderField(int n) {
+ return null;
+ }
+
+ @Override
+ public String getHeaderFieldKey(int n) {
+ return null;
+ }
+
+ @Override
+ public String getHeaderField(String name) {
+ return null;
+ }
+ }
}
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
new file mode 100644
index 00000000000..d3646c86bea
--- /dev/null
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.jmeter.protocol.http.sampler;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URL;
+
+import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.junit.jupiter.api.Test;
+
+import com.github.tomakehurst.wiremock.WireMockServer;
+import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
+
+class TestHTTPJavaFeatures {
+
+ @Test
+ void usesHttpClientVersionWhenSamplerVersionIsEmpty() {
+ assertTrue(HTTPJavaImpl.isHttp2("", "HTTP/2"));
+ assertFalse(HTTPJavaImpl.isHttp2("", "HTTP/1.1"));
+ }
+
+ @Test
+ void usesSamplerHttpVersionWhenSpecified() {
+ assertFalse(HTTPJavaImpl.isHttp2("HTTP/1.1", "HTTP/2"));
+ assertTrue(HTTPJavaImpl.isHttp2("HTTP/2", "HTTP/1.1"));
+ }
+
+ @Test
+ void defaultsToHttp11ForUnsupportedHttpVersion() {
+ assertFalse(HTTPJavaImpl.isHttp2("HTTP/3", "HTTP/2"));
+ }
+
+ @Test
+ void usesHttp2WhenSelected() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertEquals("HTTP/2", result.getResponseHeaders().substring(0, "HTTP/2".length()));
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void usesHttp2WithProxy() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2proxy")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+ sampler.setProxyHost("localhost");
+ sampler.setProxyPortInt(Integer.toString(server.port()));
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2proxy")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ } finally {
+ server.stop();
+ }
+ }
+
+ private static HTTPSamplerBase newSampler() {
+ return HTTPSamplerFactory.newInstance("Java");
+ }
+
+ private static WireMockServer createServer() {
+ return new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort());
+ }
+}
diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml
index 92fd13c699b..a05c368e344 100644
--- a/xdocs/usermanual/component_reference.xml
+++ b/xdocs/usermanual/component_reference.xml
@@ -245,8 +245,8 @@ https.default.protocol=SSLv3
Java, HttpClient4, HttpClient5.
If not specified (and not defined by HTTP Request Defaults), the default depends on the value of the JMeter property
jmeter.httpsampler, failing that, the HttpClient4 implementation is used.
- HTTP/1.1 or HTTP/2. Applies only to the
- HttpClient5 implementation. An empty value uses the httpclient.version property, which
+ HTTP/1.1 or HTTP/2. Applies to the
+ HttpClient5 and Java implementations. An empty value uses the httpclient.version property, which
defaults to HTTP/1.1.HTTP, HTTPS or FILE. Default: HTTPGET, POST, HEAD, TRACE,
diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml
index 1a584be6f1d..721a41d46bf 100644
--- a/xdocs/usermanual/properties_reference.xml
+++ b/xdocs/usermanual/properties_reference.xml
@@ -442,7 +442,7 @@ JMETER-SERVER
Defaults to: 0
- Set the default HTTP version for HttpClient5 samplers with an empty HTTP Version value.
+ Set the default HTTP version for HttpClient5 and Java samplers with an empty HTTP Version value.
Valid values are HTTP/1.1 and HTTP/2.
Defaults to: HTTP/1.1
From 3e86a0c8ea7a24a90b0370c41136c0fc1b7dcd20 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Fri, 24 Jul 2026 07:27:07 +0200
Subject: [PATCH 10/19] Add `httpVersion` to ignored properties in `JMeterTest`
---
.../src/test/java/org/apache/jmeter/junit/JMeterTest.java | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java b/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java
index 2aca34f3e1e..0aff1b1a678 100644
--- a/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java
+++ b/src/dist-check/src/test/java/org/apache/jmeter/junit/JMeterTest.java
@@ -385,6 +385,7 @@ public void GUIComponents2(GuiComponentHolder componentHolder) throws Exception
// TODO: support expressions?
IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getIpSourceType());
IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getImplementation());
+ IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getHttpVersion());
// TODO: support expressions in UrlConfigGui
IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getFollowRedirects());
IGNORED_PROPERTIES.add(HTTPSamplerBaseSchema.INSTANCE.getAutoRedirects());
From 947dae7f2043b0264a9f545055fd703c8086dab7 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Fri, 24 Jul 2026 12:18:37 +0200
Subject: [PATCH 11/19] Update `HTTPHC5Impl` to use
`HttpVersionPolicy.NEGOTIATE` for HTTP/2 and improve fallback handling with
new tests
---
.../protocol/http/sampler/HTTPHC5Impl.java | 8 +++---
.../http/sampler/TestHTTPHC5Features.java | 25 +++++++++++++++++--
2 files changed, 27 insertions(+), 6 deletions(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
index 458ea7f4151..30c1a97b55c 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -212,7 +212,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
currentRequest = request;
HttpClientKey clientKey = createHttpClientKey(url);
HttpClientContext context = createHttpClientContext(url, clientKey, request);
- response = clientKey.httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2
+ response = clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1
? executeHttp2(getHttp2Client(clientKey), request, context)
: getClient(clientKey).executeOpen(null, request, context);
result.sampleEnd();
@@ -263,7 +263,7 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht
if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) {
request.setHeader(HTTPConstants.HEADER_CONNECTION,
getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE);
- } else {
+ } else if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2) {
request.setVersion(HttpVersion.HTTP_2);
}
setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy);
@@ -377,7 +377,7 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth
org.apache.jmeter.protocol.http.control.Header header =
(org.apache.jmeter.protocol.http.control.Header) property.getObjectValue();
if (!HTTPConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(header.getName())
- && (httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_2
+ && (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1
|| !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(header.getName()))) {
request.addHeader(header.getName(), header.getValue());
}
@@ -646,7 +646,7 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException {
static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion) {
String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion;
return "HTTP/2".equals(httpVersion) || "2".equals(httpVersion)
- ? HttpVersionPolicy.FORCE_HTTP_2 : HttpVersionPolicy.FORCE_HTTP_1;
+ ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1;
}
@Override
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
index c69dc254a37..604bc240593 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
@@ -45,13 +45,13 @@ class TestHTTPHC5Features {
@Test
void usesHttpClientVersionWhenSamplerVersionIsEmpty() {
- assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2"));
+ assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2"));
}
@Test
void usesSamplerHttpVersionWhenSpecified() {
assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/1.1", "HTTP/2"));
- assertEquals(HttpVersionPolicy.FORCE_HTTP_2, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1"));
+ assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1"));
}
@Test
@@ -96,6 +96,27 @@ void usesHttp2WhenSelected() throws Exception {
}
}
+ @Test
+ void fallsBackToHttp11WhenServerDoesNotSupportHttp2() throws Exception {
+ WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig()
+ .dynamicHttpsPort()
+ .http2TlsDisabled(true));
+ try {
+ server.start();
+ server.stubFor(get(urlEqualTo("/fallback")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL("https://localhost:" + server.httpsPort() + "/fallback"), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertEquals("HTTP/1.1", result.getResponseHeaders().substring(0, "HTTP/1.1".length()));
+ } finally {
+ server.stop();
+ }
+ }
+
@Test
void usesHttp2WithProxy() throws Exception {
WireMockServer server = createServer();
From 12de7e992c05fc5cd09c443b62273d4343d3f0a5 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Fri, 24 Jul 2026 17:25:10 +0200
Subject: [PATCH 12/19] Update dependencies to replace `httpcore5-h2` with
`httpcore5` in `HTTPHC5Impl` and Gradle configurations
---
src/bom-thirdparty/build.gradle.kts | 2 +-
src/protocol/http/build.gradle.kts | 2 +-
.../org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts
index 7b9b81a0845..c31f64f9711 100644
--- a/src/bom-thirdparty/build.gradle.kts
+++ b/src/bom-thirdparty/build.gradle.kts
@@ -107,7 +107,7 @@ dependencies {
because("User might still rely on commons-text")
}
api("org.apache.httpcomponents.client5:httpclient5:5.5.1")
- api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4")
+ api("org.apache.httpcomponents.core5:httpcore5:5.3.4")
api("org.apache.httpcomponents:httpasyncclient:4.1.5")
api("org.apache.httpcomponents:httpclient:4.5.14")
api("org.apache.httpcomponents:httpcore-nio:4.4.16")
diff --git a/src/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts
index 5946f9280db..800054537e7 100644
--- a/src/protocol/http/build.gradle.kts
+++ b/src/protocol/http/build.gradle.kts
@@ -62,7 +62,7 @@ dependencies {
}
implementation("dnsjava:dnsjava")
implementation("org.apache.httpcomponents.client5:httpclient5")
- implementation("org.apache.httpcomponents.core5:httpcore5-h2")
+ implementation("org.apache.httpcomponents.core5:httpcore5")
implementation("org.apache.httpcomponents:httpmime")
implementation("org.apache.httpcomponents:httpcore")
implementation("org.brotli:dec")
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
index 30c1a97b55c..9bd94ea910d 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -43,7 +43,6 @@
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
-import org.apache.hc.client5.http.classic.ExecChain;
import org.apache.hc.client5.http.classic.ExecChainHandler;
import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
@@ -230,6 +229,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
}
if (request != null) {
result.setRequestHeaders(getRequestHeaders(request));
+ result.setSentBytes(calculateSentBytes(request));
}
return errorResult(e, result);
} finally {
From 6e74d4f20e1d99101d879be7cc36a0c294e9cee7 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Fri, 24 Jul 2026 17:41:23 +0200
Subject: [PATCH 13/19] Add `sentBytes` calculation to `HTTPHC5Impl` and
corresponding unit tests for GET and POST requests
---
.../protocol/http/sampler/HTTPHC5Impl.java | 77 +++++++++++++++++++
.../http/sampler/TestHTTPHC5Features.java | 42 ++++++++++
2 files changed, 119 insertions(+)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
index 9bd94ea910d..0f707f98342 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -108,12 +108,16 @@
import org.apache.jmeter.util.SSLManager;
import org.apache.jorphan.util.JOrphanUtils;
import org.apache.jorphan.util.StringUtilities;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* HTTP Sampler using Apache HttpClient 5.x.
*/
public class HTTPHC5Impl extends HTTPHCAbstractImpl {
+ private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class);
+
private static final ThreadLocal> HTTP_CLIENTS =
ThreadLocal.withInitial(HashMap::new);
@@ -435,6 +439,7 @@ private static int getPort(URL url) {
private void updateResult(ClassicHttpResponse response,
org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException {
result.setRequestHeaders(getRequestHeaders(request));
+ result.setSentBytes(calculateSentBytes(request));
Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE);
if (contentType != null) {
result.setContentType(contentType.getValue());
@@ -482,6 +487,78 @@ private static String getRequestHeaders(org.apache.hc.client5.http.classic.metho
return headers.toString();
}
+ private static long calculateSentBytes(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) {
+ if (request == null) {
+ return 0;
+ }
+ long sentBytes = 0;
+
+ String method = request.getMethod();
+ String uri = request.getRequestUri();
+ if (uri == null) {
+ uri = "";
+ }
+ org.apache.hc.core5.http.ProtocolVersion version = request.getVersion();
+ String versionStr = version != null ? version.toString() : "HTTP/1.1";
+
+ sentBytes += method.getBytes(Charset.defaultCharset()).length;
+ sentBytes += 1;
+ sentBytes += uri.getBytes(Charset.defaultCharset()).length;
+ sentBytes += 1;
+ sentBytes += versionStr.getBytes(Charset.defaultCharset()).length;
+ sentBytes += 2;
+
+ for (Header header : request.getHeaders()) {
+ String name = header.getName();
+ String value = header.getValue();
+ if (name != null) {
+ sentBytes += name.getBytes(Charset.defaultCharset()).length;
+ sentBytes += 2;
+ }
+ if (value != null) {
+ sentBytes += value.getBytes(Charset.defaultCharset()).length;
+ }
+ sentBytes += 2;
+ }
+ sentBytes += 2;
+
+ HttpEntity entity = request.getEntity();
+ if (entity != null) {
+ long contentLength = entity.getContentLength();
+ if (contentLength >= 0) {
+ sentBytes += contentLength;
+ } else if (entity.isRepeatable()) {
+ CountingOutputStream counter = new CountingOutputStream();
+ try {
+ entity.writeTo(counter);
+ sentBytes += counter.getCount();
+ } catch (IOException e) {
+ log.debug("Exception measuring entity length", e);
+ }
+ }
+ }
+
+ return sentBytes;
+ }
+
+ private static class CountingOutputStream extends java.io.OutputStream {
+ private long count = 0;
+
+ @Override
+ public void write(int b) {
+ count++;
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) {
+ count += len;
+ }
+
+ long getCount() {
+ return count;
+ }
+ }
+
private static String getOnlyCookieFromHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) {
Header cookie = request.getFirstHeader(HTTPConstants.HEADER_COOKIE);
return cookie == null ? "" : cookie.getValue();
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
index 604bc240593..dc8d509d130 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
@@ -20,9 +20,11 @@
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
@@ -156,6 +158,46 @@ void usesHttp11WhenSelected() throws Exception {
}
}
+ @Test
+ void setsSentBytesCorrectlyForGetRequest() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/sentBytesGet")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/sentBytesGet")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0");
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void setsSentBytesCorrectlyForPostRequest() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(post(urlEqualTo("/sentBytesPost")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+ sampler.setPostBodyRaw(true);
+ sampler.addNonEncodedArgument("", "hello world", "");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/sentBytesPost")), HTTPConstants.POST, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getSentBytes() > "hello world".length(), "sentBytes should include request line, headers, and body");
+ } finally {
+ server.stop();
+ }
+ }
+
@Test
void sendsConditionalRequestForCachedResource() throws Exception {
WireMockServer server = createServer();
From cdb95b9fd6d91a0362e7e1fa7263ab50c30f8d94 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Mon, 27 Jul 2026 13:32:01 +0200
Subject: [PATCH 14/19] Add `sentBytes` calculation to `HTTPJavaImpl` for GET
and POST requests, including HTTP/2, with corresponding unit tests
---
.../protocol/http/sampler/HTTPJavaImpl.java | 156 +++++++++++++++++-
.../http/sampler/TestHTTPJavaFeatures.java | 81 +++++++++
2 files changed, 234 insertions(+), 3 deletions(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
index 54e13a06d5b..65f33f3f220 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
@@ -34,6 +34,7 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
@@ -557,6 +558,10 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
}
}
+ Map> requestHeaders = null;
+ Map securityHeaders = Collections.emptyMap();
+ byte[] postBodyBytes = null;
+
try {
// Sampling proper - establish the connection and read the response:
// Repeatedly try to connect:
@@ -565,6 +570,8 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
for (; retry < MAX_CONN_RETRIES; retry++) {
try {
conn = setupConnection(url, method, res);
+ requestHeaders = new LinkedHashMap<>(conn.getRequestProperties());
+ securityHeaders = setConnectionAuthorization(conn, url, getAuthManager());
// Attempt the connection:
savedConn = conn;
conn.connect();
@@ -593,9 +600,15 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
if (method.equals(HTTPConstants.POST)) {
String postBody = sendPostData(conn);
res.setQueryString(postBody);
+ if (postBody != null) {
+ postBodyBytes = getBytes(postBody);
+ }
} else if (method.equals(HTTPConstants.PUT)) {
String putBody = sendPutData(conn);
res.setQueryString(putBody);
+ if (putBody != null) {
+ postBodyBytes = getBytes(putBody);
+ }
}
// Request sent. Now get the response:
byte[] responseData = readResponse(conn, res);
@@ -677,6 +690,8 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
cacheManager.saveDetails(conn, res);
}
+ res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyBytes));
+
res = resultProcessing(areFollowingRedirect, frameDepth, res);
log.debug("End : sample");
@@ -685,6 +700,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
if (res.getEndTime() == 0) {
res.sampleEnd();
}
+ res.setSentBytes(calculateSentBytes(url, method, testElement.getHttpVersion(), requestHeaders, securityHeaders, postBodyBytes));
savedConn = null; // we don't want interrupt to try disconnection again
// We don't want to continue using this connection, even if KeepAlive is set
if (conn != null) { // May not exist
@@ -780,14 +796,17 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin
}
}
+ CapturingHttpURLConnection capturingConn = null;
+ byte[] requestBodyBytes = new byte[0];
+ Map securityHeaders = Collections.emptyMap();
+
try {
- CapturingHttpURLConnection capturingConn = new CapturingHttpURLConnection(url, method);
+ capturingConn = new CapturingHttpURLConnection(url, method);
setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager());
String cookies = setConnectionCookie(capturingConn, url, getCookieManager());
- Map securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager());
+ securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager());
- byte[] requestBodyBytes = new byte[0];
if (method.equals(HTTPConstants.POST)) {
setPostHeaders(capturingConn);
String postBody = sendPostData(capturingConn);
@@ -885,6 +904,10 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin
cacheManager.saveDetails(response, res);
}
+ res.setSentBytes(calculateSentBytes(url, method, "HTTP/2",
+ capturingConn != null ? capturingConn.getRequestProperties() : null,
+ securityHeaders, requestBodyBytes));
+
res = resultProcessing(areFollowingRedirect, frameDepth, res);
log.debug("End : sampleHttp2");
@@ -893,6 +916,9 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin
if (res.getEndTime() == 0) {
res.sampleEnd();
}
+ res.setSentBytes(calculateSentBytes(url, method, "HTTP/2",
+ capturingConn != null ? capturingConn.getRequestProperties() : null,
+ securityHeaders, requestBodyBytes));
return errorResult(e, res);
}
}
@@ -960,6 +986,130 @@ private static boolean isRestrictedHeader(String name) {
|| "Upgrade".equalsIgnoreCase(name); // $NON-NLS-1$
}
+ private static long calculateSentBytes(
+ URL u,
+ String method,
+ String version,
+ Map> requestHeaders,
+ Map securityHeaders,
+ byte[] postBodyBytes) {
+ long sentBytes = 0;
+
+ if (StringUtilities.isBlank(method)) {
+ method = HTTPConstants.GET;
+ }
+
+ String uri = u != null ? u.getFile() : "";
+ if (StringUtilities.isBlank(uri)) {
+ uri = "/"; // $NON-NLS-1$
+ }
+
+ if (StringUtilities.isBlank(version)) {
+ version = HTTPConstants.HTTP_1_1;
+ }
+
+ // Request line: METHOD URI VERSION\r\n
+ sentBytes += method.getBytes(StandardCharsets.UTF_8).length;
+ sentBytes += 1;
+ sentBytes += uri.getBytes(StandardCharsets.UTF_8).length;
+ sentBytes += 1;
+ sentBytes += version.getBytes(StandardCharsets.UTF_8).length;
+ sentBytes += 2;
+
+ // Request headers
+ if (requestHeaders != null) {
+ for (Map.Entry> entry : requestHeaders.entrySet()) {
+ String key = entry.getKey();
+ if (key != null) {
+ List values = entry.getValue();
+ if (values != null) {
+ for (String val : values) {
+ sentBytes += key.getBytes(StandardCharsets.UTF_8).length;
+ sentBytes += 2; // ": "
+ if (val != null) {
+ sentBytes += val.getBytes(StandardCharsets.UTF_8).length;
+ }
+ sentBytes += 2; // "\r\n"
+ }
+ }
+ }
+ }
+ }
+
+ if (securityHeaders != null && !securityHeaders.isEmpty()) {
+ for (Map.Entry secEntry : securityHeaders.entrySet()) {
+ String secKey = secEntry.getKey();
+ String secVal = secEntry.getValue();
+ if (secKey != null && !hasHeader(requestHeaders, secKey)) {
+ sentBytes += secKey.getBytes(StandardCharsets.UTF_8).length;
+ sentBytes += 2; // ": "
+ if (secVal != null) {
+ sentBytes += secVal.getBytes(StandardCharsets.UTF_8).length;
+ }
+ sentBytes += 2; // "\r\n"
+ }
+ }
+ }
+
+ // Header/Body separator \r\n
+ sentBytes += 2;
+
+ // Request body
+ if (postBodyBytes != null && postBodyBytes.length > 0) {
+ sentBytes += postBodyBytes.length;
+ } else if (requestHeaders != null) {
+ String contentLengthStr = getHeaderValue(requestHeaders, HTTPConstants.HEADER_CONTENT_LENGTH);
+ if (StringUtilities.isNotEmpty(contentLengthStr)) {
+ try {
+ sentBytes += Long.parseLong(contentLengthStr);
+ } catch (NumberFormatException e) {
+ log.debug("Could not parse Content-Length header: {}", contentLengthStr, e);
+ }
+ }
+ }
+
+ return sentBytes;
+ }
+
+ private static boolean hasHeader(Map> headers, String headerName) {
+ if (headers == null || headerName == null) {
+ return false;
+ }
+ for (String key : headers.keySet()) {
+ if (headerName.equalsIgnoreCase(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String getHeaderValue(Map> headers, String headerName) {
+ if (headers == null || headerName == null) {
+ return null;
+ }
+ for (Map.Entry> entry : headers.entrySet()) {
+ if (headerName.equalsIgnoreCase(entry.getKey())) {
+ List values = entry.getValue();
+ if (values != null && !values.isEmpty()) {
+ return values.get(0);
+ }
+ }
+ }
+ return null;
+ }
+
+ private byte[] getBytes(String postBody) {
+ String enc = testElement != null ? testElement.getContentEncoding() : null;
+ if (StringUtilities.isBlank(enc)) {
+ enc = StandardCharsets.UTF_8.name();
+ }
+ try {
+ return postBody.getBytes(enc);
+ } catch (Exception e) {
+ return postBody.getBytes(StandardCharsets.UTF_8);
+ }
+ }
+
private HttpClient getHttpClient(URL url) {
int connectTimeout = getConnectTimeout();
String proxyHost = getProxyHost();
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
index d3646c86bea..3c6c90538b9 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
@@ -19,6 +19,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -90,6 +91,86 @@ void usesHttp2WithProxy() throws Exception {
}
}
+ @Test
+ void setsSentBytesCorrectlyForGetRequest() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/sentBytesGet")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/sentBytesGet")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0");
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void setsSentBytesCorrectlyForPostRequest() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(post(urlEqualTo("/sentBytesPost")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+ sampler.setPostBodyRaw(true);
+ sampler.addNonEncodedArgument("", "hello world", "");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/sentBytesPost")), HTTPConstants.POST, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getSentBytes() > "hello world".length(), "sentBytes should include request line, headers, and body");
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void setsSentBytesCorrectlyForHttp2GetRequest() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2SentBytesGet")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2SentBytesGet")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getSentBytes() > 0, "sentBytes should be greater than 0");
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void setsSentBytesCorrectlyForHttp2PostRequest() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(post(urlEqualTo("/http2SentBytesPost")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+ sampler.setPostBodyRaw(true);
+ sampler.addNonEncodedArgument("", "hello world http2", "");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2SentBytesPost")), HTTPConstants.POST, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getSentBytes() > "hello world http2".length(), "sentBytes should include request line, headers, and body");
+ } finally {
+ server.stop();
+ }
+ }
+
private static HTTPSamplerBase newSampler() {
return HTTPSamplerFactory.newInstance("Java");
}
From 72eccfefd8412ccd0644c88bcf52ba44c921f937 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Mon, 27 Jul 2026 15:19:55 +0200
Subject: [PATCH 15/19] Improve header handling in `HTTPJavaImpl` by preserving
security headers (`Authorization`, `Proxy-Authorization`) and adding unit
tests for validation
---
.../protocol/http/sampler/HTTPJavaImpl.java | 38 ++++++++++-----
.../http/sampler/TestHTTPJavaFeatures.java | 48 +++++++++++++++++++
2 files changed, 74 insertions(+), 12 deletions(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
index 65f33f3f220..8df60d5c68e 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
@@ -222,10 +222,10 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu
}
conn.setRequestMethod(method);
- setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager());
+ Map securityHeaders = setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager());
String cookies = setConnectionCookie(conn, u, getCookieManager());
- Map securityHeaders = setConnectionAuthorization(conn, u, getAuthManager());
+ setConnectionAuthorization(conn, u, getAuthManager(), securityHeaders);
if (method.equals(HTTPConstants.POST)) {
setPostHeaders(conn);
@@ -379,6 +379,11 @@ private static String setConnectionCookie(HttpURLConnection conn, URL u, CookieM
return cookieHeader;
}
+ private static boolean isSecurityHeader(String name) {
+ return name != null && (HTTPConstants.HEADER_AUTHORIZATION.equalsIgnoreCase(name)
+ || "Proxy-Authorization".equalsIgnoreCase(name));
+ }
+
/**
* Extracts all the required headers for that particular URL request and
* sets them in the HttpURLConnection passed in
@@ -392,11 +397,13 @@ private static String setConnectionCookie(HttpURLConnection conn, URL u, CookieM
* the HeaderManager containing all the cookies
* for this UrlConfig
* @param cacheManager the CacheManager (may be null)
+ * @return Map of security headers set from HeaderManager
*/
- private static void setConnectionHeaders(HttpURLConnection conn, URL u,
+ private static Map setConnectionHeaders(HttpURLConnection conn, URL u,
HeaderManager headerManager, CacheManager cacheManager) {
// Add all the headers from the HeaderManager
Header[] arrayOfHeaders = null;
+ Map securityHeaders = new LinkedHashMap<>();
if (headerManager != null) {
CollectionProperty headers = headerManager.getHeaders();
if (headers != null) {
@@ -408,12 +415,16 @@ private static void setConnectionHeaders(HttpURLConnection conn, URL u,
String v = header.getValue();
arrayOfHeaders[i++] = header;
conn.addRequestProperty(n, v);
+ if (isSecurityHeader(n)) {
+ securityHeaders.put(n, v);
+ }
}
}
}
if (cacheManager != null){
cacheManager.setHeaders(conn, arrayOfHeaders, u);
}
+ return securityHeaders;
}
/**
@@ -476,8 +487,10 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, Map entry : securityHeaders.entrySet()) {
- hdrs.append(entry.getKey()).append(": ") // $NON-NLS-1$
- .append(entry.getValue()).append("\n"); // $NON-NLS-1$
+ if (!hasHeader(requestHeaders, entry.getKey())) {
+ hdrs.append(entry.getKey()).append(": ") // $NON-NLS-1$
+ .append(entry.getValue()).append("\n"); // $NON-NLS-1$
+ }
}
}
return hdrs.toString();
@@ -495,9 +508,10 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, MapAuthManager containing all the cookies for
* this UrlConfig
- * @return String Authorization header value or null if not set
+ * @param securityHeaders
+ * Map to collect security headers
*/
- private static Map setConnectionAuthorization(HttpURLConnection conn, URL u, AuthManager authManager) {
+ private static void setConnectionAuthorization(HttpURLConnection conn, URL u, AuthManager authManager, Map securityHeaders) {
if (authManager != null) {
Authorization auth = authManager.getAuthForURL(u);
if (auth != null) {
@@ -505,10 +519,9 @@ private static Map setConnectionAuthorization(HttpURLConnection
conn.setRequestProperty(HTTPConstants.HEADER_AUTHORIZATION, headerValue);
// Java hides request properties so we have to
// keep trace of it
- return Collections.singletonMap(HTTPConstants.HEADER_AUTHORIZATION, headerValue);
+ securityHeaders.put(HTTPConstants.HEADER_AUTHORIZATION, headerValue);
}
}
- return Collections.emptyMap();
}
/**
@@ -571,7 +584,8 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
try {
conn = setupConnection(url, method, res);
requestHeaders = new LinkedHashMap<>(conn.getRequestProperties());
- securityHeaders = setConnectionAuthorization(conn, url, getAuthManager());
+ securityHeaders = setConnectionHeaders(conn, url, getHeaderManager(), getCacheManager());
+ setConnectionAuthorization(conn, url, getAuthManager(), securityHeaders);
// Attempt the connection:
savedConn = conn;
conn.connect();
@@ -803,9 +817,9 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin
try {
capturingConn = new CapturingHttpURLConnection(url, method);
- setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager());
+ securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager());
String cookies = setConnectionCookie(capturingConn, url, getCookieManager());
- securityHeaders = setConnectionAuthorization(capturingConn, url, getAuthManager());
+ setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders);
if (method.equals(HTTPConstants.POST)) {
setPostHeaders(capturingConn);
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
index 3c6c90538b9..e99d7c7c9b6 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
@@ -27,6 +27,8 @@
import java.net.URL;
+import org.apache.jmeter.protocol.http.control.Header;
+import org.apache.jmeter.protocol.http.control.HeaderManager;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
import org.junit.jupiter.api.Test;
@@ -171,6 +173,52 @@ void setsSentBytesCorrectlyForHttp2PostRequest() throws Exception {
}
}
+ @Test
+ void displaysAuthorizationHeaderFromHeaderManagerInHttp11() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/authHttp11")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+ HeaderManager headerManager = new HeaderManager();
+ headerManager.add(new Header("Authorization", "Bearer my-secret-token"));
+ sampler.setHeaderManager(headerManager);
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/authHttp11")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getRequestHeaders().contains("Authorization: Bearer my-secret-token"),
+ "Request headers should contain Authorization header set in HeaderManager");
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void displaysAuthorizationHeaderFromHeaderManagerInHttp2() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/authHttp2")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+ HeaderManager headerManager = new HeaderManager();
+ headerManager.add(new Header("Authorization", "Bearer my-secret-token-http2"));
+ sampler.setHeaderManager(headerManager);
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/authHttp2")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getRequestHeaders().contains("Authorization: Bearer my-secret-token-http2"),
+ "Request headers should contain Authorization header set in HeaderManager");
+ } finally {
+ server.stop();
+ }
+ }
+
private static HTTPSamplerBase newSampler() {
return HTTPSamplerFactory.newInstance("Java");
}
From 546b2a07d72de2196317fcdd51fcc81cbfa9fa1f Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Mon, 27 Jul 2026 16:41:49 +0200
Subject: [PATCH 16/19] Add connect time measurement to `HTTPJavaImpl` and
`HTTPHC5Impl` with unit tests
---
.../protocol/http/sampler/HTTPHC5Impl.java | 151 +++++++++++++++++-
.../protocol/http/sampler/HTTPJavaImpl.java | 1 +
.../http/sampler/TestHTTPHC5Features.java | 98 ++++++++++++
.../http/sampler/TestHTTPJavaFeatures.java | 22 +++
4 files changed, 270 insertions(+), 2 deletions(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
index 0f707f98342..afcb4642ba9 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java
@@ -39,6 +39,7 @@
import javax.net.ssl.SSLContext;
+import org.apache.hc.client5.http.HttpRoute;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.auth.AuthScope;
@@ -63,10 +64,16 @@
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
import org.apache.hc.client5.http.impl.routing.DefaultRoutePlanner;
+import org.apache.hc.client5.http.io.ConnectionEndpoint;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.io.LeaseRequest;
+import org.apache.hc.client5.http.nio.AsyncClientConnectionManager;
+import org.apache.hc.client5.http.nio.AsyncConnectionEndpoint;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder;
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
import org.apache.hc.client5.http.ssl.TrustAllStrategy;
+import org.apache.hc.core5.concurrent.FutureCallback;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
@@ -87,8 +94,12 @@
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.http.message.ParserCursor;
import org.apache.hc.core5.http.nio.ssl.TlsStrategy;
+import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.reactor.ConnectionInitiator;
import org.apache.hc.core5.ssl.SSLContexts;
+import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.apache.jmeter.protocol.http.control.AuthManager;
import org.apache.jmeter.protocol.http.control.Authorization;
@@ -99,6 +110,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.samplers.SampleResult;
import org.apache.jmeter.services.FileServer;
import org.apache.jmeter.testelement.property.CollectionProperty;
import org.apache.jmeter.testelement.property.JMeterProperty;
@@ -118,6 +130,9 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl {
private static final Logger log = LoggerFactory.getLogger(HTTPHC5Impl.class);
+ /** Key used to store the current {@link SampleResult} in the {@link HttpClientContext}. */
+ static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$
+
private static final ThreadLocal> HTTP_CLIENTS =
ThreadLocal.withInitial(HashMap::new);
@@ -215,6 +230,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
currentRequest = request;
HttpClientKey clientKey = createHttpClientKey(url);
HttpClientContext context = createHttpClientContext(url, clientKey, request);
+ context.setAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result);
response = clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1
? executeHttp2(getHttp2Client(clientKey), request, context)
: getClient(clientKey).executeOpen(null, request, context);
@@ -598,7 +614,7 @@ private static CloseableHttpClient createClient(HttpClientKey key) {
.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout))
.build());
}
- builder.setConnectionManager(connectionManagerBuilder.build());
+ builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build()));
builder.setRoutePlanner(createRoutePlanner(key));
return builder.disableContentCompression()
.addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING)
@@ -622,7 +638,7 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) {
.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout))
.build());
}
- builder.setConnectionManager(connectionManagerBuilder.build());
+ builder.setConnectionManager(new ConnectTimeMeasuringAsyncConnectionManager(connectionManagerBuilder.build()));
CloseableHttpAsyncClient asyncClient = builder.build();
asyncClient.start();
return asyncClient;
@@ -769,6 +785,137 @@ public boolean interrupt() {
return request != null;
}
+ private static void recordConnectEnd(HttpContext context) {
+ SampleResult sample = (SampleResult) context.getAttribute(CONTEXT_ATTRIBUTE_SAMPLER_RESULT);
+ if (sample != null) {
+ sample.connectEnd();
+ }
+ }
+
+ /**
+ * Delegating connection manager that records the connect time of the current sample
+ * as soon as the connection to the first hop has been established.
+ */
+ static final class ConnectTimeMeasuringConnectionManager implements HttpClientConnectionManager {
+
+ private final HttpClientConnectionManager delegate;
+
+ ConnectTimeMeasuringConnectionManager(HttpClientConnectionManager delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) {
+ return delegate.lease(id, route, requestTimeout, state);
+ }
+
+ @Override
+ public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) {
+ delegate.release(endpoint, newState, validDuration);
+ }
+
+ @Override
+ public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context) throws IOException {
+ try {
+ delegate.connect(endpoint, connectTimeout, context);
+ } finally {
+ recordConnectEnd(context);
+ }
+ }
+
+ @Override
+ public void upgrade(ConnectionEndpoint endpoint, HttpContext context) throws IOException {
+ delegate.upgrade(endpoint, context);
+ }
+
+ @Override
+ public void close(CloseMode closeMode) {
+ delegate.close(closeMode);
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+ }
+
+ /**
+ * Delegating asynchronous connection manager that records the connect time of the current
+ * sample as soon as the connection to the first hop has been established.
+ */
+ private static final class ConnectTimeMeasuringAsyncConnectionManager implements AsyncClientConnectionManager {
+
+ private final AsyncClientConnectionManager delegate;
+
+ private ConnectTimeMeasuringAsyncConnectionManager(AsyncClientConnectionManager delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Future lease(String id, HttpRoute route, Object state, Timeout requestTimeout,
+ FutureCallback callback) {
+ return delegate.lease(id, route, state, requestTimeout, callback);
+ }
+
+ @Override
+ public void release(AsyncConnectionEndpoint endpoint, Object newState, TimeValue validDuration) {
+ delegate.release(endpoint, newState, validDuration);
+ }
+
+ @Override
+ public Future connect(AsyncConnectionEndpoint endpoint,
+ ConnectionInitiator connectionInitiator, Timeout connectTimeout, Object attachment,
+ HttpContext context, FutureCallback callback) {
+ return delegate.connect(endpoint, connectionInitiator, connectTimeout, attachment, context,
+ new FutureCallback<>() {
+ @Override
+ public void completed(AsyncConnectionEndpoint result) {
+ recordConnectEnd(context);
+ if (callback != null) {
+ callback.completed(result);
+ }
+ }
+
+ @Override
+ public void failed(Exception ex) {
+ recordConnectEnd(context);
+ if (callback != null) {
+ callback.failed(ex);
+ }
+ }
+
+ @Override
+ public void cancelled() {
+ recordConnectEnd(context);
+ if (callback != null) {
+ callback.cancelled();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void upgrade(AsyncConnectionEndpoint endpoint, Object attachment, HttpContext context) {
+ delegate.upgrade(endpoint, attachment, context);
+ }
+
+ @Override
+ public void upgrade(AsyncConnectionEndpoint endpoint, Object attachment, HttpContext context,
+ FutureCallback callback) {
+ delegate.upgrade(endpoint, attachment, context, callback);
+ }
+
+ @Override
+ public void close(CloseMode closeMode) {
+ delegate.close(closeMode);
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+ }
+
private static final class HttpClientKey {
private final String protocol;
private final String authority;
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
index 8df60d5c68e..411f015b005 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
@@ -589,6 +589,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe
// Attempt the connection:
savedConn = conn;
conn.connect();
+ res.connectEnd();
break;
} catch (BindException e) {
if (retry >= MAX_CONN_RETRIES) {
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
index dc8d509d130..7abf55a6bd7 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPHC5Features.java
@@ -33,10 +33,20 @@
import java.net.URL;
import java.nio.charset.StandardCharsets;
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.io.ConnectionEndpoint;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.io.LeaseRequest;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.util.TimeValue;
+import org.apache.hc.core5.util.Timeout;
import org.apache.jmeter.protocol.http.control.AuthManager;
import org.apache.jmeter.protocol.http.control.CacheManager;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.apache.jmeter.samplers.SampleResult;
import org.junit.jupiter.api.Test;
import com.github.tomakehurst.wiremock.WireMockServer;
@@ -262,6 +272,94 @@ void authenticatesWithConfiguredProxyCredentials() throws Exception {
}
}
+ @Test
+ void setsConnectTimeForHttp11() throws Exception {
+ SampleResult result = new SampleResult();
+ result.sampleStart();
+ HttpClientContext context = HttpClientContext.create();
+ context.setAttribute(HTTPHC5Impl.CONTEXT_ATTRIBUTE_SAMPLER_RESULT, result);
+ HttpClientConnectionManager connectionManager =
+ new HTTPHC5Impl.ConnectTimeMeasuringConnectionManager(new SlowConnectingConnectionManager(50));
+
+ connectionManager.connect(null, null, context);
+ Thread.sleep(50);
+ result.sampleEnd();
+
+ assertTrue(result.getConnectTime() >= 50,
+ "connectTime should cover the time spent connecting, but was " + result.getConnectTime());
+ assertTrue(result.getConnectTime() <= result.getTime(),
+ "connectTime should not exceed the elapsed time");
+ }
+
+ /** Connection manager that only supports {@code connect} and takes a well-known amount of time for it. */
+ private static final class SlowConnectingConnectionManager implements HttpClientConnectionManager {
+
+ private final long connectDurationMillis;
+
+ SlowConnectingConnectionManager(long connectDurationMillis) {
+ this.connectDurationMillis = connectDurationMillis;
+ }
+
+ @Override
+ public LeaseRequest lease(String id, HttpRoute route, Timeout requestTimeout, Object state) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void release(ConnectionEndpoint endpoint, Object newState, TimeValue validDuration) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void connect(ConnectionEndpoint endpoint, TimeValue connectTimeout, HttpContext context) throws IOException {
+ try {
+ Thread.sleep(connectDurationMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void upgrade(ConnectionEndpoint endpoint, HttpContext context) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void close(CloseMode closeMode) {
+ // nothing to close
+ }
+
+ @Override
+ public void close() {
+ // nothing to close
+ }
+ }
+
+ @Test
+ void setsConnectTimeForHttp2() throws Exception {
+ WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig()
+ .dynamicHttpsPort()
+ .http2TlsDisabled(false));
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/connectTime2")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL("https://localhost:" + server.httpsPort() + "/connectTime2"), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getConnectTime() > 0,
+ "connectTime should be greater than 0, but was " + result.getConnectTime());
+ assertTrue(result.getConnectTime() <= result.getTime(),
+ "connectTime should not exceed the elapsed time");
+ } finally {
+ server.stop();
+ }
+ }
+
private static HTTPSamplerBase newSampler() {
return HTTPSamplerFactory.newInstance("HttpClient5");
}
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
index e99d7c7c9b6..b52ad4e2ce0 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
@@ -219,6 +219,28 @@ void displaysAuthorizationHeaderFromHeaderManagerInHttp2() throws Exception {
}
}
+ @Test
+ void setsConnectTimeForHttp11() throws Exception {
+ WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig().dynamicHttpsPort());
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/connectTime")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/1.1");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL("https://localhost:" + server.httpsPort() + "/connectTime"), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getConnectTime() > 0,
+ "connectTime should be greater than 0, but was " + result.getConnectTime());
+ assertTrue(result.getConnectTime() <= result.getTime(),
+ "connectTime should not exceed the elapsed time");
+ } finally {
+ server.stop();
+ }
+ }
+
private static HTTPSamplerBase newSampler() {
return HTTPSamplerFactory.newInstance("Java");
}
From eef1096fd042d46dca488794a5ddcb3f3efbf2ac Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Tue, 28 Jul 2026 09:01:44 +0200
Subject: [PATCH 17/19] Add connect time measurement for HTTP/2 in
`HTTPJavaImpl` with unit tests
---
.../protocol/http/sampler/HTTPJavaImpl.java | 392 +++++++++++++++++-
.../http/sampler/TestHTTPJavaFeatures.java | 116 ++++++
2 files changed, 501 insertions(+), 7 deletions(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
index 411f015b005..9ed41ba716e 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
@@ -34,7 +34,11 @@
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
+import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
+import java.security.KeyManagementException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
@@ -43,10 +47,27 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.zip.GZIPInputStream;
+import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLContextSpi;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLEngineResult;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLServerSocketFactory;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.SSLSessionContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManager;
import org.apache.jmeter.protocol.http.control.AuthManager;
import org.apache.jmeter.protocol.http.control.Authorization;
@@ -78,9 +99,16 @@ public class HTTPJavaImpl extends HTTPAbstractImpl {
private static final String DEFAULT_HTTP_VERSION =
JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$
- private static final ThreadLocal> HTTP_2_CLIENTS =
+ private static final ThreadLocal> HTTP_2_CLIENTS =
ThreadLocal.withInitial(HashMap::new);
+ /**
+ * Shared daemon thread pool used by the HTTP/2 {@link HttpClient} instances. It allows JMeter to observe
+ * when a connection has been established, since the client only submits tasks once the TCP connection is up.
+ */
+ private static final ExecutorService HTTP_2_EXECUTOR =
+ Executors.newCachedThreadPool(new Http2ThreadFactory());
+
private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class);
static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) {
@@ -871,10 +899,17 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin
}
}
- HttpClient client = getHttpClient(url);
+ Http2Client client = getHttpClient(url);
HttpRequest httpRequest = reqBuilder.build();
- HttpResponse response = client.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
+ HttpResponse response;
+ ConnectTimeTracker connectTimeTracker = client.connectTimeTracker;
+ connectTimeTracker.sampleStarted(res);
+ try {
+ response = client.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream());
+ } finally {
+ connectTimeTracker.sampleFinished();
+ }
res.latencyEnd();
byte[] responseData = readResponse(response, res);
@@ -1125,7 +1160,7 @@ private byte[] getBytes(String postBody) {
}
}
- private HttpClient getHttpClient(URL url) {
+ private Http2Client getHttpClient(URL url) {
int connectTimeout = getConnectTimeout();
String proxyHost = getProxyHost();
int proxyPort = getProxyPortInt();
@@ -1143,6 +1178,14 @@ private HttpClient getHttpClient(URL url) {
} catch (Exception e) {
log.warn("Problem getting SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$
}
+ if (sslContext == null) {
+ // Use the default context explicitly, so the connect time of the TLS handshake can be measured
+ try {
+ sslContext = SSLContext.getDefault();
+ } catch (NoSuchAlgorithmException e) {
+ log.warn("Problem getting default SSLContext for HTTP/2 HttpClient: ", e); // $NON-NLS-1$
+ }
+ }
}
HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort,
@@ -1151,9 +1194,11 @@ private HttpClient getHttpClient(URL url) {
return HTTP_2_CLIENTS.get().computeIfAbsent(key, HTTPJavaImpl::createHttpClient);
}
- private static HttpClient createHttpClient(HttpClientKey key) {
+ private static Http2Client createHttpClient(HttpClientKey key) {
+ ConnectTimeTracker connectTimeTracker = new ConnectTimeTracker();
HttpClient.Builder builder = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
+ .executor(new ConnectTimeMeasuringExecutor(connectTimeTracker))
.followRedirects(key.autoRedirects ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER);
if (key.connectTimeout > 0) {
@@ -1177,10 +1222,343 @@ protected PasswordAuthentication getPasswordAuthentication() {
}
if (key.sslContext != null) {
- builder.sslContext(key.sslContext);
+ builder.sslContext(new ConnectTimeMeasuringSSLContext(key.sslContext, connectTimeTracker));
+ }
+
+ return new Http2Client(builder.build(), connectTimeTracker);
+ }
+
+ /**
+ * Holder for an HTTP/2 {@link HttpClient} and the tracker which measures the connect time of its connections.
+ */
+ private static final class Http2Client {
+ private final HttpClient httpClient;
+ private final ConnectTimeTracker connectTimeTracker;
+
+ Http2Client(HttpClient httpClient, ConnectTimeTracker connectTimeTracker) {
+ this.httpClient = httpClient;
+ this.connectTimeTracker = connectTimeTracker;
+ }
+ }
+
+ /**
+ * Records the connect time of the JDK {@link HttpClient} in the current {@link SampleResult}.
+ *
+ * The JDK client does not expose a hook for connection establishment, so two indicators are used:
+ * the client submits its first task to the executor once the TCP connection has been established, and
+ * the wrapped {@code SSLEngine} reports when the TLS handshake has been finished. The TLS handshake
+ * always wins, since it is the more precise and the later event.
+ */
+ static final class ConnectTimeTracker {
+ private volatile SampleResult sampleResult;
+ private volatile boolean connectRecorded;
+ private volatile boolean handshakeRecorded;
+
+ void sampleStarted(SampleResult result) {
+ this.connectRecorded = false;
+ this.handshakeRecorded = false;
+ this.sampleResult = result;
+ }
+
+ void sampleFinished() {
+ this.sampleResult = null;
+ }
+
+ /** Invoked when the TCP connection has been established. */
+ void connectionEstablished() {
+ SampleResult result = sampleResult;
+ if (result != null && !connectRecorded && !handshakeRecorded) {
+ connectRecorded = true;
+ result.connectEnd();
+ }
+ }
+
+ /** Invoked when the TLS handshake has been finished, it overrides the plain TCP connect time. */
+ void handshakeFinished() {
+ SampleResult result = sampleResult;
+ if (result != null && !handshakeRecorded) {
+ handshakeRecorded = true;
+ connectRecorded = true;
+ result.connectEnd();
+ }
+ }
+ }
+
+ /**
+ * Executor which notifies the {@link ConnectTimeTracker} before delegating to the shared thread pool.
+ */
+ private static final class ConnectTimeMeasuringExecutor implements Executor {
+ private final ConnectTimeTracker tracker;
+
+ ConnectTimeMeasuringExecutor(ConnectTimeTracker tracker) {
+ this.tracker = tracker;
+ }
+
+ @Override
+ public void execute(Runnable command) {
+ tracker.connectionEstablished();
+ HTTP_2_EXECUTOR.execute(command);
+ }
+ }
+
+ private static final class Http2ThreadFactory implements ThreadFactory {
+ private final AtomicInteger counter = new AtomicInteger();
+
+ @Override
+ public Thread newThread(Runnable r) {
+ Thread thread = new Thread(r, "JMeter-HTTP2-" + counter.incrementAndGet()); // $NON-NLS-1$
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+
+ /**
+ * {@link SSLContext} which creates {@link SSLEngine}s that report the end of the TLS handshake.
+ */
+ static final class ConnectTimeMeasuringSSLContext extends SSLContext {
+ ConnectTimeMeasuringSSLContext(SSLContext delegate, ConnectTimeTracker tracker) {
+ super(new ConnectTimeMeasuringSSLContextSpi(delegate, tracker), delegate.getProvider(),
+ delegate.getProtocol());
+ }
+ }
+
+ private static final class ConnectTimeMeasuringSSLContextSpi extends SSLContextSpi {
+ private final SSLContext delegate;
+ private final ConnectTimeTracker tracker;
+
+ ConnectTimeMeasuringSSLContextSpi(SSLContext delegate, ConnectTimeTracker tracker) {
+ this.delegate = delegate;
+ this.tracker = tracker;
+ }
+
+ @Override
+ protected void engineInit(KeyManager[] km, TrustManager[] tm, SecureRandom sr) throws KeyManagementException {
+ delegate.init(km, tm, sr);
+ }
+
+ @Override
+ protected SSLSocketFactory engineGetSocketFactory() {
+ return delegate.getSocketFactory();
+ }
+
+ @Override
+ protected SSLServerSocketFactory engineGetServerSocketFactory() {
+ return delegate.getServerSocketFactory();
+ }
+
+ @Override
+ protected SSLEngine engineCreateSSLEngine() {
+ return new ConnectTimeMeasuringSSLEngine(delegate.createSSLEngine(), tracker);
+ }
+
+ @Override
+ protected SSLEngine engineCreateSSLEngine(String host, int port) {
+ return new ConnectTimeMeasuringSSLEngine(delegate.createSSLEngine(host, port), tracker);
+ }
+
+ @Override
+ protected SSLSessionContext engineGetServerSessionContext() {
+ return delegate.getServerSessionContext();
+ }
+
+ @Override
+ protected SSLSessionContext engineGetClientSessionContext() {
+ return delegate.getClientSessionContext();
+ }
+
+ @Override
+ protected SSLParameters engineGetDefaultSSLParameters() {
+ return delegate.getDefaultSSLParameters();
+ }
+
+ @Override
+ protected SSLParameters engineGetSupportedSSLParameters() {
+ return delegate.getSupportedSSLParameters();
+ }
+ }
+
+ /**
+ * {@link SSLEngine} which delegates all calls and notifies the {@link ConnectTimeTracker}
+ * as soon as the TLS handshake has been finished.
+ */
+ static final class ConnectTimeMeasuringSSLEngine extends SSLEngine {
+ private final SSLEngine delegate;
+ private final ConnectTimeTracker tracker;
+
+ ConnectTimeMeasuringSSLEngine(SSLEngine delegate, ConnectTimeTracker tracker) {
+ super(delegate.getPeerHost(), delegate.getPeerPort());
+ this.delegate = delegate;
+ this.tracker = tracker;
+ }
+
+ private void checkHandshakeFinished(SSLEngineResult result) {
+ if (result.getHandshakeStatus() == SSLEngineResult.HandshakeStatus.FINISHED) {
+ tracker.handshakeFinished();
+ }
+ }
+
+ @Override
+ public SSLEngineResult wrap(ByteBuffer[] srcs, int offset, int length, ByteBuffer dst) throws SSLException {
+ SSLEngineResult result = delegate.wrap(srcs, offset, length, dst);
+ checkHandshakeFinished(result);
+ return result;
}
- return builder.build();
+ @Override
+ public SSLEngineResult unwrap(ByteBuffer src, ByteBuffer[] dsts, int offset, int length) throws SSLException {
+ SSLEngineResult result = delegate.unwrap(src, dsts, offset, length);
+ checkHandshakeFinished(result);
+ return result;
+ }
+
+ @Override
+ public Runnable getDelegatedTask() {
+ return delegate.getDelegatedTask();
+ }
+
+ @Override
+ public void closeInbound() throws SSLException {
+ delegate.closeInbound();
+ }
+
+ @Override
+ public boolean isInboundDone() {
+ return delegate.isInboundDone();
+ }
+
+ @Override
+ public void closeOutbound() {
+ delegate.closeOutbound();
+ }
+
+ @Override
+ public boolean isOutboundDone() {
+ return delegate.isOutboundDone();
+ }
+
+ @Override
+ public String[] getSupportedCipherSuites() {
+ return delegate.getSupportedCipherSuites();
+ }
+
+ @Override
+ public String[] getEnabledCipherSuites() {
+ return delegate.getEnabledCipherSuites();
+ }
+
+ @Override
+ public void setEnabledCipherSuites(String[] suites) {
+ delegate.setEnabledCipherSuites(suites);
+ }
+
+ @Override
+ public String[] getSupportedProtocols() {
+ return delegate.getSupportedProtocols();
+ }
+
+ @Override
+ public String[] getEnabledProtocols() {
+ return delegate.getEnabledProtocols();
+ }
+
+ @Override
+ public void setEnabledProtocols(String[] protocols) {
+ delegate.setEnabledProtocols(protocols);
+ }
+
+ @Override
+ public SSLSession getSession() {
+ return delegate.getSession();
+ }
+
+ @Override
+ public SSLSession getHandshakeSession() {
+ return delegate.getHandshakeSession();
+ }
+
+ @Override
+ public void beginHandshake() throws SSLException {
+ delegate.beginHandshake();
+ }
+
+ @Override
+ public SSLEngineResult.HandshakeStatus getHandshakeStatus() {
+ return delegate.getHandshakeStatus();
+ }
+
+ @Override
+ public void setUseClientMode(boolean mode) {
+ delegate.setUseClientMode(mode);
+ }
+
+ @Override
+ public boolean getUseClientMode() {
+ return delegate.getUseClientMode();
+ }
+
+ @Override
+ public void setNeedClientAuth(boolean need) {
+ delegate.setNeedClientAuth(need);
+ }
+
+ @Override
+ public boolean getNeedClientAuth() {
+ return delegate.getNeedClientAuth();
+ }
+
+ @Override
+ public void setWantClientAuth(boolean want) {
+ delegate.setWantClientAuth(want);
+ }
+
+ @Override
+ public boolean getWantClientAuth() {
+ return delegate.getWantClientAuth();
+ }
+
+ @Override
+ public void setEnableSessionCreation(boolean flag) {
+ delegate.setEnableSessionCreation(flag);
+ }
+
+ @Override
+ public boolean getEnableSessionCreation() {
+ return delegate.getEnableSessionCreation();
+ }
+
+ @Override
+ public SSLParameters getSSLParameters() {
+ return delegate.getSSLParameters();
+ }
+
+ @Override
+ public void setSSLParameters(SSLParameters params) {
+ delegate.setSSLParameters(params);
+ }
+
+ @Override
+ public String getApplicationProtocol() {
+ return delegate.getApplicationProtocol();
+ }
+
+ @Override
+ public String getHandshakeApplicationProtocol() {
+ return delegate.getHandshakeApplicationProtocol();
+ }
+
+ @Override
+ public void setHandshakeApplicationProtocolSelector(BiFunction, String> selector) {
+ if (selector == null) {
+ delegate.setHandshakeApplicationProtocolSelector(null);
+ } else {
+ delegate.setHandshakeApplicationProtocolSelector((engine, protocols) -> selector.apply(this, protocols));
+ }
+ }
+
+ @Override
+ public BiFunction, String> getHandshakeApplicationProtocolSelector() {
+ return delegate.getHandshakeApplicationProtocolSelector();
+ }
}
private static class HttpClientKey {
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
index b52ad4e2ce0..49301a71df3 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
@@ -25,11 +25,23 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.net.Socket;
+import java.net.URI;
import java.net.URL;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.cert.X509Certificate;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509ExtendedTrustManager;
import org.apache.jmeter.protocol.http.control.Header;
import org.apache.jmeter.protocol.http.control.HeaderManager;
import org.apache.jmeter.protocol.http.util.HTTPConstants;
+import org.apache.jmeter.samplers.SampleResult;
import org.junit.jupiter.api.Test;
import com.github.tomakehurst.wiremock.WireMockServer;
@@ -241,6 +253,110 @@ void setsConnectTimeForHttp11() throws Exception {
}
}
+ @Test
+ void setsConnectTimeForHttp2OverPlainConnection() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2ConnectTime")).willReturn(aResponse().withStatus(200)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2ConnectTime")), HTTPConstants.GET, false, 1);
+
+ assertEquals("200", result.getResponseCode());
+ assertTrue(result.getConnectTime() > 0,
+ "connectTime should be greater than 0, but was " + result.getConnectTime());
+ assertTrue(result.getConnectTime() <= result.getTime(),
+ "connectTime should not exceed the elapsed time");
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void setsConnectTimeForHttp2OverTls() throws Exception {
+ WireMockServer server = new WireMockServer(
+ WireMockConfiguration.wireMockConfig().dynamicHttpsPort().http2TlsDisabled(false));
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2TlsConnectTime")).willReturn(aResponse().withStatus(200)));
+
+ HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker();
+ SSLContext sslContext = trustAllContext();
+ HttpClient client = HttpClient.newBuilder()
+ .version(HttpClient.Version.HTTP_2)
+ .sslContext(new HTTPJavaImpl.ConnectTimeMeasuringSSLContext(sslContext, tracker))
+ .build();
+
+ SampleResult result = new SampleResult();
+ result.sampleStart();
+ tracker.sampleStarted(result);
+ HttpResponse response;
+ try {
+ response = client.send(
+ HttpRequest.newBuilder(URI.create(
+ "https://localhost:" + server.httpsPort() + "/http2TlsConnectTime")).build(),
+ HttpResponse.BodyHandlers.ofString());
+ } finally {
+ tracker.sampleFinished();
+ }
+ result.sampleEnd();
+
+ assertEquals(200, response.statusCode());
+ assertEquals(HttpClient.Version.HTTP_2, response.version());
+ assertTrue(result.getConnectTime() > 0,
+ "connectTime should be greater than 0, but was " + result.getConnectTime());
+ assertTrue(result.getConnectTime() <= result.getTime(),
+ "connectTime should not exceed the elapsed time");
+ } finally {
+ server.stop();
+ }
+ }
+
+ private static SSLContext trustAllContext() throws Exception {
+ TrustManager trustAll = new X509ExtendedTrustManager() {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {
+ // trust everything in the test
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {
+ // trust everything in the test
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType, Socket socket) {
+ // trust everything in the test
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType, Socket socket) {
+ // trust everything in the test
+ }
+
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {
+ // trust everything in the test
+ }
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType, SSLEngine engine) {
+ // trust everything in the test
+ }
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ };
+ SSLContext context = SSLContext.getInstance("TLS");
+ context.init(null, new TrustManager[] { trustAll }, null);
+ return context;
+ }
+
private static HTTPSamplerBase newSampler() {
return HTTPSamplerFactory.newInstance("Java");
}
From 624944304b741644406f597b71423b9199dd0703 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Tue, 28 Jul 2026 09:16:11 +0200
Subject: [PATCH 18/19] Add HTTP/2 reason phrase handling in `HTTPJavaImpl`
with unit tests
---
.../protocol/http/sampler/HTTPJavaImpl.java | 84 ++++++++++++++++++-
.../http/sampler/TestHTTPJavaFeatures.java | 25 ++++++
2 files changed, 108 insertions(+), 1 deletion(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
index 9ed41ba716e..c60bf4c1fec 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPJavaImpl.java
@@ -111,6 +111,88 @@ public class HTTPJavaImpl extends HTTPAbstractImpl {
private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class);
+ /**
+ * HTTP/2 does not transmit a reason phrase (see RFC 9113, section 8.3.2), so the response message is
+ * derived from the status code. The phrases are the ones registered in the IANA HTTP status code registry.
+ */
+ private static final Map HTTP_REASON_PHRASES = createReasonPhrases();
+
+ private static Map createReasonPhrases() {
+ Map phrases = new HashMap<>();
+ phrases.put(100, "Continue"); // $NON-NLS-1$
+ phrases.put(101, "Switching Protocols"); // $NON-NLS-1$
+ phrases.put(102, "Processing"); // $NON-NLS-1$
+ phrases.put(103, "Early Hints"); // $NON-NLS-1$
+ phrases.put(200, "OK"); // $NON-NLS-1$
+ phrases.put(201, "Created"); // $NON-NLS-1$
+ phrases.put(202, "Accepted"); // $NON-NLS-1$
+ phrases.put(203, "Non-Authoritative Information"); // $NON-NLS-1$
+ phrases.put(204, "No Content"); // $NON-NLS-1$
+ phrases.put(205, "Reset Content"); // $NON-NLS-1$
+ phrases.put(206, "Partial Content"); // $NON-NLS-1$
+ phrases.put(207, "Multi-Status"); // $NON-NLS-1$
+ phrases.put(208, "Already Reported"); // $NON-NLS-1$
+ phrases.put(226, "IM Used"); // $NON-NLS-1$
+ phrases.put(300, "Multiple Choices"); // $NON-NLS-1$
+ phrases.put(301, "Moved Permanently"); // $NON-NLS-1$
+ phrases.put(302, "Found"); // $NON-NLS-1$
+ phrases.put(303, "See Other"); // $NON-NLS-1$
+ phrases.put(304, "Not Modified"); // $NON-NLS-1$
+ phrases.put(305, "Use Proxy"); // $NON-NLS-1$
+ phrases.put(307, "Temporary Redirect"); // $NON-NLS-1$
+ phrases.put(308, "Permanent Redirect"); // $NON-NLS-1$
+ phrases.put(400, "Bad Request"); // $NON-NLS-1$
+ phrases.put(401, "Unauthorized"); // $NON-NLS-1$
+ phrases.put(402, "Payment Required"); // $NON-NLS-1$
+ phrases.put(403, "Forbidden"); // $NON-NLS-1$
+ phrases.put(404, "Not Found"); // $NON-NLS-1$
+ phrases.put(405, "Method Not Allowed"); // $NON-NLS-1$
+ phrases.put(406, "Not Acceptable"); // $NON-NLS-1$
+ phrases.put(407, "Proxy Authentication Required"); // $NON-NLS-1$
+ phrases.put(408, "Request Timeout"); // $NON-NLS-1$
+ phrases.put(409, "Conflict"); // $NON-NLS-1$
+ phrases.put(410, "Gone"); // $NON-NLS-1$
+ phrases.put(411, "Length Required"); // $NON-NLS-1$
+ phrases.put(412, "Precondition Failed"); // $NON-NLS-1$
+ phrases.put(413, "Content Too Large"); // $NON-NLS-1$
+ phrases.put(414, "URI Too Long"); // $NON-NLS-1$
+ phrases.put(415, "Unsupported Media Type"); // $NON-NLS-1$
+ phrases.put(416, "Range Not Satisfiable"); // $NON-NLS-1$
+ phrases.put(417, "Expectation Failed"); // $NON-NLS-1$
+ phrases.put(421, "Misdirected Request"); // $NON-NLS-1$
+ phrases.put(422, "Unprocessable Content"); // $NON-NLS-1$
+ phrases.put(423, "Locked"); // $NON-NLS-1$
+ phrases.put(424, "Failed Dependency"); // $NON-NLS-1$
+ phrases.put(425, "Too Early"); // $NON-NLS-1$
+ phrases.put(426, "Upgrade Required"); // $NON-NLS-1$
+ phrases.put(428, "Precondition Required"); // $NON-NLS-1$
+ phrases.put(429, "Too Many Requests"); // $NON-NLS-1$
+ phrases.put(431, "Request Header Fields Too Large"); // $NON-NLS-1$
+ phrases.put(451, "Unavailable For Legal Reasons"); // $NON-NLS-1$
+ phrases.put(500, "Internal Server Error"); // $NON-NLS-1$
+ phrases.put(501, "Not Implemented"); // $NON-NLS-1$
+ phrases.put(502, "Bad Gateway"); // $NON-NLS-1$
+ phrases.put(503, "Service Unavailable"); // $NON-NLS-1$
+ phrases.put(504, "Gateway Timeout"); // $NON-NLS-1$
+ phrases.put(505, "HTTP Version Not Supported"); // $NON-NLS-1$
+ phrases.put(506, "Variant Also Negotiates"); // $NON-NLS-1$
+ phrases.put(507, "Insufficient Storage"); // $NON-NLS-1$
+ phrases.put(508, "Loop Detected"); // $NON-NLS-1$
+ phrases.put(510, "Not Extended"); // $NON-NLS-1$
+ phrases.put(511, "Network Authentication Required"); // $NON-NLS-1$
+ return Collections.unmodifiableMap(phrases);
+ }
+
+ /**
+ * Returns the reason phrase belonging to the given HTTP status code.
+ *
+ * @param statusCode the HTTP status code
+ * @return the registered reason phrase, or an empty string if the status code is unknown
+ */
+ static String getReasonPhrase(int statusCode) {
+ return HTTP_REASON_PHRASES.getOrDefault(statusCode, ""); // $NON-NLS-1$
+ }
+
static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) {
String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion;
return "HTTP/2".equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion); // $NON-NLS-1$ $NON-NLS-2$
@@ -921,7 +1003,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin
int statusCode = response.statusCode();
res.setResponseCode(Integer.toString(statusCode));
res.setSuccessful(isSuccessCode(statusCode));
- res.setResponseMessage(""); // $NON-NLS-1$
+ res.setResponseMessage(getReasonPhrase(statusCode));
String responseHeaders = getResponseHeaders(response);
res.setResponseHeaders(responseHeaders);
diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
index 49301a71df3..d008a94431b 100644
--- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
+++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPJavaFeatures.java
@@ -85,6 +85,31 @@ void usesHttp2WhenSelected() throws Exception {
}
}
+ @Test
+ void setsResponseMessageForHttp2() throws Exception {
+ WireMockServer server = createServer();
+ server.start();
+ try {
+ server.stubFor(get(urlEqualTo("/http2ResponseMessage")).willReturn(aResponse().withStatus(404)));
+ HTTPSamplerBase sampler = newSampler();
+ sampler.setHttpVersion("HTTP/2");
+
+ HTTPSampleResult result = sampler.sample(
+ new URL(server.url("/http2ResponseMessage")), HTTPConstants.GET, false, 1);
+
+ assertEquals("404", result.getResponseCode());
+ assertEquals("Not Found", result.getResponseMessage());
+ } finally {
+ server.stop();
+ }
+ }
+
+ @Test
+ void returnsEmptyReasonPhraseForUnknownStatusCode() {
+ assertEquals("OK", HTTPJavaImpl.getReasonPhrase(200));
+ assertEquals("", HTTPJavaImpl.getReasonPhrase(599));
+ }
+
@Test
void usesHttp2WithProxy() throws Exception {
WireMockServer server = createServer();
From 1bf600965db8bf433ad18dc603e31d8eae155969 Mon Sep 17 00:00:00 2001
From: andreaslind01
Date: Tue, 28 Jul 2026 09:52:18 +0200
Subject: [PATCH 19/19] Reorder HTTP version options in GUI components for
consistency
---
.../apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java | 2 +-
.../jmeter/protocol/http/control/gui/HttpTestSampleGui.java | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java
index 79534daa33a..4fdd406acf4 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/config/gui/HttpDefaultsGui.java
@@ -79,7 +79,7 @@ public class HttpDefaultsGui extends AbstractConfigGui {
private JTextField proxyUser;
private JPasswordField proxyPass;
private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations());
- private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"});
+ private final JComboBox httpVersion = new JComboBox<>(new String[] {"HTTP/1.1", "HTTP/2", ""});
private JTextField connectTimeOut;
private JTextField responseTimeOut;
diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java
index 5ce2acc909e..7b6150e3b37 100644
--- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java
+++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/gui/HttpTestSampleGui.java
@@ -81,7 +81,7 @@ public class HttpTestSampleGui extends AbstractSamplerGui {
private JTextField proxyUser;
private JPasswordField proxyPass;
private final JComboBox httpImplementation = new JComboBox<>(HTTPSamplerFactory.getImplementations());
- private final JComboBox httpVersion = new JComboBox<>(new String[] {"", "HTTP/1.1", "HTTP/2"});
+ private final JComboBox httpVersion = new JComboBox<>(new String[] {"HTTP/1.1", "HTTP/2", ""});
private JTextField connectTimeOut;
private JTextField responseTimeOut;