From c679dc154f66d33520682ff51422be9eea85630e Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 22 Jul 2026 13:10:49 +0200 Subject: [PATCH 01/31] 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> HTTP_CLIENTS = + ThreadLocal.withInitial(HashMap::new); + + private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; + + protected HTTPHC5Impl(HTTPSamplerBase testElement) { + super(testElement); + } + + @Override + protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { + HTTPSampleResult result = createSampleResult(url, method); + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = null; + CloseableHttpResponse response = null; + try { + resetStateIfNeeded(); + request = createRequest(url.toURI(), method, areFollowingRedirect); + setupRequest(url, request, result, areFollowingRedirect); + result.sampleStart(); + + CacheManager cacheManager = getCacheManager(); + if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { + log.debug("Cache Manager is not supported by HttpClient5 yet"); + } + + currentRequest = request; + response = getClient(createHttpClientKey(url)).execute(request); + result.sampleEnd(); + currentRequest = null; + + updateResult(response, request, result); + saveConnectionCookies(response, result.getURL(), getCookieManager()); + return resultProcessing(areFollowingRedirect, frameDepth, result); + } catch (Exception e) { + if (result.getEndTime() == 0) { + result.sampleEnd(); + } + if (request != null) { + result.setRequestHeaders(getRequestHeaders(request)); + } + return errorResult(e, result); + } finally { + JOrphanUtils.closeQuietly(response); + currentRequest = null; + } + } + + private HTTPSampleResult createSampleResult(URL url, String method) { + HTTPSampleResult result = new HTTPSampleResult(); + configureSampleLabel(result, url); + result.setHTTPMethod(method); + result.setURL(url); + return result; + } + + private org.apache.hc.client5.http.classic.methods.HttpUriRequestBase createRequest( + URI uri, String method, boolean areFollowingRedirect) { + return new org.apache.hc.client5.http.classic.methods.HttpUriRequestBase(method, uri); + } + + private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + HTTPSampleResult result, boolean areFollowingRedirect) throws IOException { + RequestConfig.Builder config = RequestConfig.custom() + .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); + int responseTimeout = getResponseTimeout(); + if (responseTimeout > 0) { + config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); + } + int connectTimeout = getConnectTimeout(); + if (connectTimeout > 0) { + config.setConnectTimeout(Timeout.ofMilliseconds(connectTimeout)); + } + request.setConfig(config.build()); + request.setHeader(HTTPConstants.HEADER_CONNECTION, + getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); + setConnectionHeaders(request, url, getHeaderManager()); + + String cookies = setConnectionCookie(request, url, getCookieManager()); + if (StringUtilities.isNotEmpty(cookies)) { + result.setCookies(cookies); + } else { + result.setCookies(getOnlyCookieFromHeaders(request)); + } + + if (canHaveBody(request.getMethod())) { + result.setQueryString(setupRequestEntity(request)); + } + } + + private static boolean canHaveBody(String method) { + return !HTTPConstants.HEAD.equals(method) && !HTTPConstants.TRACE.equals(method); + } + + private String setupRequestEntity(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) throws IOException { + HTTPFileArg[] files = getHTTPFiles(); + String contentEncoding = getContentEncoding(); + Charset charset = Charset.forName(contentEncoding); + HttpEntity entity; + String requestData; + if (getUseMultipart()) { + MultipartEntityBuilder builder = MultipartEntityBuilder.create().setCharset(charset); + for (JMeterProperty property : getArguments().getEnabledArguments()) { + HTTPArgument argument = (HTTPArgument) property.getObjectValue(); + if (!argument.isSkippable(argument.getName())) { + ContentType contentType = StringUtilities.isNotEmpty(argument.getContentType()) + ? ContentType.parse(argument.getContentType()) + : ContentType.TEXT_PLAIN.withCharset(charset); + builder.addTextBody(argument.getName(), argument.getValue(), contentType); + } + } + for (HTTPFileArg file : files) { + File resolvedFile = FileServer.getFileServer().getResolvedFile(file.getPath()); + ContentType contentType = StringUtilities.isNotEmpty(file.getMimeType()) + ? ContentType.parse(file.getMimeType()) : ContentType.DEFAULT_BINARY; + builder.addBinaryBody(file.getParamName(), resolvedFile, contentType, file.getName()); + } + entity = builder.build(); + requestData = getEntityPreview(entity, contentEncoding); + } else if (!hasArguments() && getSendFileAsPostBody()) { + HTTPFileArg file = files[0]; + if (request.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE) == null && StringUtilities.isNotEmpty(file.getMimeType())) { + request.setHeader(HTTPConstants.HEADER_CONTENT_TYPE, file.getMimeType()); + } + entity = new FileEntity(FileServer.getFileServer().getResolvedFile(file.getPath()), null); + requestData = ""; + } else if (getSendParameterValuesAsPostBody()) { + StringBuilder body = new StringBuilder(); + for (JMeterProperty property : getArguments().getEnabledArguments()) { + body.append(((HTTPArgument) property.getObjectValue()).getEncodedValue(contentEncoding)); + } + entity = new StringEntity(body.toString(), charset); + requestData = body.toString(); + } else if (hasArguments()) { + entity = new UrlEncodedFormEntity(createNameValuePairs(contentEncoding), charset); + requestData = getEntityPreview(entity, contentEncoding); + } else { + return ""; + } + request.setEntity(entity); + return requestData; + } + + private List createNameValuePairs(String contentEncoding) throws IOException { + List pairs = new ArrayList<>(); + for (JMeterProperty property : getArguments().getEnabledArguments()) { + HTTPArgument argument = (HTTPArgument) property.getObjectValue(); + String name = argument.getName(); + if (argument.isSkippable(name)) { + continue; + } + String value = argument.getValue(); + if (!argument.isAlwaysEncoded()) { + name = URLDecoder.decode(name, contentEncoding); + value = URLDecoder.decode(value, contentEncoding); + } + pairs.add(new BasicNameValuePair(name, value)); + } + return pairs; + } + + private static String getEntityPreview(HttpEntity entity, String contentEncoding) throws IOException { + if (!entity.isRepeatable()) { + return ""; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + return output.toString(contentEncoding); + } + + private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + URL url, HeaderManager headerManager) { + if (headerManager == null) { + return; + } + CollectionProperty headers = headerManager.getHeaders(); + if (headers == null) { + return; + } + 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())) { + request.addHeader(header.getName(), header.getValue()); + } + } + } + + private static String setConnectionCookie(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + URL url, CookieManager cookieManager) { + if (cookieManager == null) { + return null; + } + String cookies = cookieManager.getCookieHeaderForURL(url); + if (cookies != null) { + request.setHeader(HTTPConstants.HEADER_COOKIE, cookies); + } + return cookies; + } + + private void updateResult(CloseableHttpResponse response, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { + result.setRequestHeaders(getRequestHeaders(request)); + Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); + if (contentType != null) { + result.setContentType(contentType.getValue()); + result.setEncodingAndType(contentType.getValue()); + } + HttpEntity entity = response.getEntity(); + long bodySize = 0; + if (entity != null) { + byte[] body = readResponse(result, entity.getContent(), entity.getContentLength()); + result.setResponseData(body); + bodySize = body.length; + } + int statusCode = response.getCode(); + result.setResponseCode(Integer.toString(statusCode)); + result.setResponseMessage(response.getReasonPhrase()); + result.setSuccessful(isSuccessCode(statusCode)); + result.setResponseHeaders(getResponseHeaders(response)); + result.setHeadersSize(result.getResponseHeaders().length()); + result.setBodySize(bodySize); + if (result.isRedirect()) { + Header location = response.getFirstHeader(HTTPConstants.HEADER_LOCATION); + if (location != null) { + result.setRedirectLocation(location.getValue()); + } + } + } + + private static String getResponseHeaders(CloseableHttpResponse response) { + StringBuilder headers = new StringBuilder(); + headers.append(response.getVersion()).append(' ').append(response.getCode()).append(' ') + .append(response.getReasonPhrase()).append('\n'); + for (Header header : response.getHeaders()) { + headers.append(header.getName()).append(": ").append(header.getValue()).append('\n'); + } + return headers.toString(); + } + + private static String getRequestHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + StringBuilder headers = new StringBuilder(); + for (Header header : request.getHeaders()) { + if (ALL_EXCEPT_COOKIE.test(header.getName())) { + headers.append(header.getName()).append(": ").append(header.getValue()).append('\n'); + } + } + return headers.toString(); + } + + 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(); + } + + private static void saveConnectionCookies(CloseableHttpResponse response, URL url, CookieManager cookieManager) { + if (cookieManager == null) { + return; + } + for (Header header : response.getHeaders(HTTPConstants.HEADER_SET_COOKIE)) { + cookieManager.addCookieFromHeader(header.getValue(), url); + } + } + + private CloseableHttpClient getClient(HttpClientKey key) { + Map clients = HTTP_CLIENTS.get(); + return clients.computeIfAbsent(key, this::createClient); + } + + private CloseableHttpClient createClient(HttpClientKey key) { + org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + if (key.hasProxy) { + builder.setProxy(new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort)); + } + return builder.build(); + } + + private HttpClientKey createHttpClientKey(URL url) { + String proxyScheme = getProxyScheme(); + String proxyHost = getProxyHost(); + int proxyPort = getProxyPortInt(); + boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); + boolean useStaticProxy = isStaticProxy(url.getHost()); + if (!useDynamicProxy) { + proxyScheme = PROXY_SCHEME; + proxyHost = PROXY_HOST; + proxyPort = PROXY_PORT; + } + return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, + proxyScheme, proxyHost, proxyPort); + } + + @Override + protected void notifyFirstSampleAfterLoopRestart() { + JMeterVariables variables = JMeterContextService.getContext().getVariables(); + resetStateOnThreadGroupIteration.set(variables != null && !variables.isSameUserOnNextIteration() + && RESET_STATE_ON_THREAD_GROUP_ITERATION); + } + + private void resetStateIfNeeded() { + if (resetStateOnThreadGroupIteration.get()) { + closeThreadLocalClients(); + ((JsseSSLManager) SSLManager.getInstance()).resetContext(); + resetStateOnThreadGroupIteration.set(false); + } + } + + @Override + protected void threadFinished() { + closeThreadLocalClients(); + } + + private static void closeThreadLocalClients() { + Map clients = HTTP_CLIENTS.get(); + for (CloseableHttpClient client : clients.values()) { + JOrphanUtils.closeQuietly(client); + } + clients.clear(); + } + + @Override + public boolean interrupt() { + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = currentRequest; + if (request != null) { + currentRequest = null; + request.cancel(); + } + return request != null; + } + + private static final class HttpClientKey { + private final String protocol; + private final String authority; + private final boolean hasProxy; + private final String proxyScheme; + private final String proxyHost; + private final int proxyPort; + + private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, + String proxyHost, int proxyPort) { + this.protocol = protocol; + this.authority = authority; + this.hasProxy = hasProxy; + this.proxyScheme = proxyScheme; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof HttpClientKey other)) { + return false; + } + return hasProxy == other.hasProxy && proxyPort == other.proxyPort + && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) + && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost); + } + + @Override + public int hashCode() { + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort); + } + } +} \ No newline at end of file diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java index 1b9798bad4f..c7781156dc4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java @@ -38,6 +38,8 @@ public final class HTTPSamplerFactory { //+ JMX implementation attribute values (also displayed in GUI) - do not change public static final String IMPL_HTTP_CLIENT4 = "HttpClient4"; // $NON-NLS-1$ + public static final String IMPL_HTTP_CLIENT5 = "HttpClient5"; // $NON-NLS-1$ + public static final String IMPL_HTTP_CLIENT3_1 = "HttpClient3.1"; // $NON-NLS-1$ public static final String IMPL_JAVA = "Java"; // $NON-NLS-1$ @@ -62,7 +64,7 @@ public static HTTPSamplerBase newInstance() { /** * Create a new instance of the required sampler type * - * @param alias HTTP_SAMPLER or HTTP_SAMPLER_APACHE or IMPL_HTTP_CLIENT3_1 or IMPL_HTTP_CLIENT4 + * @param alias HTTP_SAMPLER or HTTP_SAMPLER_APACHE or IMPL_HTTP_CLIENT3_1, IMPL_HTTP_CLIENT4 or IMPL_HTTP_CLIENT5 * @return the appropriate sampler * @throws UnsupportedOperationException if alias is not recognised */ @@ -76,11 +78,14 @@ public static HTTPSamplerBase newInstance(String alias) { if (alias.equals(IMPL_HTTP_CLIENT4) || alias.equals(HTTP_SAMPLER_APACHE) || alias.equals(IMPL_HTTP_CLIENT3_1)) { return new HTTPSamplerProxy(IMPL_HTTP_CLIENT4); } + if (alias.equals(IMPL_HTTP_CLIENT5)) { + return new HTTPSamplerProxy(IMPL_HTTP_CLIENT5); + } throw new IllegalArgumentException("Unknown sampler type: '" + alias+"'"); } public static String[] getImplementations(){ - return new String[]{IMPL_HTTP_CLIENT4,IMPL_JAVA}; + return new String[]{IMPL_HTTP_CLIENT4, IMPL_HTTP_CLIENT5, IMPL_JAVA}; } public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase base){ @@ -94,6 +99,8 @@ public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase ba return new HTTPJavaImpl(base); } else if (IMPL_HTTP_CLIENT4.equals(impl) || IMPL_HTTP_CLIENT3_1.equals(impl)) { return new HTTPHC4Impl(base); + } else if (IMPL_HTTP_CLIENT5.equals(impl)) { + return new HTTPHC5Impl(base); } else { throw new IllegalArgumentException("Unknown implementation type: '"+impl+"'"); } diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java new file mode 100644 index 00000000000..1c889457a3b --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -0,0 +1,45 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; + +import org.junit.jupiter.api.Test; + +public class TestHTTPSamplerFactory { + + @Test + void httpClient5IsSelectable() { + assertTrue(Arrays.asList(HTTPSamplerFactory.getImplementations()).contains("HttpClient5")); + + HTTPSamplerBase sampler = HTTPSamplerFactory.newInstance("HttpClient5"); + + assertEquals("HttpClient5", sampler.getImplementation()); + assertInstanceOf(HTTPHC5Impl.class, HTTPSamplerFactory.getImplementation(sampler.getImplementation(), sampler)); + } + + @Test + void unknownImplementationIsRejected() { + assertThrows(IllegalArgumentException.class, () -> HTTPSamplerFactory.newInstance("HttpClient6")); + } +} \ No newline at end of file From acd67ece3a9e932c38c2ea23fa1c9f2a72052e67 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 06:19:38 +0200 Subject: [PATCH 02/31] Refactor `HTTPHC5Impl` to improve connection and timeout handling methods --- .../protocol/http/sampler/HTTPHC5Impl.java | 55 +++++++++++-------- .../http/sampler/TestHTTPSamplerFactory.java | 2 +- 2 files changed, 32 insertions(+), 25 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 773adfd6ab1..1cf79902b01 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 @@ -30,12 +30,14 @@ import java.util.Map; import java.util.Objects; +import org.apache.hc.client5.http.config.ConnectionConfig; 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.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.HttpEntity; @@ -83,10 +85,10 @@ protected HTTPHC5Impl(HTTPSamplerBase testElement) { protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRedirect, int frameDepth) { HTTPSampleResult result = createSampleResult(url, method); org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request = null; - CloseableHttpResponse response = null; + ClassicHttpResponse response = null; try { resetStateIfNeeded(); - request = createRequest(url.toURI(), method, areFollowingRedirect); + request = createRequest(url.toURI(), method); setupRequest(url, request, result, areFollowingRedirect); result.sampleStart(); @@ -96,7 +98,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe } currentRequest = request; - response = getClient(createHttpClientKey(url)).execute(request); + response = getClient(createHttpClientKey(url)).executeOpen(null, request, null); result.sampleEnd(); currentRequest = null; @@ -125,8 +127,7 @@ private HTTPSampleResult createSampleResult(URL url, String method) { return result; } - private org.apache.hc.client5.http.classic.methods.HttpUriRequestBase createRequest( - URI uri, String method, boolean areFollowingRedirect) { + private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase createRequest(URI uri, String method) { return new org.apache.hc.client5.http.classic.methods.HttpUriRequestBase(method, uri); } @@ -138,14 +139,10 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht if (responseTimeout > 0) { config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); } - int connectTimeout = getConnectTimeout(); - if (connectTimeout > 0) { - config.setConnectTimeout(Timeout.ofMilliseconds(connectTimeout)); - } request.setConfig(config.build()); request.setHeader(HTTPConstants.HEADER_CONNECTION, getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); - setConnectionHeaders(request, url, getHeaderManager()); + setConnectionHeaders(request, getHeaderManager()); String cookies = setConnectionCookie(request, url, getCookieManager()); if (StringUtilities.isNotEmpty(cookies)) { @@ -240,7 +237,7 @@ private static String getEntityPreview(HttpEntity entity, String contentEncoding } private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - URL url, HeaderManager headerManager) { + HeaderManager headerManager) { if (headerManager == null) { return; } @@ -269,7 +266,7 @@ private static String setConnectionCookie(org.apache.hc.client5.http.classic.met return cookies; } - private void updateResult(CloseableHttpResponse response, + private void updateResult(ClassicHttpResponse response, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { result.setRequestHeaders(getRequestHeaders(request)); Header contentType = response.getFirstHeader(HTTPConstants.HEADER_CONTENT_TYPE); @@ -299,7 +296,7 @@ private void updateResult(CloseableHttpResponse response, } } - private static String getResponseHeaders(CloseableHttpResponse response) { + private static String getResponseHeaders(ClassicHttpResponse response) { StringBuilder headers = new StringBuilder(); headers.append(response.getVersion()).append(' ').append(response.getCode()).append(' ') .append(response.getReasonPhrase()).append('\n'); @@ -324,7 +321,7 @@ private static String getOnlyCookieFromHeaders(org.apache.hc.client5.http.classi return cookie == null ? "" : cookie.getValue(); } - private static void saveConnectionCookies(CloseableHttpResponse response, URL url, CookieManager cookieManager) { + private static void saveConnectionCookies(ClassicHttpResponse response, URL url, CookieManager cookieManager) { if (cookieManager == null) { return; } @@ -333,13 +330,20 @@ private static void saveConnectionCookies(CloseableHttpResponse response, URL ur } } - private CloseableHttpClient getClient(HttpClientKey key) { + private static CloseableHttpClient getClient(HttpClientKey key) { Map clients = HTTP_CLIENTS.get(); - return clients.computeIfAbsent(key, this::createClient); + return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); } - private CloseableHttpClient createClient(HttpClientKey key) { + private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + if (key.connectTimeout > 0) { + builder.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) + .build()) + .build()); + } if (key.hasProxy) { builder.setProxy(new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort)); } @@ -350,6 +354,7 @@ private HttpClientKey createHttpClientKey(URL url) { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); + int connectTimeout = getConnectTimeout(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); if (!useDynamicProxy) { @@ -358,7 +363,7 @@ private HttpClientKey createHttpClientKey(URL url) { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort); + proxyScheme, proxyHost, proxyPort, connectTimeout); } @Override @@ -368,7 +373,7 @@ protected void notifyFirstSampleAfterLoopRestart() { && RESET_STATE_ON_THREAD_GROUP_ITERATION); } - private void resetStateIfNeeded() { + private static void resetStateIfNeeded() { if (resetStateOnThreadGroupIteration.get()) { closeThreadLocalClients(); ((JsseSSLManager) SSLManager.getInstance()).resetContext(); @@ -406,15 +411,17 @@ private static final class HttpClientKey { private final String proxyScheme; private final String proxyHost; private final int proxyPort; + private final int connectTimeout; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort) { + String proxyHost, int proxyPort, int connectTimeout) { this.protocol = protocol; this.authority = authority; this.hasProxy = hasProxy; this.proxyScheme = proxyScheme; this.proxyHost = proxyHost; this.proxyPort = proxyPort; + this.connectTimeout = connectTimeout; } @Override @@ -425,14 +432,14 @@ public boolean equals(Object object) { if (!(object instanceof HttpClientKey other)) { return false; } - return hasProxy == other.hasProxy && proxyPort == other.proxyPort + return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort); + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout); } } -} \ No newline at end of file +} diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java index 1c889457a3b..a617f13df1d 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -42,4 +42,4 @@ void httpClient5IsSelectable() { void unknownImplementationIsRejected() { assertThrows(IllegalArgumentException.class, () -> HTTPSamplerFactory.newInstance("HttpClient6")); } -} \ No newline at end of file +} From e72755d362328d8c0e88f883381eee8967e677db Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 07:04:02 +0200 Subject: [PATCH 03/31] Add support for DNS resolution and response decompression in HTTPHC5Impl --- .../http/control/DNSCacheManager.java | 12 +- .../protocol/http/sampler/HTTPHC5Impl.java | 111 ++++++++++++++++-- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java index 7af898b28d5..308ca34b2d4 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java @@ -62,7 +62,8 @@ * * @since 2.12 */ -public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver { +public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver, + org.apache.hc.client5.http.DnsResolver { private static final long serialVersionUID = 2122L; @@ -248,6 +249,15 @@ public InetAddress[] resolve(String host) throws UnknownHostException { } } + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + InetAddress[] addresses = resolve(host); + if (addresses == null || addresses.length == 0) { + return host; + } + return addresses[0].getCanonicalHostName(); + } + private static void logCache(String hitOrMiss, String host, InetAddress[] addresses) { if (log.isDebugEnabled()) { log.debug("Cache {} thread#{}: {} => {}", hitOrMiss, JMeterContextService.getContext().getThreadNum(), host, 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 1cf79902b01..0398b0e18fc 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 @@ -20,6 +20,7 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.net.InetAddress; import java.net.URI; import java.net.URL; import java.net.URLDecoder; @@ -27,25 +28,41 @@ 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 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.entity.BrotliInputStreamFactory; +import org.apache.hc.client5.http.entity.DecompressingEntity; +import org.apache.hc.client5.http.entity.DeflateInputStreamFactory; +import org.apache.hc.client5.http.entity.GZIPInputStreamFactory; +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.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.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http.HeaderElement; 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.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.FileEntity; import org.apache.hc.core5.http.io.entity.StringEntity; +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.util.Timeout; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.control.CookieManager; @@ -75,6 +92,52 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final ThreadLocal> HTTP_CLIENTS = ThreadLocal.withInitial(HashMap::new); + private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, + HttpHeaders.CONTENT_MD5}; + + private static final Lookup CONTENT_DECODERS = RegistryBuilder.create() + .register("br", BrotliInputStreamFactory.getInstance()) + .register("gzip", GZIPInputStreamFactory.getInstance()) + .register("x-gzip", GZIPInputStreamFactory.getInstance()) + .register("deflate", DeflateInputStreamFactory.getInstance()) + .build(); + + private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { + HttpClientContext context = scope.clientContext; + RequestConfig requestConfig = context.getRequestConfigOrDefault(); + ClassicHttpResponse response = chain.proceed(request, scope); + HttpEntity entity = response.getEntity(); + if (!requestConfig.isContentCompressionEnabled() || entity == null || entity.getContentLength() == 0 + || entity.getContentEncoding() == null) { + return response; + } + + Header[][] headersToSave = new Header[HEADERS_TO_SAVE.length][]; + for (int i = 0; i < HEADERS_TO_SAVE.length; i++) { + headersToSave[i] = response.getHeaders(HEADERS_TO_SAVE[i]); + } + String contentEncoding = entity.getContentEncoding(); + HeaderElement[] codecs = BasicHeaderValueParser.INSTANCE.parseElements(contentEncoding, + new ParserCursor(0, contentEncoding.length())); + for (HeaderElement codec : codecs) { + InputStreamFactory decoderFactory = CONTENT_DECODERS.lookup(codec.getName().toLowerCase(Locale.ROOT)); + if (decoderFactory != null) { + response.setEntity(new DecompressingEntity(response.getEntity(), decoderFactory)); + response.removeHeaders(HttpHeaders.CONTENT_LENGTH); + response.removeHeaders(HttpHeaders.CONTENT_ENCODING); + response.removeHeaders(HttpHeaders.CONTENT_MD5); + } + } + for (Header[] headers : headersToSave) { + for (Header header : headers) { + if (!response.containsHeader(header.getName())) { + response.addHeader(header); + } + } + } + return response; + }; + private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; protected HTTPHC5Impl(HTTPSamplerBase testElement) { @@ -337,24 +400,41 @@ private static CloseableHttpClient getClient(HttpClientKey key) { private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); + if (key.dnsResolver != null) { + connectionManagerBuilder.setDnsResolver(key.dnsResolver); + } if (key.connectTimeout > 0) { - builder.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create() + connectionManagerBuilder .setDefaultConnectionConfig(ConnectionConfig.custom() .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) - .build()) - .build()); + .build()); } - if (key.hasProxy) { - builder.setProxy(new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort)); - } - return builder.build(); + builder.setConnectionManager(connectionManagerBuilder.build()); + builder.setRoutePlanner(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; + } + + @Override + 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 HttpClientKey createHttpClientKey(URL url) { + private HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); int connectTimeout = getConnectTimeout(); + org.apache.hc.client5.http.DnsResolver dnsResolver = testElement.getDNSResolver(); + InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); if (!useDynamicProxy) { @@ -363,7 +443,7 @@ private HttpClientKey createHttpClientKey(URL url) { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, connectTimeout); + proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, localAddress); } @Override @@ -412,9 +492,12 @@ private static final class HttpClientKey { private final String proxyHost; private final int proxyPort; private final int connectTimeout; + private final org.apache.hc.client5.http.DnsResolver dnsResolver; + private final InetAddress localAddress; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort, int connectTimeout) { + String proxyHost, int proxyPort, int connectTimeout, org.apache.hc.client5.http.DnsResolver dnsResolver, + InetAddress localAddress) { this.protocol = protocol; this.authority = authority; this.hasProxy = hasProxy; @@ -422,6 +505,8 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.proxyHost = proxyHost; this.proxyPort = proxyPort; this.connectTimeout = connectTimeout; + this.dnsResolver = dnsResolver; + this.localAddress = localAddress; } @Override @@ -434,12 +519,14 @@ public boolean equals(Object object) { } return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) - && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost); + && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost) + && Objects.equals(dnsResolver, other.dnsResolver) && Objects.equals(localAddress, other.localAddress); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout); + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, + localAddress); } } } From de30d28009c41bb601400b1d4b7d4ad02e8a778f Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 08:22:58 +0200 Subject: [PATCH 04/31] Refactor `DNSCacheManager` to simplify interface and integrate with `HTTPHC5Impl` for DNS resolution --- .../http/control/DNSCacheManager.java | 12 +------ .../protocol/http/sampler/HTTPHC5Impl.java | 35 ++++++++++++++----- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java index 308ca34b2d4..7af898b28d5 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/control/DNSCacheManager.java @@ -62,8 +62,7 @@ * * @since 2.12 */ -public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver, - org.apache.hc.client5.http.DnsResolver { +public class DNSCacheManager extends ConfigTestElement implements TestIterationListener, Serializable, DnsResolver { private static final long serialVersionUID = 2122L; @@ -249,15 +248,6 @@ public InetAddress[] resolve(String host) throws UnknownHostException { } } - @Override - public String resolveCanonicalHostname(String host) throws UnknownHostException { - InetAddress[] addresses = resolve(host); - if (addresses == null || addresses.length == 0) { - return host; - } - return addresses[0].getCanonicalHostName(); - } - private static void logCache(String hitOrMiss, String host, InetAddress[] addresses) { if (log.isDebugEnabled()) { log.debug("Cache {} thread#{}: {} => {}", hitOrMiss, JMeterContextService.getContext().getThreadNum(), host, 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 0398b0e18fc..687c1c6598f 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 @@ -24,6 +24,7 @@ import java.net.URI; import java.net.URL; import java.net.URLDecoder; +import java.net.UnknownHostException; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; @@ -66,6 +67,7 @@ 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.DNSCacheManager; import org.apache.jmeter.protocol.http.control.HeaderManager; import org.apache.jmeter.protocol.http.util.HTTPArgument; import org.apache.jmeter.protocol.http.util.HTTPConstants; @@ -401,8 +403,8 @@ private static CloseableHttpClient getClient(HttpClientKey key) { private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); - if (key.dnsResolver != null) { - connectionManagerBuilder.setDnsResolver(key.dnsResolver); + if (key.dnsCacheManager != null) { + connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } if (key.connectTimeout > 0) { connectionManagerBuilder @@ -428,12 +430,27 @@ protected InetAddress determineLocalAddress(HttpHost firstHop, .build(); } + private static org.apache.hc.client5.http.DnsResolver createDnsResolver(DNSCacheManager dnsCacheManager) { + return new org.apache.hc.client5.http.DnsResolver() { + @Override + public InetAddress[] resolve(String host) throws UnknownHostException { + return dnsCacheManager.resolve(host); + } + + @Override + public String resolveCanonicalHostname(String host) throws UnknownHostException { + InetAddress[] addresses = resolve(host); + return addresses == null || addresses.length == 0 ? host : addresses[0].getCanonicalHostName(); + } + }; + } + private HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); int connectTimeout = getConnectTimeout(); - org.apache.hc.client5.http.DnsResolver dnsResolver = testElement.getDNSResolver(); + DNSCacheManager dnsCacheManager = testElement.getDNSResolver(); InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); boolean useStaticProxy = isStaticProxy(url.getHost()); @@ -443,7 +460,7 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, localAddress); + proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, localAddress); } @Override @@ -492,11 +509,11 @@ private static final class HttpClientKey { private final String proxyHost; private final int proxyPort; private final int connectTimeout; - private final org.apache.hc.client5.http.DnsResolver dnsResolver; + private final DNSCacheManager dnsCacheManager; private final InetAddress localAddress; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort, int connectTimeout, org.apache.hc.client5.http.DnsResolver dnsResolver, + String proxyHost, int proxyPort, int connectTimeout, DNSCacheManager dnsCacheManager, InetAddress localAddress) { this.protocol = protocol; this.authority = authority; @@ -505,7 +522,7 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.proxyHost = proxyHost; this.proxyPort = proxyPort; this.connectTimeout = connectTimeout; - this.dnsResolver = dnsResolver; + this.dnsCacheManager = dnsCacheManager; this.localAddress = localAddress; } @@ -520,12 +537,12 @@ public boolean equals(Object object) { return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && Objects.equals(protocol, other.protocol) && Objects.equals(authority, other.authority) && Objects.equals(proxyScheme, other.proxyScheme) && Objects.equals(proxyHost, other.proxyHost) - && Objects.equals(dnsResolver, other.dnsResolver) && Objects.equals(localAddress, other.localAddress); + && Objects.equals(dnsCacheManager, other.dnsCacheManager) && Objects.equals(localAddress, other.localAddress); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsResolver, + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, localAddress); } } From 0b533bd591db80e81f1f05dd7e1ecc742bf2831d Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 08:49:03 +0200 Subject: [PATCH 05/31] Handle null `RequestConfig` in `HTTPHC5Impl` to ensure default configuration is used --- .../org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 687c1c6598f..1243f301059 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 @@ -106,7 +106,10 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; - RequestConfig requestConfig = context.getRequestConfigOrDefault(); + RequestConfig requestConfig = context.getRequestConfig(); + if (requestConfig == null) { + requestConfig = RequestConfig.DEFAULT; + } ClassicHttpResponse response = chain.proceed(request, scope); HttpEntity entity = response.getEntity(); if (!requestConfig.isContentCompressionEnabled() || entity == null || entity.getContentLength() == 0 From fa4a0386b73734ccd75e5a8278ab39a1f3e92c0e Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 23 Jul 2026 10:22:15 +0200 Subject: [PATCH 06/31] Add caching and conditional requests support in `HTTPHC5Impl` using `CacheManager` --- .../protocol/http/control/CacheManager.java | 69 ++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 73 ++++++++++++++++--- xdocs/usermanual/component_reference.xml | 20 ++--- xdocs/usermanual/properties_reference.xml | 2 + 4 files changed, 146 insertions(+), 18 deletions(-) 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 8852f6332e4..9e8a2b5ce55 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 @@ -245,6 +245,33 @@ public void saveDetails(HttpResponse method, HTTPSampleResult res) { } } + /** + * Save the Last-Modified, Etag, and Expires headers if the result is + * cacheable. Version for Apache HttpClient 5 implementation. + * + * @param response response to extract header information from + * @param res result to decide if result is cacheable + */ + public void saveDetails(org.apache.hc.core5.http.ClassicHttpResponse response, HTTPSampleResult res) { + final String varyHeader = getHeader(response, HTTPConstants.VARY); + if (isCacheable(res, varyHeader)) { + String lastModified = getHeader(response, HTTPConstants.LAST_MODIFIED); + String expires = getHeader(response, HTTPConstants.EXPIRES); + String etag = getHeader(response, HTTPConstants.ETAG); + String cacheControl = getHeader(response, HTTPConstants.CACHE_CONTROL); + String date = getHeader(response, HTTPConstants.DATE); + 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(); + } + // helper method to save the cache entry private void setCache(String lastModified, String cacheControl, String expires, String etag, String url, String date, Map.Entry varyHeader) { @@ -410,6 +437,29 @@ public void setHeaders(URL url, HttpRequestBase request) { } } + /** + * Check the cache, and if there is a match, set conditional request headers. + * + * @param url URL to look up in cache + * @param request request where to set the headers + */ + public void setHeaders(URL url, org.apache.hc.core5.http.ClassicHttpRequest request) { + CacheEntry entry = getEntry(url.toString(), asHeaders(request.getHeaders())); + if (log.isDebugEnabled()) { + log.debug("setHeaders for HTTP Method:{}(HC5) URL:{} Entry:{}", request.getMethod(), url, entry); + } + if (entry != null) { + String lastModified = entry.getLastModified(); + if (lastModified != null) { + request.setHeader(HTTPConstants.IF_MODIFIED_SINCE, lastModified); + } + String etag = entry.getEtag(); + if (etag != null) { + request.setHeader(HTTPConstants.IF_NONE_MATCH, etag); + } + } + } + /** * Check the cache, and if there is a match, set the headers: *
    @@ -460,6 +510,17 @@ public boolean inCache(URL url, Header[] allHeaders) { return entryStillValid(url, getEntry(url.toString(), allHeaders)); } + /** + * Check whether the URL has a valid entry for the supplied HttpClient 5 request headers. + * + * @param url URL to look up in cache + * @param allHeaders request headers + * @return {@code true} if the matching entry has not expired + */ + public boolean inCache(URL url, org.apache.hc.core5.http.Header[] allHeaders) { + return entryStillValid(url, getEntry(url.toString(), asHeaders(allHeaders))); + } + public boolean inCache(URL url, org.apache.jmeter.protocol.http.control.Header[] allHeaders) { return entryStillValid(url, getEntry(url.toString(), asHeaders(allHeaders))); } @@ -484,6 +545,14 @@ private static Header[] asHeaders(String allHeaders) { return result.toArray(new Header[result.size()]); } + private static Header[] asHeaders(org.apache.hc.core5.http.Header[] allHeaders) { + Header[] result = new Header[allHeaders.length]; + for (int i = 0; i < allHeaders.length; i++) { + result[i] = new BasicHeader(allHeaders[i].getName(), allHeaders[i].getValue()); + } + return result; + } + private static class HeaderAdapter implements Header { private final org.apache.jmeter.protocol.http.control.Header delegate; 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 1243f301059..586cae3e1b0 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 @@ -33,6 +33,8 @@ import java.util.Map; import java.util.Objects; +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; @@ -44,6 +46,7 @@ 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.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; @@ -65,6 +68,8 @@ import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.ParserCursor; import org.apache.hc.core5.util.Timeout; +import org.apache.jmeter.protocol.http.control.AuthManager; +import org.apache.jmeter.protocol.http.control.Authorization; import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.control.CookieManager; import org.apache.jmeter.protocol.http.control.DNSCacheManager; @@ -81,16 +86,12 @@ 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); @@ -162,15 +163,22 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe CacheManager cacheManager = getCacheManager(); if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { - log.debug("Cache Manager is not supported by HttpClient5 yet"); + if (cacheManager.inCache(url, request.getHeaders())) { + return updateSampleResultForResourceInCache(result); + } } currentRequest = request; - response = getClient(createHttpClientKey(url)).executeOpen(null, request, null); + HttpClientKey clientKey = createHttpClientKey(url); + HttpClientContext context = createHttpClientContext(url, clientKey, request); + response = getClient(clientKey).executeOpen(null, request, context); result.sampleEnd(); currentRequest = null; updateResult(response, request, result); + if (cacheManager != null) { + cacheManager.saveDetails(response, result); + } saveConnectionCookies(response, result.getURL(), getCookieManager()); return resultProcessing(areFollowingRedirect, frameDepth, result); } catch (Exception e) { @@ -211,6 +219,10 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht request.setHeader(HTTPConstants.HEADER_CONNECTION, getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); setConnectionHeaders(request, getHeaderManager()); + CacheManager cacheManager = getCacheManager(); + if (cacheManager != null) { + cacheManager.setHeaders(url, request); + } String cookies = setConnectionCookie(request, url, getCookieManager()); if (StringUtilities.isNotEmpty(cookies)) { @@ -334,6 +346,42 @@ private static String setConnectionCookie(org.apache.hc.client5.http.classic.met return cookies; } + private HttpClientContext createHttpClientContext(URL url, HttpClientKey key, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + HttpClientContext context = HttpClientContext.create(); + BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); + configureTargetCredentials(url, request, credentialsProvider); + configureProxyCredentials(key, credentialsProvider); + context.setCredentialsProvider(credentialsProvider); + return context; + } + + private void configureTargetCredentials(URL url, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + BasicCredentialsProvider credentialsProvider) { + AuthManager authManager = getAuthManager(); + Authorization authorization = authManager == null ? null : authManager.getAuthForURL(url); + if (authorization == null) { + return; + } + credentialsProvider.setCredentials(new AuthScope(url.getHost(), getPort(url)), + new UsernamePasswordCredentials(authorization.getUser(), authorization.getPass().toCharArray())); + if (AuthManager.Mechanism.BASIC.equals(authorization.getMechanism())) { + request.setHeader(HttpHeaders.AUTHORIZATION, authorization.toBasicHeader()); + } + } + + private static void configureProxyCredentials(HttpClientKey key, BasicCredentialsProvider credentialsProvider) { + if (key.hasProxy && StringUtilities.isNotEmpty(key.proxyUser)) { + credentialsProvider.setCredentials(new AuthScope(key.proxyHost, key.proxyPort), + new UsernamePasswordCredentials(key.proxyUser, key.proxyPass.toCharArray())); + } + } + + private static int getPort(URL url) { + return url.getPort() == -1 ? url.getDefaultPort() : url.getPort(); + } + private void updateResult(ClassicHttpResponse response, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HTTPSampleResult result) throws IOException { result.setRequestHeaders(getRequestHeaders(request)); @@ -453,6 +501,8 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); int connectTimeout = getConnectTimeout(); + String proxyUser = getProxyUser(); + String proxyPass = getProxyPass(); DNSCacheManager dnsCacheManager = testElement.getDNSResolver(); InetAddress localAddress = getIpSourceAddress(); boolean useDynamicProxy = isDynamicProxy(proxyHost, proxyPort); @@ -463,7 +513,7 @@ private HttpClientKey createHttpClientKey(URL url) throws IOException { proxyPort = PROXY_PORT; } return new HttpClientKey(url.getProtocol(), url.getAuthority(), useDynamicProxy || useStaticProxy, - proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, localAddress); + proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress); } @Override @@ -511,12 +561,14 @@ private static final class HttpClientKey { private final String proxyScheme; private final String proxyHost; private final int proxyPort; + private final String proxyUser; + private final String proxyPass; private final int connectTimeout; private final DNSCacheManager dnsCacheManager; private final InetAddress localAddress; private HttpClientKey(String protocol, String authority, boolean hasProxy, String proxyScheme, - String proxyHost, int proxyPort, int connectTimeout, DNSCacheManager dnsCacheManager, + String proxyHost, int proxyPort, String proxyUser, String proxyPass, int connectTimeout, DNSCacheManager dnsCacheManager, InetAddress localAddress) { this.protocol = protocol; this.authority = authority; @@ -524,6 +576,8 @@ private HttpClientKey(String protocol, String authority, boolean hasProxy, Strin this.proxyScheme = proxyScheme; this.proxyHost = proxyHost; this.proxyPort = proxyPort; + this.proxyUser = proxyUser; + this.proxyPass = proxyPass; this.connectTimeout = connectTimeout; this.dnsCacheManager = dnsCacheManager; this.localAddress = localAddress; @@ -540,12 +594,13 @@ public boolean equals(Object object) { return hasProxy == other.hasProxy && proxyPort == other.proxyPort && connectTimeout == other.connectTimeout && 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); } @Override public int hashCode() { - return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, connectTimeout, dnsCacheManager, + return Objects.hash(protocol, authority, hasProxy, proxyScheme, proxyHost, proxyPort, proxyUser, proxyPass, connectTimeout, dnsCacheManager, localAddress); } } diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index 663b24fe6e4..6c70ff36de1 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -142,6 +142,7 @@ Latency is set to the time it takes to login.
    Java
    uses the HTTP implementation provided by the JVM. This has some limitations in comparison with the HttpClient implementations - see below.
    HTTPClient4
    uses Apache HttpComponents HttpClient 4.x.
    +
    HTTPClient5
    uses Apache HttpComponents HttpClient 5.x.
    Blank Value
    does not set implementation on HTTP Samplers, so relies on HTTP Request Defaults if present or on jmeter.httpsampler property defined in jmeter.properties
    @@ -241,13 +242,13 @@ https.default.protocol=SSLv3 Port the proxy server is listening to. (Optional) username for proxy server. (Optional) password for proxy server. (N.B. this is stored unencrypted in the test plan) - Java, HttpClient4. + 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, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, OPTIONS, PUT, DELETE, PATCH (not supported for - JAVA implementation). With HttpClient4, the following methods related to WebDav are + JAVA implementation). With HttpClient4 or HttpClient5, the following methods related to WebDav are also allowed: COPY, LOCK, MKCOL, MOVE, PROPFIND, PROPPATCH, UNLOCK, REPORT, MKCALENDAR, SEARCH. @@ -484,7 +485,7 @@ so the value may be greater than the number of bytes in the response content.

    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/31] 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: HTTP GET, 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/31] 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/31] 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: HTTP GET, 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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; From d5c9b60a303a44a4b54529c8c1082c03648bcb44 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 08:47:39 +0200 Subject: [PATCH 20/31] Implement HTTP/2 multiplexing and configurable protocol settings in HttpClient5 sampler --- bin/jmeter.properties | 36 +++++ .../protocol/http/sampler/HTTPHC5Impl.java | 148 +++++++++++++++++- .../http/sampler/TestHTTPHC5Features.java | 82 ++++++++++ xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 42 +++++ 5 files changed, 303 insertions(+), 6 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 8820915bb25..30170ccd9f0 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -382,6 +382,42 @@ remote_hosts=127.0.0.1 # Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 +# HTTP/2 settings used by the HttpClient5 sampler implementation + +# Multiplex concurrent message exchanges (e.g. parallel downloads of embedded +# resources) over a single HTTP/2 connection +#httpclient5.h2.multiplexing=true + +# Maximum number of HTTP/2 connections kept per target host. With multiplexing +# enabled a single connection usually serves all concurrent requests +#httpclient5.h2.max_connections_per_route=6 + +# Size in bytes of the HPACK dynamic header table announced to the server. +# 0 disables indexing of headers by the server +#httpclient5.h2.header_table_size=8192 + +# Compress the request headers with HPACK +#httpclient5.h2.header_compression=true + +# Maximum number of concurrent streams the client accepts on a connection +#httpclient5.h2.max_concurrent_streams=250 + +# Flow control window in bytes announced per stream +#httpclient5.h2.initial_window_size=65535 + +# Largest frame payload in bytes the client is willing to receive +#httpclient5.h2.max_frame_size=65536 + +# Accept server push. JMeter cannot report pushed resources, so they are +# dropped, therefore push is disabled by default +#httpclient5.h2.push_enabled=false + +# Use HTTP/2 over cleartext (h2c, prior knowledge) when HTTP/2 is selected for +# a http:// URL. Without it such requests fall back to HTTP/1.1, because +# protocol negotiation requires TLS. Only enable it if all plain HTTP targets +# support h2c +#httpclient5.h2.prior_knowledge=false + # Define characters per second > 0 to emulate slow connections #httpclient.socket.http.cps=0 #httpclient.socket.https.cps=0 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 afcb4642ba9..aae0f69a7c1 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 @@ -20,6 +20,8 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.net.InetAddress; import java.net.URI; import java.net.URL; @@ -28,13 +30,14 @@ 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.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; @@ -96,7 +99,9 @@ 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.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.pool.PoolConcurrencyPolicy; import org.apache.hc.core5.reactor.ConnectionInitiator; import org.apache.hc.core5.ssl.SSLContexts; import org.apache.hc.core5.util.TimeValue; @@ -116,6 +121,7 @@ import org.apache.jmeter.testelement.property.JMeterProperty; import org.apache.jmeter.threads.JMeterContextService; import org.apache.jmeter.threads.JMeterVariables; +import org.apache.jmeter.util.JMeterUtils; import org.apache.jmeter.util.JsseSSLManager; import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; @@ -134,10 +140,66 @@ public class HTTPHC5Impl extends HTTPHCAbstractImpl { static final String CONTEXT_ATTRIBUTE_SAMPLER_RESULT = "__jmeter.S_R__"; //$NON-NLS-1$ private static final ThreadLocal> HTTP_CLIENTS = - ThreadLocal.withInitial(HashMap::new); + new InheritableThreadLocal<>() { + @Override + protected Map initialValue() { + return new ConcurrentHashMap<>(); + } + }; + /** + * HTTP/2 clients are shared with the threads that download embedded resources in parallel, + * so that concurrent requests to the same host can be multiplexed over a single connection. + */ private static final ThreadLocal> HTTP_2_CLIENTS = - ThreadLocal.withInitial(HashMap::new); + new InheritableThreadLocal<>() { + @Override + protected Map initialValue() { + return new ConcurrentHashMap<>(); + } + }; + + /** Multiplex concurrent message exchanges over a single HTTP/2 connection. */ + private static final boolean HTTP_2_MULTIPLEXING = + JMeterUtils.getPropDefault("httpclient5.h2.multiplexing", true); + + /** Size of the HPACK dynamic header table announced to the server, {@code 0} disables HPACK indexing. */ + private static final int HTTP_2_HEADER_TABLE_SIZE = + JMeterUtils.getPropDefault("httpclient5.h2.header_table_size", H2Config.DEFAULT.getHeaderTableSize()); + + /** Whether HPACK compression of the request headers is used. */ + private static final boolean HTTP_2_HEADER_COMPRESSION = + JMeterUtils.getPropDefault("httpclient5.h2.header_compression", true); + + private static final int HTTP_2_MAX_CONCURRENT_STREAMS = + JMeterUtils.getPropDefault("httpclient5.h2.max_concurrent_streams", H2Config.DEFAULT.getMaxConcurrentStreams()); + + private static final int HTTP_2_INITIAL_WINDOW_SIZE = + JMeterUtils.getPropDefault("httpclient5.h2.initial_window_size", H2Config.DEFAULT.getInitialWindowSize()); + + private static final int HTTP_2_MAX_FRAME_SIZE = + JMeterUtils.getPropDefault("httpclient5.h2.max_frame_size", H2Config.DEFAULT.getMaxFrameSize()); + + /** + * JMeter has no way of reporting pushed resources, so server push is switched off to avoid + * the server wasting bandwidth on responses that are dropped. + */ + private static final boolean HTTP_2_PUSH_ENABLED = + JMeterUtils.getPropDefault("httpclient5.h2.push_enabled", false); + + /** + * Use HTTP/2 without TLS (h2c with prior knowledge) when HTTP/2 is selected for a {@code http://} URL. + * Without prior knowledge such requests silently fall back to HTTP/1.1, as ALPN is unavailable. + */ + private static final boolean HTTP_2_PRIOR_KNOWLEDGE = + JMeterUtils.getPropDefault("httpclient5.h2.prior_knowledge", false); + + private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = + JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + + private static final H2Config HTTP_2_CONFIG = createHttp2Config(); + + private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; @@ -205,6 +267,17 @@ private static TlsStrategy createHttp2TlsStrategy() { } } + static H2Config createHttp2Config() { + return H2Config.custom() + .setHeaderTableSize(HTTP_2_HEADER_TABLE_SIZE) + .setCompressionEnabled(HTTP_2_HEADER_COMPRESSION) + .setMaxConcurrentStreams(HTTP_2_MAX_CONCURRENT_STREAMS) + .setInitialWindowSize(HTTP_2_INITIAL_WINDOW_SIZE) + .setMaxFrameSize(HTTP_2_MAX_FRAME_SIZE) + .setPushEnabled(HTTP_2_PUSH_ENABLED) + .build(); + } + protected HTTPHC5Impl(HTTPSamplerBase testElement) { super(testElement); } @@ -272,7 +345,8 @@ 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); + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, + url.getProtocol()); RequestConfig.Builder config = RequestConfig.custom() .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); int responseTimeout = getResponseTimeout(); @@ -622,14 +696,25 @@ private static CloseableHttpClient createClient(HttpClientKey key) { } private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { + boolean multiplexing = HTTP_2_MULTIPLEXING && MESSAGE_MULTIPLEXING_SETTER != null; HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() + .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)); + if (multiplexing) { + // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway + builder.disableConnectionState(); + } PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create(); connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) .build()); + if (multiplexing) { + enableMessageMultiplexing(connectionManagerBuilder); + } + connectionManagerBuilder.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX); + connectionManagerBuilder.setMaxConnPerRoute(HTTP_2_MAX_CONNECTIONS_PER_ROUTE); if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } @@ -644,6 +729,28 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { return asyncClient; } + /** + * Looks up {@code PoolingAsyncClientConnectionManagerBuilder#setMessageMultiplexing(boolean)}, + * which is only available from HttpClient 5.5 onwards. JMeter still runs with older HttpClient + * versions, where HTTP/2 requests just cannot share a connection. + */ + private static Method findMessageMultiplexingSetter() { + try { + return PoolingAsyncClientConnectionManagerBuilder.class.getMethod("setMessageMultiplexing", boolean.class); + } catch (NoSuchMethodException e) { + log.info("HTTP/2 message multiplexing is unavailable, HttpClient 5.5 or later is required"); + return null; + } + } + + private static void enableMessageMultiplexing(PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder) { + try { + MESSAGE_MULTIPLEXING_SETTER.invoke(connectionManagerBuilder, true); + } catch (IllegalAccessException | InvocationTargetException e) { + log.warn("Could not enable HTTP/2 message multiplexing", e); + } + } + @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) @@ -658,7 +765,7 @@ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, } Future responseFuture = client.execute(asyncRequest, context, null); try { - return createClassicResponse(responseFuture.get(1, java.util.concurrent.TimeUnit.MINUTES)); + return createClassicResponse(responseFuture.get(getHttp2ExecutionTimeoutMillis(request), TimeUnit.MILLISECONDS)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while executing HTTP/2 request", e); @@ -670,6 +777,20 @@ private static ClassicHttpResponse executeHttp2(CloseableHttpAsyncClient client, } } + /** + * The future is only a safety net: HttpClient enforces the configured response timeout itself, + * so this waits a little longer than the sampler is willing to wait for the response. + */ + private static long getHttp2ExecutionTimeoutMillis( + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + RequestConfig requestConfig = request.getConfig(); + Timeout responseTimeout = requestConfig == null ? null : requestConfig.getResponseTimeout(); + if (responseTimeout == null || responseTimeout.toMilliseconds() <= 0) { + return TimeUnit.MINUTES.toMillis(1); + } + return responseTimeout.toMilliseconds() + TimeUnit.SECONDS.toMillis(5); + } + private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyncResponse) { BasicClassicHttpResponse response = new BasicClassicHttpResponse(asyncResponse.getCode(), asyncResponse.getReasonPhrase()); @@ -725,7 +846,8 @@ 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); + HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), HTTP_VERSION, + url.getProtocol()); if (!useDynamicProxy) { proxyScheme = PROXY_SCHEME; proxyHost = PROXY_HOST; @@ -742,6 +864,20 @@ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } + /** + * Determines the version policy for a request to the given scheme. Plain HTTP does not support + * ALPN, so HTTP/2 can only be used over {@code http://} when the client assumes that the server + * speaks HTTP/2 (h2c with prior knowledge). + */ + static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion, String scheme) { + HttpVersionPolicy policy = getHttpVersionPolicy(samplerHttpVersion, defaultHttpVersion); + if (policy == HttpVersionPolicy.NEGOTIATE && HTTP_2_PRIOR_KNOWLEDGE + && !HTTPConstants.PROTOCOL_HTTPS.equalsIgnoreCase(scheme)) { + return HttpVersionPolicy.FORCE_HTTP_2; + } + return policy; + } + @Override protected void notifyFirstSampleAfterLoopRestart() { JMeterVariables variables = JMeterContextService.getContext().getVariables(); 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 7abf55a6bd7..4f208247a56 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 @@ -32,6 +32,12 @@ import java.io.InputStream; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.io.ConnectionEndpoint; @@ -40,6 +46,7 @@ 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.http2.config.H2Config; import org.apache.hc.core5.io.CloseMode; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; @@ -71,6 +78,81 @@ void defaultsToHttp11ForUnsupportedHttpVersion() { assertEquals(HttpVersionPolicy.FORCE_HTTP_1, HTTPHC5Impl.getHttpVersionPolicy("HTTP/3", "HTTP/2")); } + @Test + void negotiatesHttp2OverTls() { + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1", "https")); + } + + @Test + void doesNotUsePriorKnowledgeForCleartextByDefault() { + assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1", "http")); + } + + @Test + void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try { + server.stubFor(get(urlEqualTo("/multiplexed")) + .willReturn(aResponse().withStatus(200).withFixedDelay(200))); + URL url = new URL("https://localhost:" + server.httpsPort() + "/multiplexed"); + // warm up, so the connection that gets shared by the concurrent samples is already established + HTTPSamplerBase warmUpSampler = newSampler(); + warmUpSampler.setHttpVersion("HTTP/2"); + assertEquals("200", warmUpSampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + + int requests = 4; + ExecutorService executor = Executors.newFixedThreadPool(requests); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < requests; i++) { + results.add(executor.submit(() -> { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + return sampler.sample(url, HTTPConstants.GET, false, 1); + })); + } + for (Future result : results) { + HTTPSampleResult sampleResult = result.get(30, TimeUnit.SECONDS); + assertEquals("200", sampleResult.getResponseCode()); + assertEquals("HTTP/2", sampleResult.getResponseHeaders().substring(0, "HTTP/2".length())); + } + } finally { + executor.shutdownNow(); + } + } finally { + server.stop(); + } + } + + @Test + void enablesMessageMultiplexingWithoutRequiringHttpClient55() throws Exception { + byte[] classBytes; + try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { + classBytes = classFile.readAllBytes(); + } + assertTrue(new String(classBytes, StandardCharsets.ISO_8859_1).contains("setMessageMultiplexing"), + "HTTP/2 message multiplexing should be enabled"); + assertFalse(hasMethodReference(classBytes, + "org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManagerBuilder", + "setMessageMultiplexing"), + "setMessageMultiplexing was added in HttpClient 5.5 and must not be linked directly"); + } + + @Test + void appliesHttp2ProtocolSettings() { + H2Config config = HTTPHC5Impl.createHttp2Config(); + + assertEquals(8192, config.getHeaderTableSize(), "HPACK dynamic table size"); + assertTrue(config.isCompressionEnabled(), "HPACK header compression"); + assertEquals(250, config.getMaxConcurrentStreams()); + assertEquals(65535, config.getInitialWindowSize()); + assertEquals(65536, config.getMaxFrameSize()); + assertFalse(config.isPushEnabled(), "server push is dropped by JMeter, so it must not be announced"); + } + @Test void doesNotRequireProtocolUpgradeConfiguration() throws Exception { try (InputStream classFile = HTTPHC5Impl.class.getResourceAsStream("HTTPHC5Impl.class")) { diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 988377c81b1..1c1aa0e8415 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -81,6 +81,7 @@ Summary

  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • +
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.

Timers, Assertions, Config, Pre- & Post-Processors

diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 721a41d46bf..53d9c980c82 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -446,6 +446,48 @@ JMETER-SERVER Valid values are HTTP/1.1 and HTTP/2.
Defaults to: HTTP/1.1 + + Multiplex concurrent message exchanges (for example parallel downloads of embedded resources) + over a single HTTP/2 connection.
+ Defaults to: true +
+ + Maximum number of HTTP/2 connections kept per target host. With multiplexing enabled a single + connection usually serves all concurrent requests.
+ Defaults to: 6 +
+ + Size in bytes of the HPACK dynamic header table announced to the server. + 0 disables indexing of the headers by the server.
+ Defaults to: 8192 +
+ + Compress the request headers with HPACK.
+ Defaults to: true +
+ + Maximum number of concurrent streams the client accepts on a single HTTP/2 connection.
+ Defaults to: 250 +
+ + HTTP/2 flow control window in bytes announced per stream.
+ Defaults to: 65535 +
+ + Largest HTTP/2 frame payload in bytes the client is willing to receive.
+ Defaults to: 65536 +
+ + Accept HTTP/2 server push. JMeter cannot report pushed resources, so they are dropped, + therefore push is disabled by default.
+ Defaults to: false +
+ + Use HTTP/2 over cleartext (h2c with prior knowledge) when HTTP/2 is selected for a + http:// URL. Without it such requests fall back to HTTP/1.1, because protocol + negotiation requires TLS. Only enable it if all plain HTTP targets support h2c.
+ Defaults to: false +
Set characters per second to a value greater then zero to emulate slow connections.
Defaults to: 0 From 64a3a247fef841f9abab9ea0f78b10f83a6015dc Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 10:09:42 +0200 Subject: [PATCH 21/31] Add configurable User-Agent header handling in HttpClient5 sampler --- bin/jmeter.properties | 3 ++ .../protocol/http/sampler/HTTPHC5Impl.java | 47 +++++++++++++++++++ xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 5 ++ 4 files changed, 56 insertions(+) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 30170ccd9f0..de3c73a64df 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -418,6 +418,9 @@ remote_hosts=127.0.0.1 # support h2c #httpclient5.h2.prior_knowledge=false +# If true, default HC5 User-Agent will not be added +#httpclient5.default_user_agent_disabled=false + # Define characters per second > 0 to emulate slow connections #httpclient.socket.http.cps=0 #httpclient.socket.https.cps=0 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 aae0f69a7c1..27a07217c7b 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 @@ -84,6 +84,7 @@ 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.HttpRequestInterceptor; import org.apache.hc.core5.http.HttpVersion; import org.apache.hc.core5.http.NameValuePair; import org.apache.hc.core5.http.config.Lookup; @@ -106,6 +107,7 @@ import org.apache.hc.core5.ssl.SSLContexts; import org.apache.hc.core5.util.TimeValue; import org.apache.hc.core5.util.Timeout; +import org.apache.hc.core5.util.VersionInfo; import org.apache.jmeter.protocol.http.control.AuthManager; import org.apache.jmeter.protocol.http.control.Authorization; import org.apache.jmeter.protocol.http.control.CacheManager; @@ -197,6 +199,31 @@ protected Map initialValue() { private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + /** + * Name of the property that suppresses the {@code User-Agent} header HttpClient sends when the + * test plan does not define one itself. + */ + private static final String DISABLE_DEFAULT_UA_PROPERTY = "httpclient5.default_user_agent_disabled"; + + /** + * {@code User-Agent} JMeter adds to requests without one, so it shows up in the sample result and + * is accounted for in the sent bytes, instead of being added invisibly by HttpClient. + */ + private static final String DEFAULT_USER_AGENT = VersionInfo.getSoftwareInfo("Apache-HttpClient", + "org.apache.hc.client5", org.apache.hc.client5.http.impl.classic.HttpClientBuilder.class); + + /** Remembers whether the request had a {@code User-Agent} header before HttpClient added its own. */ + private static final String CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT = "__jmeter.U_A__"; + + private static final HttpRequestInterceptor RECORD_USER_AGENT_PRESENCE = (request, entity, context) -> + context.setAttribute(CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT, request.containsHeader(HttpHeaders.USER_AGENT)); + + private static final HttpRequestInterceptor REMOVE_DEFAULT_USER_AGENT = (request, entity, context) -> { + if (!Boolean.TRUE.equals(context.getAttribute(CONTEXT_ATTRIBUTE_USER_AGENT_PRESENT))) { + request.removeHeaders(HttpHeaders.USER_AGENT); + } + }; + private static final H2Config HTTP_2_CONFIG = createHttp2Config(); private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); @@ -361,6 +388,7 @@ private void setupRequest(URL url, org.apache.hc.client5.http.classic.methods.Ht request.setVersion(HttpVersion.HTTP_2); } setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy); + setDefaultUserAgent(request); CacheManager cacheManager = getCacheManager(); if (cacheManager != null) { cacheManager.setHeaders(url, request); @@ -478,6 +506,13 @@ private static void setConnectionHeaders(org.apache.hc.client5.http.classic.meth } } + private static void setDefaultUserAgent(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { + if (isDefaultUserAgentDisabled() || request.containsHeader(HttpHeaders.USER_AGENT)) { + return; + } + request.setHeader(HttpHeaders.USER_AGENT, DEFAULT_USER_AGENT); + } + private static String setConnectionCookie(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, URL url, CookieManager cookieManager) { if (cookieManager == null) { @@ -673,8 +708,15 @@ private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { return clients.computeIfAbsent(key, HTTPHC5Impl::createHttp2Client); } + private static boolean isDefaultUserAgentDisabled() { + return JMeterUtils.getPropDefault(DISABLE_DEFAULT_UA_PROPERTY, false); + } + private static CloseableHttpClient createClient(HttpClientKey key) { org.apache.hc.client5.http.impl.classic.HttpClientBuilder builder = HttpClients.custom().disableAutomaticRetries(); + if (isDefaultUserAgentDisabled()) { + builder.disableDefaultUserAgent(); + } PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = PoolingHttpClientConnectionManagerBuilder.create(); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() .setVersionPolicy(key.httpVersionPolicy) @@ -705,6 +747,11 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { // A connection bound to a user token cannot be shared, and HTTP/2 has no connection scoped state anyway builder.disableConnectionState(); } + if (isDefaultUserAgentDisabled()) { + // HttpAsyncClientBuilder has no disableDefaultUserAgent, so the generated header is dropped again + builder.addRequestInterceptorFirst(RECORD_USER_AGENT_PRESENCE); + builder.addRequestInterceptorLast(REMOVE_DEFAULT_USER_AGENT); + } PoolingAsyncClientConnectionManagerBuilder connectionManagerBuilder = PoolingAsyncClientConnectionManagerBuilder.create(); connectionManagerBuilder.setTlsStrategy(HTTP_2_TLS_STRATEGY); connectionManagerBuilder.setDefaultTlsConfig(TlsConfig.custom() diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 1c1aa0e8415..f1373f24b6c 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -82,6 +82,7 @@ Summary
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • +
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 53d9c980c82..9bf279bab65 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -488,6 +488,11 @@ JMETER-SERVER negotiation requires TLS. Only enable it if all plain HTTP targets support h2c.
    Defaults to: false
    + + If true, the default HC5 User-Agent (Apache-HttpClient/X.Y.Z (Java/A.B.C)) will not be added. + A User-Agent header defined in the test plan is still sent.
    + Defaults to: false +
    Set characters per second to a value greater then zero to emulate slow connections.
    Defaults to: 0 From c2d10d5a80088af90f0aec5a04440655a75db5fc Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 10:55:29 +0200 Subject: [PATCH 22/31] Enhance HTTP/2 support in Java sampler with multiplexing and configurable settings --- bin/jmeter.properties | 37 +++++ .../protocol/http/sampler/HTTPJavaImpl.java | 134 +++++++++++++--- .../http/sampler/TestHTTPJavaFeatures.java | 148 +++++++++++++++++- xdocs/changes.xml | 1 + xdocs/usermanual/properties_reference.xml | 47 ++++++ 5 files changed, 344 insertions(+), 23 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index de3c73a64df..aacff1c37eb 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -418,6 +418,43 @@ remote_hosts=127.0.0.1 # support h2c #httpclient5.h2.prior_knowledge=false +# HTTP/2 settings used by the Java sampler implementation. Except for the +# multiplexing they are applied as jdk.httpclient.* system properties, so a +# setting given on the command line always takes precedence + +# Multiplex concurrent message exchanges (e.g. requests of different threads or +# parallel downloads of embedded resources) over a single HTTP/2 connection. +# Requests which start before the first HTTP/2 connection to a host has been +# established still open a connection of their own +#http.java.h2.multiplexing=true + +# Size in bytes of the HPACK dynamic header table announced to the server. +# 0 disables indexing of headers by the server (JDK default: 16384) +#http.java.h2.header_table_size=16384 + +# Maximum number of server initiated (pushed) streams the client accepts on a +# connection (JDK default: 100 with server push enabled, 0 otherwise) +#http.java.h2.max_concurrent_streams=100 + +# Flow control window in bytes announced per stream (JDK default: 16777216) +#http.java.h2.initial_window_size=16777216 + +# Flow control window in bytes announced for the whole connection, it must not +# be smaller than the stream window (JDK default: 67108864) +#http.java.h2.connection_window_size=67108864 + +# Largest frame payload in bytes the client is willing to receive, between +# 16384 and 16777215 (JDK default: 16384) +#http.java.h2.max_frame_size=16384 + +# Seconds an idle HTTP/2 connection is kept in the pool (JDK default: the value +# of jdk.httpclient.keepalive.timeout, which defaults to 30) +#http.java.h2.keep_alive_timeout=30 + +# Accept server push. JMeter cannot report pushed resources, so they are +# dropped, therefore push is disabled by default +#http.java.h2.push_enabled=false + # If true, default HC5 User-Agent will not be added #httpclient5.default_user_agent_disabled=false 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 c60bf4c1fec..81847ee1232 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 @@ -47,6 +47,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -100,7 +102,14 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ private static final ThreadLocal> HTTP_2_CLIENTS = - ThreadLocal.withInitial(HashMap::new); + ThreadLocal.withInitial(ConcurrentHashMap::new); + + /** + * HTTP/2 clients used when multiplexing is enabled. The JDK client sends every exchange of a client + * over a single connection per origin, so one shared instance lets the requests of all JMeter threads + * and of the threads which download embedded resources in parallel share a connection. + */ + private static final Map SHARED_HTTP_2_CLIENTS = new ConcurrentHashMap<>(); /** * Shared daemon thread pool used by the HTTP/2 {@link HttpClient} instances. It allows JMeter to observe @@ -111,6 +120,81 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final Logger log = LoggerFactory.getLogger(HTTPJavaImpl.class); + /** Multiplex concurrent HTTP/2 message exchanges over a single connection per origin. */ + private static final boolean HTTP_2_MULTIPLEXING = + JMeterUtils.getPropDefault("http.java.h2.multiplexing", true); // $NON-NLS-1$ + + /** + * Accept HTTP/2 server push. JMeter has no way of reporting pushed resources, so push is switched off + * to avoid the server wasting bandwidth on responses that are dropped. + */ + private static final String HTTP_2_PUSH_ENABLED_PROPERTY = "http.java.h2.push_enabled"; // $NON-NLS-1$ + + /** System property the JDK client reads to decide whether it announces {@code SETTINGS_ENABLE_PUSH}. */ + private static final String JDK_PUSH_ENABLED_PROPERTY = "jdk.httpclient.enablepush"; // $NON-NLS-1$ + + /** + * Maps the JMeter HTTP/2 properties to the {@code jdk.httpclient.*} system properties, which are the only + * way to configure the HTTP/2 protocol settings of the JDK client. They are read whenever a client is + * built, so they have to be in place before the first HTTP/2 sample is taken. + */ + private static final Map HTTP_2_SYSTEM_PROPERTIES = createHttp2SystemPropertyMapping(); + + static { + applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); + } + + private static Map createHttp2SystemPropertyMapping() { + Map mapping = new LinkedHashMap<>(); + // Size of the HPACK dynamic header table announced to the server, 0 disables HPACK indexing + mapping.put("http.java.h2.header_table_size", "jdk.httpclient.hpack.maxheadertablesize"); // $NON-NLS-1$ $NON-NLS-2$ + // Number of server initiated (pushed) streams the client accepts on a connection + mapping.put("http.java.h2.max_concurrent_streams", "jdk.httpclient.maxstreams"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.initial_window_size", "jdk.httpclient.windowsize"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.connection_window_size", "jdk.httpclient.connectionWindowSize"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.max_frame_size", "jdk.httpclient.maxframesize"); // $NON-NLS-1$ $NON-NLS-2$ + mapping.put("http.java.h2.keep_alive_timeout", "jdk.httpclient.keepalive.timeout.h2"); // $NON-NLS-1$ $NON-NLS-2$ + return Collections.unmodifiableMap(mapping); + } + + /** + * Copies the HTTP/2 settings from the JMeter properties to the system properties of the JDK client. + * Values which are already defined, for example on the command line, are never overwritten. + * + * @param jmeterProperties the JMeter properties, may be {@code null} when they have not been loaded + * @param systemProperties the system properties to configure + */ + static void applyHttp2SystemProperties(Properties jmeterProperties, Properties systemProperties) { + Properties source = jmeterProperties != null ? jmeterProperties : new Properties(); + for (Map.Entry entry : HTTP_2_SYSTEM_PROPERTIES.entrySet()) { + String value = source.getProperty(entry.getKey()); + if (StringUtilities.isNotBlank(value)) { + setUnlessDefined(systemProperties, entry.getValue(), value.trim()); + } + } + boolean pushEnabled = Boolean.parseBoolean(source.getProperty(HTTP_2_PUSH_ENABLED_PROPERTY, "false")); // $NON-NLS-1$ + setUnlessDefined(systemProperties, JDK_PUSH_ENABLED_PROPERTY, pushEnabled ? "1" : "0"); // $NON-NLS-1$ $NON-NLS-2$ + } + + private static void setUnlessDefined(Properties systemProperties, String name, String value) { + String current = systemProperties.getProperty(name); + if (current == null) { + systemProperties.setProperty(name, value); + } else if (!current.equals(value)) { + log.info("Keeping system property {}={}, it takes precedence over the JMeter property", name, current); // $NON-NLS-1$ + } + } + + /** + * Returns the HTTP/2 clients of the current thread, or the shared ones when the exchanges of all threads + * should be multiplexed over a single connection. + * + * @return the map the HTTP/2 clients are cached in + */ + static Map getHttp2Clients() { + return HTTP_2_MULTIPLEXING ? SHARED_HTTP_2_CLIENTS : HTTP_2_CLIENTS.get(); + } + /** * 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. @@ -990,7 +1074,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin try { response = client.httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofInputStream()); } finally { - connectTimeTracker.sampleFinished(); + connectTimeTracker.sampleFinished(res); } res.latencyEnd(); @@ -1273,7 +1357,7 @@ private Http2Client getHttpClient(URL url) { HttpClientKey key = new HttpClientKey(connectTimeout, proxyHost, proxyPort, proxyUser, proxyPass, autoRedirects, sslContext); - return HTTP_2_CLIENTS.get().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); + return getHttp2Clients().computeIfAbsent(key, HTTPJavaImpl::createHttpClient); } private static Http2Client createHttpClient(HttpClientKey key) { @@ -1324,45 +1408,51 @@ private static final class Http2Client { } /** - * Records the connect time of the JDK {@link HttpClient} in the current {@link SampleResult}. + * Records the connect time of the JDK {@link HttpClient} in the {@link SampleResult}s which are in flight. *

    * 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. + *

    + * A multiplexed client is shared by several threads, so more than one sample can be in flight. Since the + * connection is established for all of them at once, the connect time is recorded in every sample which + * is waiting for it. */ static final class ConnectTimeTracker { - private volatile SampleResult sampleResult; - private volatile boolean connectRecorded; - private volatile boolean handshakeRecorded; + private static final Integer NOTHING_RECORDED = 0; + private static final Integer CONNECT_RECORDED = 1; + private static final Integer HANDSHAKE_RECORDED = 2; + + /** Samples which are currently in flight, together with the connect event recorded for them. */ + private final Map activeSamples = new ConcurrentHashMap<>(); void sampleStarted(SampleResult result) { - this.connectRecorded = false; - this.handshakeRecorded = false; - this.sampleResult = result; + activeSamples.put(result, NOTHING_RECORDED); } - void sampleFinished() { - this.sampleResult = null; + void sampleFinished(SampleResult result) { + activeSamples.remove(result); } /** Invoked when the TCP connection has been established. */ void connectionEstablished() { - SampleResult result = sampleResult; - if (result != null && !connectRecorded && !handshakeRecorded) { - connectRecorded = true; - result.connectEnd(); - } + recordConnectEnd(CONNECT_RECORDED); } /** 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; + recordConnectEnd(HANDSHAKE_RECORDED); + } + + private void recordConnectEnd(Integer event) { + activeSamples.replaceAll((result, recorded) -> { + if (recorded.intValue() >= event.intValue()) { + return recorded; + } result.connectEnd(); - } + return event; + }); } } 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 d008a94431b..280eece0b69 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 @@ -23,6 +23,8 @@ 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.net.Socket; @@ -32,6 +34,15 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; @@ -110,6 +121,141 @@ void returnsEmptyReasonPhraseForUnknownStatusCode() { assertEquals("", HTTPJavaImpl.getReasonPhrase(599)); } + @Test + void mapsHttp2SettingsToJdkSystemProperties() { + Properties jmeterProperties = new Properties(); + jmeterProperties.setProperty("http.java.h2.header_table_size", "8192"); + jmeterProperties.setProperty("http.java.h2.max_concurrent_streams", "250"); + jmeterProperties.setProperty("http.java.h2.initial_window_size", "65535"); + jmeterProperties.setProperty("http.java.h2.connection_window_size", "1048576"); + jmeterProperties.setProperty("http.java.h2.max_frame_size", "16384"); + jmeterProperties.setProperty("http.java.h2.keep_alive_timeout", "60"); + jmeterProperties.setProperty("http.java.h2.push_enabled", "true"); + Properties systemProperties = new Properties(); + + HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); + + assertEquals("8192", systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); + assertEquals("250", systemProperties.getProperty("jdk.httpclient.maxstreams")); + assertEquals("65535", systemProperties.getProperty("jdk.httpclient.windowsize")); + assertEquals("1048576", systemProperties.getProperty("jdk.httpclient.connectionWindowSize")); + assertEquals("16384", systemProperties.getProperty("jdk.httpclient.maxframesize")); + assertEquals("60", systemProperties.getProperty("jdk.httpclient.keepalive.timeout.h2")); + assertEquals("1", systemProperties.getProperty("jdk.httpclient.enablepush")); + } + + @Test + void disablesServerPushAndKeepsJdkDefaultsWhenNothingIsConfigured() { + Properties systemProperties = new Properties(); + + HTTPJavaImpl.applyHttp2SystemProperties(new Properties(), systemProperties); + + assertEquals("0", systemProperties.getProperty("jdk.httpclient.enablepush")); + assertNull(systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); + assertNull(systemProperties.getProperty("jdk.httpclient.windowsize")); + } + + @Test + void doesNotOverrideHttp2SettingsGivenOnTheCommandLine() { + Properties jmeterProperties = new Properties(); + jmeterProperties.setProperty("http.java.h2.header_table_size", "8192"); + Properties systemProperties = new Properties(); + systemProperties.setProperty("jdk.httpclient.hpack.maxheadertablesize", "4096"); + systemProperties.setProperty("jdk.httpclient.enablepush", "1"); + + HTTPJavaImpl.applyHttp2SystemProperties(jmeterProperties, systemProperties); + + assertEquals("4096", systemProperties.getProperty("jdk.httpclient.hpack.maxheadertablesize")); + assertEquals("1", systemProperties.getProperty("jdk.httpclient.enablepush")); + } + + @Test + void sharesHttp2ClientsBetweenThreadsForMultiplexing() throws Exception { + Map clientsOfMainThread = HTTPJavaImpl.getHttp2Clients(); + AtomicReference> clientsOfOtherThread = new AtomicReference<>(); + + Thread thread = new Thread(() -> clientsOfOtherThread.set(HTTPJavaImpl.getHttp2Clients())); + thread.start(); + thread.join(); + + assertSame(clientsOfMainThread, clientsOfOtherThread.get(), + "HTTP/2 clients should be shared, so concurrent requests are multiplexed over one connection"); + } + + @Test + void multiplexesConcurrentHttp2RequestsOverOneConnection() throws Exception { + WireMockServer server = createServer(); + server.start(); + try { + server.stubFor(get(urlEqualTo("/multiplexed")) + .willReturn(aResponse().withStatus(200).withFixedDelay(200).withBody("multiplexed"))); + + int requests = 4; + ExecutorService executor = Executors.newFixedThreadPool(requests); + try { + List> results = new ArrayList<>(); + for (int i = 0; i < requests; i++) { + results.add(executor.submit(() -> { + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + return sampler.sample( + new URL(server.url("/multiplexed")), HTTPConstants.GET, false, 1); + })); + } + for (Future result : results) { + HTTPSampleResult sampleResult = result.get(60, TimeUnit.SECONDS); + assertEquals("200", sampleResult.getResponseCode()); + assertTrue(sampleResult.getResponseHeaders().startsWith("HTTP/2"), + "Response should have been received over HTTP/2, but was " + + sampleResult.getResponseHeaders()); + } + } finally { + executor.shutdownNow(); + } + } finally { + server.stop(); + } + } + + @Test + void recordsConnectTimeForEveryMultiplexedSample() throws Exception { + HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + SampleResult first = new SampleResult(); + SampleResult second = new SampleResult(); + first.sampleStart(); + second.sampleStart(); + tracker.sampleStarted(first); + tracker.sampleStarted(second); + + Thread.sleep(5); + tracker.connectionEstablished(); + Thread.sleep(5); + + first.sampleEnd(); + second.sampleEnd(); + tracker.sampleFinished(first); + tracker.sampleFinished(second); + + assertTrue(first.getConnectTime() > 0, "connectTime of the first sample should have been recorded"); + assertTrue(second.getConnectTime() > 0, "connectTime of the second sample should have been recorded"); + assertTrue(first.getConnectTime() <= first.getTime()); + assertTrue(second.getConnectTime() <= second.getTime()); + } + + @Test + void ignoresSamplesWhichAreNoLongerInFlight() { + HTTPJavaImpl.ConnectTimeTracker tracker = new HTTPJavaImpl.ConnectTimeTracker(); + SampleResult result = new SampleResult(); + result.sampleStart(); + tracker.sampleStarted(result); + tracker.sampleFinished(result); + + tracker.connectionEstablished(); + result.sampleEnd(); + + assertEquals(0, result.getConnectTime()); + } + @Test void usesHttp2WithProxy() throws Exception { WireMockServer server = createServer(); @@ -325,7 +471,7 @@ void setsConnectTimeForHttp2OverTls() throws Exception { "https://localhost:" + server.httpsPort() + "/http2TlsConnectTime")).build(), HttpResponse.BodyHandlers.ofString()); } finally { - tracker.sampleFinished(); + tracker.sampleFinished(result); } result.sampleEnd(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index f1373f24b6c..fd725e9d4eb 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -82,6 +82,7 @@ Summary

  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • +
  • Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 9bf279bab65..15b9f557cae 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -493,6 +493,53 @@ JMETER-SERVER A User-Agent header defined in the test plan is still sent.
    Defaults to: false
    + + Multiplex concurrent message exchanges (for example the requests of different threads or parallel + downloads of embedded resources) of the Java sampler implementation over a single HTTP/2 + connection per origin. Requests which start before the first HTTP/2 connection to a host has been + established still open a connection of their own.
    + Defaults to: true +
    + + Size in bytes of the HPACK dynamic header table the Java sampler implementation announces to the + server. 0 disables indexing of the headers by the server. It is applied as the + jdk.httpclient.hpack.maxheadertablesize system property.
    + Defaults to the JDK default: 16384 +
    + + Maximum number of server initiated (pushed) streams the Java sampler implementation accepts on a + single HTTP/2 connection. It is applied as the jdk.httpclient.maxstreams system + property.
    + Defaults to the JDK default: 100 with server push enabled, 0 otherwise +
    + + HTTP/2 flow control window in bytes the Java sampler implementation announces per stream. It is + applied as the jdk.httpclient.windowsize system property.
    + Defaults to the JDK default: 16777216 +
    + + HTTP/2 flow control window in bytes the Java sampler implementation announces for the whole + connection, it must not be smaller than the stream window. It is applied as the + jdk.httpclient.connectionWindowSize system property.
    + Defaults to the JDK default: 67108864 +
    + + Largest HTTP/2 frame payload in bytes the Java sampler implementation is willing to receive, + between 16384 and 16777215. It is applied as the + jdk.httpclient.maxframesize system property.
    + Defaults to the JDK default: 16384 +
    + + Seconds an idle HTTP/2 connection of the Java sampler implementation is kept in the pool. It is + applied as the jdk.httpclient.keepalive.timeout.h2 system property.
    + Defaults to the JDK default: the value of jdk.httpclient.keepalive.timeout +
    + + Accept HTTP/2 server push in the Java sampler implementation. JMeter cannot report pushed + resources, so they are dropped, therefore push is disabled by default. It is applied as the + jdk.httpclient.enablepush system property.
    + Defaults to: false +
    Set characters per second to a value greater then zero to emulate slow connections.
    Defaults to: 0 From 4cf64cfaf73a58df2f678649239c687bad0e21fa Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 11:11:48 +0200 Subject: [PATCH 23/31] Add default User-Agent header handling for Java sampler in HTTP/1.1 and HTTP/2 --- .../protocol/http/sampler/HTTPJavaImpl.java | 35 +++++++++++++++++++ xdocs/changes.xml | 1 + 2 files changed, 36 insertions(+) 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 81847ee1232..ce41c1a0903 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 @@ -101,6 +101,26 @@ public class HTTPJavaImpl extends HTTPAbstractImpl { private static final String DEFAULT_HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", HTTPConstants.HTTP_1_1); // $NON-NLS-1$ + /** Name of the {@code User-Agent} request header. */ + private static final String HEADER_USER_AGENT = "User-Agent"; // $NON-NLS-1$ + + /** + * {@code User-Agent} of {@link HttpURLConnection}, which is used for HTTP/1.1. JMeter adds it to the + * request itself, so it shows up in the sample result and is accounted for in the sent bytes, instead of + * being added invisibly by the JDK. + */ + private static final String HTTP_1_DEFAULT_USER_AGENT = createHttp1DefaultUserAgent(); + + /** {@code User-Agent} of {@link HttpClient}, which is used for HTTP/2. */ + private static final String HTTP_2_DEFAULT_USER_AGENT = + "Java-http-client/" + System.getProperty("java.version"); // $NON-NLS-1$ + + private static String createHttp1DefaultUserAgent() { + String javaAgent = "Java/" + System.getProperty("java.version"); // $NON-NLS-1$ + String agent = System.getProperty("http.agent"); // $NON-NLS-1$ + return agent == null ? javaAgent : agent + " " + javaAgent; + } + private static final ThreadLocal> HTTP_2_CLIENTS = ThreadLocal.withInitial(ConcurrentHashMap::new); @@ -420,6 +440,7 @@ protected HttpURLConnection setupConnection(URL u, String method, HTTPSampleResu String cookies = setConnectionCookie(conn, u, getCookieManager()); setConnectionAuthorization(conn, u, getAuthManager(), securityHeaders); + setDefaultUserAgent(conn, HTTP_1_DEFAULT_USER_AGENT); if (method.equals(HTTPConstants.POST)) { setPostHeaders(conn); @@ -690,6 +711,19 @@ private static String getFromConnectionHeaders(HttpURLConnection conn, MapHttpURLConnection passed in. @@ -1015,6 +1049,7 @@ private HTTPSampleResult sampleHttp2(URL url, String method, boolean areFollowin securityHeaders = setConnectionHeaders(capturingConn, url, getHeaderManager(), getCacheManager()); String cookies = setConnectionCookie(capturingConn, url, getCookieManager()); setConnectionAuthorization(capturingConn, url, getAuthManager(), securityHeaders); + setDefaultUserAgent(capturingConn, HTTP_2_DEFAULT_USER_AGENT); if (method.equals(HTTPConstants.POST)) { setPostHeaders(capturingConn); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index fd725e9d4eb..2ef22a20b9f 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -84,6 +84,7 @@ Summary
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • +
  • Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • Timers, Assertions, Config, Pre- & Post-Processors

    From 16d03cdca985cfea35fd1f8168d8de287a34e3ac Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Fri, 31 Jul 2026 12:00:47 +0200 Subject: [PATCH 24/31] Add HTTP/2 core dependency for enhanced support in HttpClient5 --- src/bom-thirdparty/build.gradle.kts | 1 + src/protocol/http/build.gradle.kts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts index c31f64f9711..6c74f7e13d9 100644 --- a/src/bom-thirdparty/build.gradle.kts +++ b/src/bom-thirdparty/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { } api("org.apache.httpcomponents.client5:httpclient5:5.5.1") api("org.apache.httpcomponents.core5:httpcore5:5.3.4") + 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/protocol/http/build.gradle.kts b/src/protocol/http/build.gradle.kts index 800054537e7..d8d5c20017e 100644 --- a/src/protocol/http/build.gradle.kts +++ b/src/protocol/http/build.gradle.kts @@ -63,6 +63,9 @@ dependencies { implementation("dnsjava:dnsjava") implementation("org.apache.httpcomponents.client5:httpclient5") implementation("org.apache.httpcomponents.core5:httpcore5") + implementation("org.apache.httpcomponents.core5:httpcore5-h2") { + because("HTTPHC5Impl uses H2Config and HttpVersionPolicy from org.apache.hc.core5.http2") + } implementation("org.apache.httpcomponents:httpmime") implementation("org.apache.httpcomponents:httpcore") implementation("org.brotli:dec") From 9cfa7b3bc3d14fb6bd6edd051464906c4c8631e2 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 11 Aug 2026 06:32:35 +0200 Subject: [PATCH 25/31] Update Apache HttpClient 5 and HttpCore 5 dependencies to 5.6.4 and 5.4.3 respectively for improved SSL parameter handling in HTTP/2 --- src/bom-thirdparty/build.gradle.kts | 6 +++--- .../protocol/http/sampler/HTTPHC5Impl.java | 21 +++++++++++++++---- xdocs/changes.xml | 1 + 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/bom-thirdparty/build.gradle.kts b/src/bom-thirdparty/build.gradle.kts index 6c74f7e13d9..62bca7e461c 100644 --- a/src/bom-thirdparty/build.gradle.kts +++ b/src/bom-thirdparty/build.gradle.kts @@ -106,9 +106,9 @@ dependencies { api("org.apache.commons:commons-text:1.14.0") { because("User might still rely on commons-text") } - api("org.apache.httpcomponents.client5:httpclient5:5.5.1") - api("org.apache.httpcomponents.core5:httpcore5:5.3.4") - api("org.apache.httpcomponents.core5:httpcore5-h2:5.3.4") + api("org.apache.httpcomponents.client5:httpclient5:5.6.4") + api("org.apache.httpcomponents.core5:httpcore5:5.4.3") + api("org.apache.httpcomponents.core5:httpcore5-h2:5.4.3") 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/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 27a07217c7b..456381461ce 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 @@ -51,7 +51,6 @@ 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; import org.apache.hc.client5.http.entity.GZIPInputStreamFactory; @@ -74,6 +73,7 @@ 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.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.client5.http.ssl.TrustAllStrategy; import org.apache.hc.core5.concurrent.FutureCallback; @@ -128,6 +128,7 @@ import org.apache.jmeter.util.SSLManager; import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; +import org.brotli.dec.BrotliInputStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -231,8 +232,16 @@ protected Map initialValue() { private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, HttpHeaders.CONTENT_MD5}; + // HttpClient 5.6 switched BrotliInputStreamFactory to the optional brotli4j library, so decode "br" + // with the org.brotli:dec library JMeter ships, like HTTPHC4Impl does. + @SuppressWarnings("deprecation") + private static final InputStreamFactory BROTLI = BrotliInputStream::new; + + // The InputStreamFactory-based decoders are deprecated in favour of the @Internal ContentCodecRegistry, + // so keep using them until a public replacement is available. + @SuppressWarnings("deprecation") private static final Lookup CONTENT_DECODERS = RegistryBuilder.create() - .register("br", BrotliInputStreamFactory.getInstance()) + .register("br", BROTLI) .register("gzip", GZIPInputStreamFactory.getInstance()) .register("x-gzip", GZIPInputStreamFactory.getInstance()) .register("deflate", DeflateInputStreamFactory.getInstance()) @@ -240,6 +249,7 @@ protected Map initialValue() { private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy(); + @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; RequestConfig requestConfig = context.getRequestConfig(); @@ -281,14 +291,17 @@ protected Map initialValue() { 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) + // Leave hostname verification to the no-op verifier below. Without CLIENT the policy would + // default to BOTH, and the JSSE built-in endpoint identification would reject the + // self-signed certificates JMeter deliberately accepts when testing. + .setHostVerificationPolicy(HostnameVerificationPolicy.CLIENT) .setHostnameVerifier(NoopHostnameVerifier.INSTANCE) - .build(); + .buildAsync(); } catch (GeneralSecurityException e) { throw new IllegalStateException("Could not create HTTP/2 TLS strategy", e); } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 2ef22a20b9f..758f08c5d3b 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -102,6 +102,7 @@ Summary
  • Update json-path to 2.10.0 for JSON query expressions.
  • Update Neo4j Java driver to 6.x for Bolt-based database tests.
  • Update Rhino JavaScript engine to 1.8.0 for JSR-223 JavaScript execution.
  • +
  • Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • UI

    From cf2d748ac73a7881bc78ee11983e4cbbf6f440cb Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 11 Aug 2026 07:46:55 +0200 Subject: [PATCH 26/31] Enhance HTTP/2 response handling by implementing custom decompression and ensuring consistent header reporting across transports --- .../protocol/http/sampler/HTTPHC5Impl.java | 30 ++++++-- .../http/sampler/TestDecompression.java | 70 +++++++++++++++++++ 2 files changed, 94 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 456381461ce..7ef191e536a 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 @@ -249,7 +249,6 @@ protected Map initialValue() { private static final TlsStrategy HTTP_2_TLS_STRATEGY = createHttp2TlsStrategy(); - @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry private static final ExecChainHandler RESPONSE_CONTENT_ENCODING = (request, scope, chain) -> { HttpClientContext context = scope.clientContext; RequestConfig requestConfig = context.getRequestConfig(); @@ -257,9 +256,22 @@ protected Map initialValue() { requestConfig = RequestConfig.DEFAULT; } ClassicHttpResponse response = chain.proceed(request, scope); + if (!requestConfig.isContentCompressionEnabled()) { + return response; + } + return decompressResponse(response); + }; + + /** + * Decodes the response body while keeping the {@code Content-Encoding}, {@code Content-Length} and + * {@code Content-MD5} headers, so the sample result still reports what the server actually sent. + * JMeter therefore disables the transparent decompression of HttpClient, which drops those headers, + * and decodes the response itself for both the HTTP/1.1 and the HTTP/2 transport. + */ + @SuppressWarnings("deprecation") // DecompressingEntity is superseded by the @Internal ContentCodecRegistry + private static ClassicHttpResponse decompressResponse(ClassicHttpResponse response) { HttpEntity entity = response.getEntity(); - if (!requestConfig.isContentCompressionEnabled() || entity == null || entity.getContentLength() == 0 - || entity.getContentEncoding() == null) { + if (entity == null || entity.getContentLength() == 0 || entity.getContentEncoding() == null) { return response; } @@ -287,7 +299,7 @@ protected Map initialValue() { } } return response; - }; + } private volatile org.apache.hc.client5.http.classic.methods.HttpUriRequestBase currentRequest; @@ -754,6 +766,10 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { boolean multiplexing = HTTP_2_MULTIPLEXING && MESSAGE_MULTIPLEXING_SETTER != null; HttpAsyncClientBuilder builder = HttpAsyncClients.custom() .disableAutomaticRetries() + // HttpClient 5.6 added transparent content compression to the async transport. JMeter decodes + // the response itself, so the HTTP/2 sampler reports the same headers as the HTTP/1.1 one and + // does not depend on the optional codec libraries HttpClient detects on the classpath. + .disableContentCompression() .setH2Config(HTTP_2_CONFIG) .setRoutePlanner(createRoutePlanner(key)); if (multiplexing) { @@ -860,9 +876,11 @@ private static ClassicHttpResponse createClassicResponse(SimpleHttpResponse asyn } byte[] responseBody = asyncResponse.getBodyBytes(); if (responseBody != null) { - response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType())); + response.setEntity(new ByteArrayEntity(responseBody, asyncResponse.getContentType(), + asyncResponse.getFirstHeader(HttpHeaders.CONTENT_ENCODING) == null ? null + : asyncResponse.getFirstHeader(HttpHeaders.CONTENT_ENCODING).getValue())); } - return response; + return decompressResponse(response); } private static DefaultRoutePlanner createRoutePlanner(HttpClientKey key) { diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java index 82d22bda273..e733f497778 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestDecompression.java @@ -112,6 +112,76 @@ public void mockServer(String httpImpl, ClientGzip clientGzip, ServerGzip server } } + public static List http2Params() { + List res = new ArrayList<>(); + for (ClientGzip clientGzip : ClientGzip.values()) { + for (ServerGzip serverGzip : ServerGzip.values()) { + res.add(Arguments.of(clientGzip, serverGzip)); + } + } + return res; + } + + /** + * HttpClient5 uses the async transport for HTTP/2, which decompresses responses in a different + * code path than the HTTP/1.1 transport, so both have to report the same headers and body. + */ + @ParameterizedTest + @MethodSource("http2Params") + public void http2(ClientGzip clientGzip, ServerGzip serverGzip) throws MalformedURLException { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false) + .gzipDisabled(serverGzip == ServerGzip.NOT_SUPPORTED)); + server.start(); + try { + HTTPSamplerBase http = HTTPSamplerFactory.newInstance(HTTPSamplerFactory.IMPL_HTTP_CLIENT5); + http.setHttpVersion("HTTP/2"); + String expectedResponse = "Hello, 丈, \uD83D\uDE03, and नि"; + HeaderManager hm = new HeaderManager(); + if (clientGzip == ClientGzip.REQUESTED) { + hm.add(new Header("Accept-Encoding", "gzip")); + } + hm.add(new Header("Content-Encoding", "utf-8")); + http.setHeaderManager(hm); + MappingBuilder mappingBuilder = WireMock.get("/gzip"); + if (clientGzip == ClientGzip.REQUESTED) { + mappingBuilder = mappingBuilder.withHeader("Accept-Encoding", WireMock.equalTo("gzip")); + } + server.stubFor( + mappingBuilder + .willReturn( + WireMock.aResponse() + .withBody(expectedResponse) + .withHeader("Content-Type", "text/plain;charset=utf-8") + ) + ); + + HTTPSampleResult res = http.sample( + new URL("https://localhost:" + server.httpsPort() + "/gzip"), "GET", false, 1); + + Assertions.assertAll( + () -> assertEquals(expectedResponse, res.getResponseDataAsString(), "response body"), + () -> { + // HTTP/2 header names are lower case, so compare them ignoring case + if (clientGzip == ClientGzip.NOT_REQUESTED || serverGzip == ServerGzip.NOT_SUPPORTED) { + assertFalse( + StringsKt.contains(res.getResponseHeaders(), "Content-Encoding:", true), + () -> "clientGzip is " + clientGzip + ", so Content-Encoding header should NOT be present" + ); + } else { + assertTrue( + StringsKt.contains(res.getResponseHeaders(), "Content-Encoding: gzip", true), + () -> "clientGzip is " + clientGzip + ", so Content-Encoding: gzip header should be present" + ); + } + } + ); + } finally { + server.stop(); + } + } + private static WireMockServer createServer(Consumer config) { WireMockConfiguration configuration = WireMockConfiguration From 24b89960cee2b8a6b5aea2fabec537f9417169f7 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Tue, 11 Aug 2026 13:40:12 +0200 Subject: [PATCH 27/31] Re-validate pooled HTTP/2 connections after inactivity to prevent failures from closed connections --- bin/jmeter.properties | 7 + .../protocol/http/sampler/HTTPHC5Impl.java | 30 ++- .../http/sampler/TestHTTPHC5Features.java | 197 ++++++++++++++++++ xdocs/changes.xml | 5 + xdocs/usermanual/properties_reference.xml | 8 + 5 files changed, 240 insertions(+), 7 deletions(-) diff --git a/bin/jmeter.properties b/bin/jmeter.properties index aacff1c37eb..454ae4744bf 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -382,6 +382,13 @@ remote_hosts=127.0.0.1 # Valid values are HTTP/1.1 and HTTP/2 (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 +# Milliseconds of inactivity after which a pooled connection of the HttpClient5 +# sampler implementation is re-validated before it is reused (HTTP/2 PING, or a +# stale check for HTTP/1.1), -1 disables the check. Without it a connection the +# server closed while the thread was idle is reused as it is and fails the +# sample, as JMeter deliberately does not retry requests +#httpclient5.validate_after_inactivity=2000 + # HTTP/2 settings used by the HttpClient5 sampler implementation # Multiplex concurrent message exchanges (e.g. parallel downloads of embedded 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 7ef191e536a..9fe5c703293 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 @@ -200,6 +200,17 @@ protected Map initialValue() { private static final int HTTP_2_MAX_CONNECTIONS_PER_ROUTE = JMeterUtils.getPropDefault("httpclient5.h2.max_connections_per_route", 6); + /** + * Milliseconds of inactivity after which a pooled connection is re-validated before it is used + * again, {@code -1} disables the check. JMeter keeps a client per thread and reuses its + * connections across iterations, so a connection the server (or an idle timeout of a load + * balancer) closed in between would otherwise be handed out as-is and fail the next sample. + * HttpClient leaves this undefined by default, which skips the check altogether, and the + * automatic retries that would hide such a failure are deliberately disabled. + */ + private static final long VALIDATE_AFTER_INACTIVITY = + JMeterUtils.getPropDefault("httpclient5.validate_after_inactivity", 2000L); + /** * Name of the property that suppresses the {@code User-Agent} header HttpClient sends when the * test plan does not define one itself. @@ -749,12 +760,12 @@ private static CloseableHttpClient createClient(HttpClientKey key) { if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } + ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() + .setValidateAfterInactivity(TimeValue.ofMilliseconds(VALIDATE_AFTER_INACTIVITY)); if (key.connectTimeout > 0) { - connectionManagerBuilder - .setDefaultConnectionConfig(ConnectionConfig.custom() - .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) - .build()); + connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } + connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build()); builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build())); builder.setRoutePlanner(createRoutePlanner(key)); return builder.disableContentCompression() @@ -794,11 +805,16 @@ private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { if (key.dnsCacheManager != null) { connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); } + // Without this an HTTP/2 connection is leased straight out of the pool, and a connection the + // server closed while the thread was idle only shows up when the request is written to it, + // which fails the sample with a ConnectionClosedException. The check sends an HTTP/2 PING + // and replaces the connection if the peer does not answer. + ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() + .setValidateAfterInactivity(TimeValue.ofMilliseconds(VALIDATE_AFTER_INACTIVITY)); if (key.connectTimeout > 0) { - connectionManagerBuilder.setDefaultConnectionConfig(ConnectionConfig.custom() - .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) - .build()); + connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } + connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build()); builder.setConnectionManager(new ConnectTimeMeasuringAsyncConnectionManager(connectionManagerBuilder.build())); CloseableHttpAsyncClient asyncClient = builder.build(); asyncClient.start(); 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 4f208247a56..b63f901cb18 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 @@ -27,9 +27,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; +import java.io.Closeable; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -38,6 +43,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.apache.hc.client5.http.HttpRoute; import org.apache.hc.client5.http.io.ConnectionEndpoint; @@ -54,6 +60,8 @@ import org.apache.jmeter.protocol.http.control.CacheManager; import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.samplers.SampleResult; +import org.apache.jmeter.util.JMeterUtils; +import org.apache.jorphan.util.JOrphanUtils; import org.junit.jupiter.api.Test; import com.github.tomakehurst.wiremock.WireMockServer; @@ -442,6 +450,195 @@ void setsConnectTimeForHttp2() throws Exception { } } + /** + * A pooled HTTP/2 connection that the server dropped while the thread was idle must not be handed + * out as it is. What triggers this is the idle gap between two samples, not the number of requests, + * so the test waits out {@code httpclient5.validate_after_inactivity} between the two samples and + * only then lets the connection die, while the client still believes it is usable. + * + *

    Without the re-validation the second request is written to that connection and the sample + * fails with {@code ConnectionClosedException}, which is what + * {@link #doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap()} pins down. + */ + @Test + void revalidatesPooledHttp2ConnectionAfterIdleGap() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try (ConnectionDroppingRelay relay = new ConnectionDroppingRelay(server.httpsPort())) { + server.stubFor(get(urlEqualTo("/idle")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setResponseTimeout("30000"); + URL url = new URL("https://localhost:" + relay.getPort() + "/idle"); + + assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + + Thread.sleep(validateAfterInactivityMillis() + 500); + relay.dropConnectionOnNextRequest(); + + // the same sampler on the same thread, so the cached HTTP/2 client and its pool are reused + HTTPSampleResult result = sampler.sample(url, HTTPConstants.GET, false, 1); + + assertEquals("200", result.getResponseCode(), + "the stale pooled connection should have been replaced, but the sample failed with " + + result.getResponseMessage()); + assertEquals(2, relay.getAcceptedConnections(), + "the stale connection should have been discarded and replaced by a new one"); + } finally { + server.stop(); + } + } + + /** + * Counterpart of {@link #revalidatesPooledHttp2ConnectionAfterIdleGap()} which documents the + * behaviour the re-validation fixes: without an idle gap the connection is leased without being + * checked, so the request itself runs into the connection the server has already given up on. + */ + @Test + void doesNotRevalidatePooledHttp2ConnectionWithoutAnIdleGap() throws Exception { + WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() + .dynamicHttpsPort() + .http2TlsDisabled(false)); + server.start(); + try (ConnectionDroppingRelay relay = new ConnectionDroppingRelay(server.httpsPort())) { + server.stubFor(get(urlEqualTo("/busy")).willReturn(aResponse().withStatus(200))); + HTTPSamplerBase sampler = newSampler(); + sampler.setHttpVersion("HTTP/2"); + sampler.setResponseTimeout("30000"); + URL url = new URL("https://localhost:" + relay.getPort() + "/busy"); + + assertEquals("200", sampler.sample(url, HTTPConstants.GET, false, 1).getResponseCode()); + relay.dropConnectionOnNextRequest(); + + HTTPSampleResult result = sampler.sample(url, HTTPConstants.GET, false, 1); + + assertFalse(result.isSuccessful(), + "a connection reused within the validation interval is not checked, so the request " + + "is expected to run into the dropped connection"); + } finally { + server.stop(); + } + } + + private static long validateAfterInactivityMillis() { + return JMeterUtils.getPropDefault("httpclient5.validate_after_inactivity", 2000L); + } + + /** + * Relays TCP traffic to a backend server and can drop a single relayed connection as soon as the + * client writes to it again, which is how an idle connection closed by the server (or by a load + * balancer) looks to a client that has not noticed the close yet. New connections are relayed as + * usual, so the backend stays reachable. + */ + private static final class ConnectionDroppingRelay implements Closeable { + + private final ServerSocket serverSocket; + private final int backendPort; + private final ExecutorService executor = Executors.newCachedThreadPool(runnable -> { + Thread thread = new Thread(runnable, "relay"); + thread.setDaemon(true); + return thread; + }); + private final AtomicInteger acceptedConnections = new AtomicInteger(); + private volatile RelayedConnection currentConnection; + + ConnectionDroppingRelay(int backendPort) throws IOException { + this.backendPort = backendPort; + this.serverSocket = new ServerSocket(0, 0, InetAddress.getLoopbackAddress()); + executor.execute(this::acceptConnections); + } + + int getPort() { + return serverSocket.getLocalPort(); + } + + int getAcceptedConnections() { + return acceptedConnections.get(); + } + + void dropConnectionOnNextRequest() { + RelayedConnection connection = currentConnection; + if (connection == null) { + throw new IllegalStateException("No connection has been relayed yet"); + } + connection.doomed = true; + } + + private void acceptConnections() { + while (!serverSocket.isClosed()) { + RelayedConnection connection; + try { + Socket client = serverSocket.accept(); + connection = new RelayedConnection(client, + new Socket(InetAddress.getLoopbackAddress(), backendPort)); + } catch (IOException e) { + return; + } + acceptedConnections.incrementAndGet(); + currentConnection = connection; + executor.execute(connection::relayRequests); + executor.execute(connection::relayResponses); + } + } + + @Override + public void close() throws IOException { + serverSocket.close(); + executor.shutdownNow(); + } + } + + private static final class RelayedConnection { + + private final Socket client; + private final Socket backend; + private volatile boolean doomed; + + RelayedConnection(Socket client, Socket backend) { + this.client = client; + this.backend = backend; + } + + void relayRequests() { + try { + InputStream input = client.getInputStream(); + OutputStream output = backend.getOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1 && !doomed) { + output.write(buffer, 0, read); + output.flush(); + } + } catch (IOException ignored) { // NOSONAR the connection is closed below in any case + // the peer went away, which ends the relaying just as well + } + close(); + } + + void relayResponses() { + try { + InputStream input = backend.getInputStream(); + OutputStream output = client.getOutputStream(); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + output.flush(); + } + } catch (IOException ignored) { // NOSONAR the connection is closed below in any case + // the peer went away, which ends the relaying just as well + } + close(); + } + + private void close() { + JOrphanUtils.closeQuietly(client); + JOrphanUtils.closeQuietly(backend); + } + } + private static HTTPSamplerBase newSampler() { return HTTPSamplerFactory.newInstance("HttpClient5"); } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 758f08c5d3b..16c0eec982d 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -121,6 +121,11 @@ Summary

  • 6620Fix report generation paths so dashboard output files are created in the correct location after internal refactoring.
  • 6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
  • + +

    HTTP Samplers and Test Script Recorder

    +
      +
    • Re-validate pooled connections of the HttpClient5 sampler implementation after they have been idle, so a connection the server closed in between two samples is replaced instead of failing the next sample with Could not execute HTTP/2 request. The period of inactivity can be configured with the new httpclient5.validate_after_inactivity property.
    • +
    Thanks diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index 15b9f557cae..c9ce02d3f7a 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -446,6 +446,14 @@ JMETER-SERVER Valid values are HTTP/1.1 and HTTP/2.
    Defaults to: HTTP/1.1
    + + Milliseconds of inactivity after which a pooled connection of the HttpClient5 sampler + implementation is re-validated before it is reused, using an HTTP/2 PING or a stale + check for HTTP/1.1. -1 disables the check. Without it a connection the server closed + while the thread was idle is reused as it is and fails the sample, as JMeter deliberately does not + retry requests.
    + Defaults to: 2000 +
    Multiplex concurrent message exchanges (for example parallel downloads of embedded resources) over a single HTTP/2 connection.
    From f5a3743e98325a72f36d8f465bcaa31be1888316 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 12 Aug 2026 08:50:25 +0200 Subject: [PATCH 28/31] Group and reorganize HTTP/2 sampler changes in `changes.xml` for clarity and update contributors list --- xdocs/changes.xml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 16c0eec982d..fd74c83143b 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -81,10 +81,14 @@ Summary
  • 6250Avoid adding "; charset=" automatically to multipart/form-data requests to align behavior with modern HTTP clients.
  • 6080Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)
  • 62676268Add a space between key and value after : in View Results Tree > Sampler result tab for better readability.
  • -
  • Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties.
  • -
  • Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
  • -
  • Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • -
  • Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • + + +

    HTTP/2 support for the HttpClient5 and Java sampler implementations:

    +
      +
    • 6742Multiplex concurrent HTTP/2 requests of the HttpClient5 sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size and compression, maximum concurrent streams, flow control window, maximum frame size, server push and h2c prior knowledge) configurable with the new httpclient5.h2.* properties. Pooled connections are re-validated after the period of inactivity configured with the new httpclient5.validate_after_inactivity property, so a connection the server closed in between two samples is replaced instead of failing the next sample.
    • +
    • 6742Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
    • +
    • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
    • +
    • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.

    Timers, Assertions, Config, Pre- & Post-Processors

    @@ -102,7 +106,7 @@ Summary
  • Update json-path to 2.10.0 for JSON query expressions.
  • Update Neo4j Java driver to 6.x for Bolt-based database tests.
  • Update Rhino JavaScript engine to 1.8.0 for JSR-223 JavaScript execution.
  • -
  • Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • +
  • 6742Update Apache HttpClient 5 to 5.6.4 and Apache HttpCore 5 to 5.4.3, which corrects the application of SSL parameters in the async TLS upgrade method used by the HttpClient5 sampler implementation for HTTP/2.
  • UI

    @@ -122,10 +126,6 @@ Summary
  • 6456Handle malformed percent-encoded URLs gracefully when recording HTTP traffic, logging a warning instead of failing the recording.
  • -

    HTTP Samplers and Test Script Recorder

    -
      -
    • Re-validate pooled connections of the HttpClient5 sampler implementation after they have been idle, so a connection the server closed in between two samples is replaced instead of failing the next sample with Could not execute HTTP/2 request. The period of inactivity can be configured with the new httpclient5.validate_after_inactivity property.
    • -
    Thanks @@ -137,6 +137,7 @@ Summary
  • Gabriele Coletta (github.com/gdmg92)
  • Patrick Uiterwijk (patrick at puiterwijk.org)
  • Piotr P. Karwasz (github.com/piotrgithub)
  • +
  • Andreas Lind (github.com/andreaslind01)
  • We also thank bug reporters who helped us improve JMeter.

      From d890c2f870e4846cbbab0f0708d50f4cf75c49fb Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Wed, 12 Aug 2026 13:59:12 +0200 Subject: [PATCH 29/31] Add support for Kerberos authentication in HttpClient5 sampler implementation --- .../protocol/http/sampler/HTTPHC5Impl.java | 238 +++++++++++++++++- .../http/sampler/TestHTTPHC5Features.java | 53 ++++ xdocs/changes.xml | 1 + xdocs/usermanual/component_reference.xml | 2 +- 4 files changed, 280 insertions(+), 14 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 9fe5c703293..c17fccd043b 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 @@ -29,7 +29,11 @@ import java.net.UnknownHostException; import java.nio.charset.Charset; import java.security.GeneralSecurityException; +import java.security.Principal; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -41,11 +45,20 @@ import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLContext; +import javax.security.auth.Subject; +import org.apache.hc.client5.http.DnsResolver; import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.SystemDefaultDnsResolver; 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.AuthSchemeFactory; import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.Credentials; +import org.apache.hc.client5.http.auth.CredentialsProvider; +import org.apache.hc.client5.http.auth.KerberosConfig; +import org.apache.hc.client5.http.auth.KerberosCredentials; +import org.apache.hc.client5.http.auth.StandardAuthScheme; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; import org.apache.hc.client5.http.classic.ExecChainHandler; import org.apache.hc.client5.http.config.ConnectionConfig; @@ -61,6 +74,11 @@ 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.auth.BasicSchemeFactory; +import org.apache.hc.client5.http.impl.auth.BearerSchemeFactory; +import org.apache.hc.client5.http.impl.auth.DigestSchemeFactory; +import org.apache.hc.client5.http.impl.auth.KerberosSchemeFactory; +import org.apache.hc.client5.http.impl.auth.SPNegoSchemeFactory; 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; @@ -129,6 +147,10 @@ import org.apache.jorphan.util.JOrphanUtils; import org.apache.jorphan.util.StringUtilities; import org.brotli.dec.BrotliInputStream; +import org.ietf.jgss.GSSCredential; +import org.ietf.jgss.GSSException; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.Oid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -238,6 +260,43 @@ protected Map initialValue() { private static final H2Config HTTP_2_CONFIG = createHttp2Config(); + /** + * Request that the Kerberos credentials of the JMeter user are delegated to the server, so + * that it can act on behalf of the user, see the {@code kerberos.spnego.delegate_cred} + * property. + */ + private static final boolean KERBEROS_DELEGATE_CRED = + JMeterUtils.getPropDefault("kerberos.spnego.delegate_cred", false); + + /** + * HttpClient 5 only offers {@code Bearer}, {@code Digest} and {@code Basic} by default, so the + * negotiation based schemes have to be requested explicitly for a Kerberos authorization. + */ + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + private static final List KERBEROS_PREFERRED_AUTH_SCHEMES = + List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS); + + private static final Oid SPNEGO_OID = createOid("1.3.6.1.5.5.2"); + + private static final Oid KERBEROS_OID = createOid("1.2.840.113554.1.2.2"); + + /** + * Credentials without a principal, telling the negotiation based auth schemes to use the + * credentials of the JAAS {@link Subject} the request is executed with. + */ + private static final Credentials USE_JAAS_CREDENTIALS = new Credentials() { + @Override + public Principal getUserPrincipal() { + return null; + } + + @Override + @SuppressWarnings("deprecation") // Credentials.getPassword is deprecated, but has to be implemented + public char[] getPassword() { + return new char[0]; + } + }; + private static final Method MESSAGE_MULTIPLEXING_SETTER = findMessageMultiplexingSetter(); private static final String[] HEADERS_TO_SAVE = {HttpHeaders.CONTENT_LENGTH, HttpHeaders.CONTENT_ENCODING, @@ -367,9 +426,7 @@ protected HTTPSampleResult sample(URL url, String method, boolean areFollowingRe 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); + response = executeRequest(url, clientKey, request, context); result.sampleEnd(); currentRequest = null; @@ -406,6 +463,37 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre return new org.apache.hc.client5.http.classic.methods.HttpUriRequestBase(method, uri); } + /** + * Executes the request under the JAAS {@link Subject} of the {@link AuthManager}, if the URL is + * covered by a Kerberos authorization, so that the auth scheme can use its credentials. + */ + private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) + throws IOException { + Subject subject = getSubjectForUrl(url); + if (subject == null) { + return doExecuteRequest(clientKey, request, context); + } + try { + return Subject.doAs(subject, (PrivilegedExceptionAction) () -> + doExecuteRequest(clientKey, request, context)); + } catch (PrivilegedActionException e) { + Exception cause = e.getException(); + if (cause instanceof IOException ioException) { + throw ioException; + } + throw new IOException("Could not execute the request with subject " + subject, cause); + } + } + + private static ClassicHttpResponse doExecuteRequest(HttpClientKey clientKey, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) + throws IOException { + return clientKey.httpVersionPolicy != HttpVersionPolicy.FORCE_HTTP_1 + ? executeHttp2(getHttp2Client(clientKey), request, context) + : getClient(clientKey).executeOpen(null, request, context); + } + 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, @@ -561,22 +649,36 @@ private static String setConnectionCookie(org.apache.hc.client5.http.classic.met return cookies; } - private HttpClientContext createHttpClientContext(URL url, HttpClientKey key, + HttpClientContext createHttpClientContext(URL url, HttpClientKey key, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request) { HttpClientContext context = HttpClientContext.create(); BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider(); - configureTargetCredentials(url, request, credentialsProvider); + Authorization authorization = getAuthorizationForUrl(url); + configureTargetCredentials(url, request, credentialsProvider, authorization); configureProxyCredentials(key, credentialsProvider); - context.setCredentialsProvider(credentialsProvider); + if (isKerberos(authorization)) { + configureKerberos(url, request, context, credentialsProvider); + } else { + context.setCredentialsProvider(credentialsProvider); + } return context; } - private void configureTargetCredentials(URL url, - org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - BasicCredentialsProvider credentialsProvider) { + private Authorization getAuthorizationForUrl(URL url) { AuthManager authManager = getAuthManager(); - Authorization authorization = authManager == null ? null : authManager.getAuthForURL(url); - if (authorization == null) { + return authManager == null ? null : authManager.getAuthForURL(url); + } + + private static boolean isKerberos(Authorization authorization) { + return authorization != null && AuthManager.Mechanism.KERBEROS.equals(authorization.getMechanism()); + } + + private static void configureTargetCredentials(URL url, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + BasicCredentialsProvider credentialsProvider, Authorization authorization) { + if (authorization == null || isKerberos(authorization)) { + // The credentials of a Kerberos authorization are used to log in to the KDC, they must + // not be sent to the server return; } credentialsProvider.setCredentials(new AuthScope(url.getHost(), getPort(url)), @@ -586,6 +688,116 @@ private void configureTargetCredentials(URL url, } } + /** + * Sets up the {@code Negotiate} and {@code Kerberos} auth schemes for a single request, as + * HttpClient 5 neither registers them nor prefers them by default. The credentials are taken + * from the JAAS {@link Subject} the {@link AuthManager} logged the user in with. + */ + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + private void configureKerberos(URL url, + org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + HttpClientContext context, CredentialsProvider credentialsProvider) { + KerberosConfig kerberosConfig = KerberosConfig.custom() + .setStripPort(isStripPort(url)) + .setUseCanonicalHostname(AuthManager.USE_CANONICAL_HOST_NAME) + .setRequestDelegCreds(KERBEROS_DELEGATE_CRED + ? KerberosConfig.Option.ENABLE : KerberosConfig.Option.DEFAULT) + .build(); + DnsResolver dnsResolver = SystemDefaultDnsResolver.INSTANCE; + context.setAuthSchemeRegistry(RegistryBuilder.create() + .register(StandardAuthScheme.BASIC, BasicSchemeFactory.INSTANCE) + .register(StandardAuthScheme.DIGEST, DigestSchemeFactory.INSTANCE) + .register(StandardAuthScheme.BEARER, BearerSchemeFactory.INSTANCE) + .register(StandardAuthScheme.SPNEGO, new SPNegoSchemeFactory(kerberosConfig, dnsResolver)) + .register(StandardAuthScheme.KERBEROS, new KerberosSchemeFactory(kerberosConfig, dnsResolver)) + .build()); + context.setCredentialsProvider( + new KerberosCredentialsProvider(getSubjectForUrl(url), credentialsProvider)); + RequestConfig requestConfig = request.getConfig() == null ? RequestConfig.DEFAULT : request.getConfig(); + request.setConfig(RequestConfig.copy(requestConfig) + .setTargetPreferredAuthSchemes(KERBEROS_PREFERRED_AUTH_SCHEMES) + .build()); + } + + /** + * IE and Firefox always strip the port from the URL before they construct the SPN, see the + * {@code kerberos.spnego.strip_port} property. + */ + private static boolean isStripPort(URL url) { + if (AuthManager.STRIP_PORT) { + return true; + } + int port = url.getPort(); + return port == HTTPConstants.DEFAULT_HTTP_PORT || port == HTTPConstants.DEFAULT_HTTPS_PORT; + } + + private Subject getSubjectForUrl(URL url) { + AuthManager authManager = getAuthManager(); + return authManager == null ? null : authManager.getSubjectForUrl(url); + } + + private static Oid createOid(String oid) { + try { + return new Oid(oid); + } catch (GSSException e) { + log.warn("Could not create OID {}", oid, e); + return null; + } + } + + /** + * Provides the GSS credentials of a JAAS {@link Subject} to the negotiation based auth schemes. + * The HTTP/2 transport builds the token on an I/O reactor thread, which is not covered by the + * {@link Subject#doAs(Subject, PrivilegedExceptionAction)} the HTTP/1.1 request is executed + * with, so the credentials have to be passed to HttpClient explicitly. + */ + private static final class KerberosCredentialsProvider implements CredentialsProvider { + + private final Subject subject; + private final CredentialsProvider delegate; + private final Map credentialsCache = new HashMap<>(); + + private KerberosCredentialsProvider(Subject subject, CredentialsProvider delegate) { + this.subject = subject; + this.delegate = delegate; + } + + @Override + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + public Credentials getCredentials(AuthScope authScope, HttpContext context) { + String schemeName = authScope == null ? null : authScope.getSchemeName(); + if (StandardAuthScheme.SPNEGO.equalsIgnoreCase(schemeName)) { + return getKerberosCredentials(schemeName, SPNEGO_OID); + } + if (StandardAuthScheme.KERBEROS.equalsIgnoreCase(schemeName)) { + return getKerberosCredentials(schemeName, KERBEROS_OID); + } + return delegate.getCredentials(authScope, context); + } + + private Credentials getKerberosCredentials(String schemeName, Oid oid) { + return credentialsCache.computeIfAbsent(schemeName, name -> createKerberosCredentials(oid)); + } + + @SuppressWarnings("deprecation") // KerberosCredentials is deprecated without a replacement + private Credentials createKerberosCredentials(Oid oid) { + if (subject == null || oid == null) { + return USE_JAAS_CREDENTIALS; + } + try { + GSSCredential gssCredential = Subject.doAs(subject, + (PrivilegedExceptionAction) () -> GSSManager.getInstance() + .createCredential(null, GSSCredential.DEFAULT_LIFETIME, oid, + GSSCredential.INITIATE_ONLY)); + return new KerberosCredentials(gssCredential); + } catch (PrivilegedActionException e) { + log.warn("Could not obtain the GSS credentials of subject {}, " + + "falling back to the credentials of the current context", subject, e.getException()); + return USE_JAAS_CREDENTIALS; + } + } + } + private static void configureProxyCredentials(HttpClientKey key, BasicCredentialsProvider credentialsProvider) { if (key.hasProxy && StringUtilities.isNotEmpty(key.proxyUser)) { credentialsProvider.setCredentials(new AuthScope(key.proxyHost, key.proxyPort), @@ -929,7 +1141,7 @@ public String resolveCanonicalHostname(String host) throws UnknownHostException }; } - private HttpClientKey createHttpClientKey(URL url) throws IOException { + HttpClientKey createHttpClientKey(URL url) throws IOException { String proxyScheme = getProxyScheme(); String proxyHost = getProxyHost(); int proxyPort = getProxyPortInt(); @@ -1146,7 +1358,7 @@ public void close() throws IOException { } } - private static final class HttpClientKey { + static final class HttpClientKey { private final String protocol; private final String authority; private final boolean hasProxy; 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 b63f901cb18..2cdd8e9acfb 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 @@ -24,6 +24,8 @@ 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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; @@ -46,10 +48,16 @@ import java.util.concurrent.atomic.AtomicInteger; import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.auth.AuthSchemeFactory; +import org.apache.hc.client5.http.auth.AuthScope; +import org.apache.hc.client5.http.auth.StandardAuthScheme; +import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase; 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.HttpHeaders; +import org.apache.hc.core5.http.config.Lookup; import org.apache.hc.core5.http.protocol.HttpContext; import org.apache.hc.core5.http2.HttpVersionPolicy; import org.apache.hc.core5.http2.config.H2Config; @@ -340,6 +348,51 @@ void sendsBasicCredentialsFromAuthorizationManager() throws Exception { } } + @Test + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + void sendsNegotiateCredentialsForKerberosAuthorization() throws Exception { + AuthManager authManager = new AuthManager(); + authManager.set(-1, "http://kerberos.example.invalid/", "user", "pass", "", "", AuthManager.Mechanism.KERBEROS); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://kerberos.example.invalid/protected"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + Lookup authSchemes = context.getAuthSchemeRegistry(); + assertNotNull(authSchemes, "the Kerberos auth schemes have to be registered for the request"); + assertNotNull(authSchemes.lookup(StandardAuthScheme.SPNEGO), "Negotiate has to be supported"); + assertNotNull(authSchemes.lookup(StandardAuthScheme.KERBEROS), "Kerberos has to be supported"); + assertEquals(List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS), + new ArrayList<>(request.getConfig().getTargetPreferredAuthSchemes()), + "HttpClient 5 only prefers Bearer, Digest and Basic by default"); + assertNotNull(context.getCredentialsProvider().getCredentials( + new AuthScope(null, "kerberos.example.invalid", 80, null, StandardAuthScheme.SPNEGO), context), + "the Negotiate scheme needs credentials to authenticate with"); + assertFalse(request.containsHeader(HttpHeaders.AUTHORIZATION), + "the credentials of a Kerberos authorization are only used to log in to the KDC"); + } + + @Test + void keepsDefaultAuthSchemesWithoutKerberosAuthorization() throws Exception { + AuthManager authManager = new AuthManager(); + authManager.set(-1, "http://basic.example.invalid/", "user", "pass", "", "", AuthManager.Mechanism.BASIC); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://basic.example.invalid/protected"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + assertNull(context.getAuthSchemeRegistry(), "the client should use its default auth schemes"); + assertNull(request.getConfig(), "the request configuration should be left alone"); + } + @Test void authenticatesWithConfiguredProxyCredentials() throws Exception { WireMockServer server = createServer(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index fd74c83143b..06d62fa30b6 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -89,6 +89,7 @@ Summary
    • 6742Multiplex concurrent HTTP/2 requests of the Java sampler implementation over a single connection, and make the HTTP/2 protocol settings (HPACK header table size, maximum concurrent streams, flow control windows, maximum frame size, connection keep alive and server push) configurable with the new http.java.h2.* properties.
    • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
    • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
    • +
    • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.

    Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index a05c368e344..d438a48977c 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -3723,7 +3723,7 @@ information for the user named, "jmeter".
    Java
    BASIC
    HttpClient 4
    BASIC, DIGEST and Kerberos
    -
    HttpClient 5
    BASIC and DIGEST
    +
    HttpClient 5
    BASIC, DIGEST and Kerberos
    From b0e8ca464c5eac8f24da1fbbc2bb8a2bf442f403 Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 13 Aug 2026 07:48:21 +0200 Subject: [PATCH 30/31] Restrict HTTP version options by sampler implementation and add "HTTP/2 Strict" support for HttpClient5 --- bin/jmeter.properties | 2 +- .../http/config/gui/HttpDefaultsGui.java | 5 +- .../http/control/gui/HttpTestSampleGui.java | 5 +- .../http/gui/HttpVersionComboBox.java | 116 ++++++++++++++++++ .../protocol/http/sampler/HTTPHC5Impl.java | 8 +- .../protocol/http/sampler/HTTPJavaImpl.java | 4 +- .../http/sampler/HTTPSamplerFactory.java | 25 ++++ .../http/util/HTTPConstantsInterface.java | 12 ++ .../http/gui/TestHttpVersionComboBox.java | 113 +++++++++++++++++ .../http/sampler/TestHTTPHC5Features.java | 12 ++ .../http/sampler/TestHTTPSamplerFactory.java | 16 +++ xdocs/changes.xml | 1 + xdocs/usermanual/component_reference.xml | 8 +- xdocs/usermanual/properties_reference.xml | 3 +- 14 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java create mode 100644 src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java diff --git a/bin/jmeter.properties b/bin/jmeter.properties index 454ae4744bf..03d215c40ce 100644 --- a/bin/jmeter.properties +++ b/bin/jmeter.properties @@ -379,7 +379,7 @@ remote_hosts=127.0.0.1 # 0 == no timeout # 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) +# Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, HTTP/2 Strict (defaults to HTTP/1.1) #httpclient.version=HTTP/1.1 # Milliseconds of inactivity after which a pooled connection of the HttpClient5 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 4fdd406acf4..71bf2ca5e2a 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 @@ -37,6 +37,7 @@ import org.apache.jmeter.gui.TestElementMetadata; import org.apache.jmeter.gui.util.HorizontalPanel; import org.apache.jmeter.gui.util.VerticalPanel; +import org.apache.jmeter.protocol.http.gui.HttpVersionComboBox; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; @@ -79,7 +80,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 HttpVersionComboBox httpVersion = new HttpVersionComboBox(); private JTextField connectTimeOut; private JTextField responseTimeOut; @@ -171,6 +172,7 @@ public void configure(TestElement el) { HTTPSamplerBaseSchema httpSchema = HTTPSamplerBaseSchema.INSTANCE; sourceIpType.setSelectedIndex(samplerBase.get(httpSchema.getIpSourceType())); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); + httpVersion.setImplementation(samplerBase.getString(httpSchema.getImplementation())); httpVersion.setSelectedItem(samplerBase.getString(httpSchema.getHttpVersion())); } @@ -333,6 +335,7 @@ protected final JPanel getImplementationPanel(){ httpImplementation.addItem("");// $NON-NLS-1$ implPanel.add(httpImplementation); implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$ + httpVersion.bindToImplementation(httpImplementation); 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 7b6150e3b37..95849db5c41 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 @@ -37,6 +37,7 @@ import org.apache.jmeter.gui.util.HorizontalPanel; import org.apache.jmeter.gui.util.VerticalPanel; import org.apache.jmeter.protocol.http.config.gui.UrlConfigGui; +import org.apache.jmeter.protocol.http.gui.HttpVersionComboBox; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBase; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerBaseSchema; import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; @@ -81,7 +82,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 HttpVersionComboBox httpVersion = new HttpVersionComboBox(); private JTextField connectTimeOut; private JTextField responseTimeOut; @@ -136,6 +137,7 @@ public void configure(TestElement element) { if (!isAJP) { sourceIpType.setSelectedIndex(samplerBase.getIpSourceType()); httpImplementation.setSelectedItem(samplerBase.getString(httpSchema.getImplementation())); + httpVersion.setImplementation(samplerBase.getString(httpSchema.getImplementation())); httpVersion.setSelectedItem(samplerBase.getHttpVersion()); } } @@ -355,6 +357,7 @@ protected final JPanel getImplementationPanel(){ httpImplementation.addItem("");// $NON-NLS-1$ implPanel.add(httpImplementation); implPanel.add(new JLabel(JMeterUtils.getResString("http_version"))); // $NON-NLS-1$ + httpVersion.bindToImplementation(httpImplementation); implPanel.add(httpVersion); return implPanel; } diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java new file mode 100644 index 00000000000..c5800054748 --- /dev/null +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/gui/HttpVersionComboBox.java @@ -0,0 +1,116 @@ +/* + * 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.gui; + +import java.awt.Component; +import java.awt.event.ItemEvent; +import java.util.Arrays; +import java.util.Objects; + +import javax.swing.DefaultComboBoxModel; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComboBox; +import javax.swing.JList; + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.apache.jmeter.protocol.http.util.HTTPConstants; + +/** + * Combo box for the HTTP version of a sampler, offering only the versions the selected + * implementation supports. The items are the values stored in the {@code HTTPSampler.httpVersion} + * property, the rendering spells out how HTTP/2 is applied. + * + * @since 5.7 + */ +public class HttpVersionComboBox extends JComboBox { + + private static final long serialVersionUID = 1L; + + public HttpVersionComboBox() { + super(HTTPSamplerFactory.getHttpVersions("")); + setRenderer(new DefaultListCellRenderer() { + private static final long serialVersionUID = 1L; + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + return super.getListCellRendererComponent(list, getLabel(value), index, isSelected, cellHasFocus); + } + }); + } + + private static Object getLabel(Object value) { + if (HTTPConstants.HTTP_VERSION_2.equals(value)) { + // Spelled out, as HTTP/2 falls back to HTTP/1.1 when the server does not support it + return "HTTP/2 Negotiate"; + } + return value; + } + + /** + * Keeps the offered HTTP versions in sync with the implementation selected in the given combo + * box, so combinations an implementation would ignore cannot be selected. + * + * @param httpImplementation combo box holding the implementation of the sampler + */ + public void bindToImplementation(JComboBox httpImplementation) { + httpImplementation.addItemListener(event -> { + if (event.getStateChange() == ItemEvent.SELECTED) { + setImplementation(Objects.toString(event.getItem(), "")); + } + }); + setImplementation(Objects.toString(httpImplementation.getSelectedItem(), "")); + } + + /** + * Restricts the offered HTTP versions to the ones the given implementation supports. + * + * @param implementation implementation name, an empty value refers to the default implementation + */ + public void setImplementation(String implementation) { + String[] versions = HTTPSamplerFactory.getHttpVersions(implementation); + String selected = (String) getSelectedItem(); + if (Arrays.equals(versions, getItems())) { + return; + } + setModel(new DefaultComboBoxModel<>(versions)); + setSelectedItem(Arrays.asList(versions).contains(selected) ? selected : ""); + } + + private String[] getItems() { + String[] items = new String[getItemCount()]; + Arrays.setAll(items, this::getItemAt); + return items; + } + + /** + * {@inheritDoc} + *

    + * A version which the selected implementation does not offer, e.g. one read from a test plan + * saved with an earlier version of JMeter, is added to the model instead of being dropped + * silently. + */ + @Override + public void setSelectedItem(Object item) { + if (item instanceof String version && !Arrays.asList(getItems()).contains(version) + && getModel() instanceof DefaultComboBoxModel) { + ((DefaultComboBoxModel) getModel()).addElement(version); + } + super.setSelectedItem(item); + } +} 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 c17fccd043b..d9042631664 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 @@ -1166,14 +1166,18 @@ 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) + if (HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion)) { + return HttpVersionPolicy.FORCE_HTTP_2; + } + return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) || "2".equals(httpVersion) ? HttpVersionPolicy.NEGOTIATE : HttpVersionPolicy.FORCE_HTTP_1; } /** * Determines the version policy for a request to the given scheme. Plain HTTP does not support * ALPN, so HTTP/2 can only be used over {@code http://} when the client assumes that the server - * speaks HTTP/2 (h2c with prior knowledge). + * speaks HTTP/2 (h2c with prior knowledge). {@code HTTP/2 Strict} keeps its policy for every + * scheme, as it requires HTTP/2 either way. */ static HttpVersionPolicy getHttpVersionPolicy(String samplerHttpVersion, String defaultHttpVersion, String scheme) { HttpVersionPolicy policy = getHttpVersionPolicy(samplerHttpVersion, defaultHttpVersion); 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 ce41c1a0903..05301c3c612 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 @@ -299,7 +299,9 @@ static String getReasonPhrase(int statusCode) { 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$ + // java.net.http.HttpClient always negotiates, so a strict HTTP/2 request is negotiated as well + return HTTPConstants.HTTP_VERSION_2.equalsIgnoreCase(httpVersion) || "2".equalsIgnoreCase(httpVersion) + || HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion); // $NON-NLS-1$ } private boolean isHttp2() { diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java index c7781156dc4..30f0d902dcc 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPSamplerFactory.java @@ -17,6 +17,7 @@ package org.apache.jmeter.protocol.http.sampler; +import org.apache.jmeter.protocol.http.util.HTTPConstants; import org.apache.jmeter.util.JMeterUtils; import org.apache.jorphan.util.StringUtilities; @@ -88,6 +89,30 @@ public static String[] getImplementations(){ return new String[]{IMPL_HTTP_CLIENT4, IMPL_HTTP_CLIENT5, IMPL_JAVA}; } + /** + * Returns the HTTP versions the given implementation can actually use, starting with the empty + * value which leaves the choice to the {@code httpclient.version} property. Implementations + * which ignore the HTTP version of the sampler do not offer HTTP/2 at all, so a combination + * that would be silently dropped cannot be selected in the first place. + * + * @param implementation implementation name, an empty value refers to the default implementation + * @return the selectable values of the {@code HTTPSampler.httpVersion} property + */ + public static String[] getHttpVersions(String implementation) { + String impl = StringUtilities.isBlank(implementation) ? DEFAULT_CLASSNAME : implementation; + if (IMPL_HTTP_CLIENT5.equals(impl)) { + // HttpClient 5 is the only implementation which can require HTTP/2 for the connection + return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2, + HTTPConstants.HTTP_VERSION_2_STRICT}; + } + if (IMPL_JAVA.equals(impl) || HTTP_SAMPLER_JAVA.equals(impl)) { + // java.net.http.HttpClient always negotiates, it has no API to insist on HTTP/2 + return new String[]{"", HTTPConstants.HTTP_VERSION_1_1, HTTPConstants.HTTP_VERSION_2}; + } + // HttpClient4 (and its aliases) never read the HTTP version of the sampler + return new String[]{"", HTTPConstants.HTTP_VERSION_1_1}; + } + public static HTTPAbstractImpl getImplementation(String impl, HTTPSamplerBase base){ if (HTTPSamplerBase.PROTOCOL_FILE.equals(base.getProtocol())) { return new HTTPFileImpl(base); diff --git a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java index fffb369de34..cf4d7adadd8 100644 --- a/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java +++ b/src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/util/HTTPConstantsInterface.java @@ -33,6 +33,18 @@ public interface HTTPConstantsInterface { // CHECKSTYLE IGNORE InterfaceIsType String DEFAULT_HTTP_PORT_STRING = "80"; // $NON-NLS-1$ String PROTOCOL_HTTP = "http"; // $NON-NLS-1$ String PROTOCOL_HTTPS = "https"; // $NON-NLS-1$ + /** Value of the sampler {@code HTTPSampler.httpVersion} property which forces HTTP/1.1. */ + String HTTP_VERSION_1_1 = "HTTP/1.1"; // $NON-NLS-1$ + /** + * Value of the sampler {@code HTTPSampler.httpVersion} property which uses HTTP/2 if the server + * agrees to it, falling back to HTTP/1.1 otherwise. + */ + String HTTP_VERSION_2 = "HTTP/2"; // $NON-NLS-1$ + /** + * Value of the sampler {@code HTTPSampler.httpVersion} property which requires HTTP/2, so a + * server that does not support it fails the sample instead of being used with HTTP/1.1. + */ + String HTTP_VERSION_2_STRICT = "HTTP/2 Strict"; // $NON-NLS-1$ String HEAD = "HEAD"; // $NON-NLS-1$ String POST = "POST"; // $NON-NLS-1$ String PUT = "PUT"; // $NON-NLS-1$ diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java new file mode 100644 index 00000000000..88027121c52 --- /dev/null +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/gui/TestHttpVersionComboBox.java @@ -0,0 +1,113 @@ +/* + * 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.gui; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.swing.JComboBox; + +import org.apache.jmeter.protocol.http.sampler.HTTPSamplerFactory; +import org.junit.jupiter.api.Test; + +public class TestHttpVersionComboBox { + + private static JComboBox implementationComboBox() { + JComboBox implementation = new JComboBox<>(HTTPSamplerFactory.getImplementations()); + implementation.addItem(""); + return implementation; + } + + private static List items(JComboBox comboBox) { + List items = new ArrayList<>(); + for (int i = 0; i < comboBox.getItemCount(); i++) { + items.add(comboBox.getItemAt(i)); + } + return items; + } + + @Test + void offersTheVersionsOfTheSelectedImplementation() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient5"); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2", "HTTP/2 Strict"), items(httpVersion)); + + implementation.setSelectedItem("Java"); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), items(httpVersion)); + + implementation.setSelectedItem("HttpClient4"); + assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); + } + + @Test + void keepsTheSelectionWhenTheNewImplementationSupportsIt() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient5"); + httpVersion.setSelectedItem("HTTP/2"); + implementation.setSelectedItem("Java"); + + assertEquals("HTTP/2", httpVersion.getSelectedItem()); + } + + @Test + void resetsTheSelectionWhenTheNewImplementationDoesNotSupportIt() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient5"); + httpVersion.setSelectedItem("HTTP/2 Strict"); + implementation.setSelectedItem("Java"); + + assertEquals("", httpVersion.getSelectedItem()); + } + + @Test + void keepsAValueWhichIsNotOffered() { + JComboBox implementation = implementationComboBox(); + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + httpVersion.bindToImplementation(implementation); + + implementation.setSelectedItem("HttpClient4"); + httpVersion.setSelectedItem("HTTP/2"); + + assertEquals("HTTP/2", httpVersion.getSelectedItem()); + } + + @Test + void dropsAValueWhichIsNotOfferedWhenTheImplementationIsSetAgain() { + HttpVersionComboBox httpVersion = new HttpVersionComboBox(); + + httpVersion.setImplementation("HttpClient4"); + httpVersion.setSelectedItem("HTTP/2"); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), items(httpVersion)); + + httpVersion.setImplementation("HttpClient4"); + assertEquals(Arrays.asList("", "HTTP/1.1"), items(httpVersion)); + assertEquals("", httpVersion.getSelectedItem()); + } +} 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 2cdd8e9acfb..10064cf5e7d 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 @@ -104,6 +104,18 @@ void doesNotUsePriorKnowledgeForCleartextByDefault() { assertEquals(HttpVersionPolicy.NEGOTIATE, HTTPHC5Impl.getHttpVersionPolicy("HTTP/2", "HTTP/1.1", "http")); } + @Test + void requiresHttp2ForStrictHttp2RegardlessOfScheme() { + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("HTTP/2 Strict", "HTTP/1.1")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("HTTP/2 Strict", "HTTP/1.1", "https")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("HTTP/2 Strict", "HTTP/1.1", "http")); + assertEquals(HttpVersionPolicy.FORCE_HTTP_2, + HTTPHC5Impl.getHttpVersionPolicy("", "HTTP/2 Strict", "https")); + } + @Test void multiplexesConcurrentRequestsOverASingleHttp2Connection() throws Exception { WireMockServer server = new WireMockServer(WireMockConfiguration.wireMockConfig() diff --git a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java index a617f13df1d..fc4387ab795 100644 --- a/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java +++ b/src/protocol/http/src/test/java/org/apache/jmeter/protocol/http/sampler/TestHTTPSamplerFactory.java @@ -42,4 +42,20 @@ void httpClient5IsSelectable() { void unknownImplementationIsRejected() { assertThrows(IllegalArgumentException.class, () -> HTTPSamplerFactory.newInstance("HttpClient6")); } + + @Test + void httpVersionsMatchTheCapabilitiesOfTheImplementation() { + assertEquals(Arrays.asList("", "HTTP/1.1"), + Arrays.asList(HTTPSamplerFactory.getHttpVersions("HttpClient4"))); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2"), + Arrays.asList(HTTPSamplerFactory.getHttpVersions("Java"))); + assertEquals(Arrays.asList("", "HTTP/1.1", "HTTP/2", "HTTP/2 Strict"), + Arrays.asList(HTTPSamplerFactory.getHttpVersions("HttpClient5"))); + } + + @Test + void httpVersionsOfBlankImplementationAreTheOnesOfTheDefaultImplementation() { + assertEquals(Arrays.asList(HTTPSamplerFactory.getHttpVersions(HTTPSamplerFactory.DEFAULT_CLASSNAME)), + Arrays.asList(HTTPSamplerFactory.getHttpVersions(""))); + } } diff --git a/xdocs/changes.xml b/xdocs/changes.xml index 06d62fa30b6..ddc860401f1 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -90,6 +90,7 @@ Summary

  • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
  • +
  • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 offers HTTP/1.1 only, as it ignores the HTTP version, Java adds HTTP/2 Negotiate, and HttpClient5 additionally offers the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.
  • Timers, Assertions, Config, Pre- & Post-Processors

    diff --git a/xdocs/usermanual/component_reference.xml b/xdocs/usermanual/component_reference.xml index d438a48977c..9b7bdf4f4a7 100644 --- a/xdocs/usermanual/component_reference.xml +++ b/xdocs/usermanual/component_reference.xml @@ -245,8 +245,12 @@ 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 to the - HttpClient5 and Java implementations. An empty value uses the httpclient.version property, which + The HTTP versions offered depend on the selected implementation: + HttpClient5 supports HTTP/1.1, HTTP/2 Negotiate (stored as HTTP/2, + falls back to HTTP/1.1 when the server does not offer HTTP/2) and HTTP/2 Strict (stored as + HTTP/2 Strict, requires HTTP/2, so the request fails when the server does not support it), + Java supports HTTP/1.1 and HTTP/2 Negotiate, and HttpClient4 + supports HTTP/1.1 only. An empty value uses the httpclient.version property, which defaults to HTTP/1.1. HTTP, HTTPS or FILE. Default: HTTP GET, POST, HEAD, TRACE, diff --git a/xdocs/usermanual/properties_reference.xml b/xdocs/usermanual/properties_reference.xml index c9ce02d3f7a..3b8870d9e7a 100644 --- a/xdocs/usermanual/properties_reference.xml +++ b/xdocs/usermanual/properties_reference.xml @@ -443,7 +443,8 @@ JMETER-SERVER 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.
    + Valid values are HTTP/1.1, HTTP/2 and, for HttpClient5 only, + HTTP/2 Strict.
    Defaults to: HTTP/1.1
    From ab525f613e37175d9808ce57fa515b0d482fc1ce Mon Sep 17 00:00:00 2001 From: andreaslind01 Date: Thu, 13 Aug 2026 09:46:46 +0200 Subject: [PATCH 31/31] Add proxy Kerberos/SPNEGO authentication support to HttpClient5 sampler --- .../protocol/http/sampler/HTTPHC5Impl.java | 122 ++++++++++++++---- .../http/sampler/TestHTTPHC5Features.java | 70 ++++++++++ xdocs/changes.xml | 1 + 3 files changed, 170 insertions(+), 23 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 d9042631664..b6052f223d4 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 @@ -23,6 +23,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; +import java.net.MalformedURLException; import java.net.URI; import java.net.URL; import java.net.URLDecoder; @@ -276,6 +277,16 @@ protected Map initialValue() { private static final List KERBEROS_PREFERRED_AUTH_SCHEMES = List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS); + /** + * Auth schemes offered to a proxy that can be authenticated with Kerberos. Unlike the target + * schemes these keep the password based schemes as a fallback, so that a proxy which offers + * {@code Negotiate} next to {@code Basic} or {@code Digest} can still be used. + */ + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + private static final List KERBEROS_PROXY_PREFERRED_AUTH_SCHEMES = + List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS, StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC); + private static final Oid SPNEGO_OID = createOid("1.3.6.1.5.5.2"); private static final Oid KERBEROS_OID = createOid("1.2.840.113554.1.2.2"); @@ -464,13 +475,17 @@ private static org.apache.hc.client5.http.classic.methods.HttpUriRequestBase cre } /** - * Executes the request under the JAAS {@link Subject} of the {@link AuthManager}, if the URL is - * covered by a Kerberos authorization, so that the auth scheme can use its credentials. + * Executes the request under the JAAS {@link Subject} of the {@link AuthManager}, if the URL or + * the proxy is covered by a Kerberos authorization, so that the auth scheme can use its + * credentials. */ private ClassicHttpResponse executeRequest(URL url, HttpClientKey clientKey, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, HttpClientContext context) throws IOException { Subject subject = getSubjectForUrl(url); + if (subject == null) { + subject = getSubjectForProxy(clientKey); + } if (subject == null) { return doExecuteRequest(clientKey, request, context); } @@ -656,8 +671,10 @@ HttpClientContext createHttpClientContext(URL url, HttpClientKey key, Authorization authorization = getAuthorizationForUrl(url); configureTargetCredentials(url, request, credentialsProvider, authorization); configureProxyCredentials(key, credentialsProvider); - if (isKerberos(authorization)) { - configureKerberos(url, request, context, credentialsProvider); + boolean kerberosTarget = isKerberos(authorization); + boolean kerberosProxy = isKerberosProxy(key); + if (kerberosTarget || kerberosProxy) { + configureKerberos(url, key, request, context, credentialsProvider, kerberosTarget, kerberosProxy); } else { context.setCredentialsProvider(credentialsProvider); } @@ -666,7 +683,34 @@ HttpClientContext createHttpClientContext(URL url, HttpClientKey key, private Authorization getAuthorizationForUrl(URL url) { AuthManager authManager = getAuthManager(); - return authManager == null ? null : authManager.getAuthForURL(url); + return authManager == null || url == null ? null : authManager.getAuthForURL(url); + } + + /** + * A proxy is authenticated with Kerberos, if the {@link AuthManager} holds a Kerberos + * authorization for it, or if it has no credentials configured at all. Enterprise proxies + * commonly challenge with {@code Proxy-Authenticate: Negotiate}, which can only be answered + * with the Kerberos ticket of the JMeter user, as JMeter has no place to configure proxy + * credentials for a negotiation based scheme. + */ + private boolean isKerberosProxy(HttpClientKey key) { + if (!key.hasProxy) { + return false; + } + return StringUtilities.isEmpty(key.proxyUser) || isKerberos(getAuthorizationForUrl(getProxyUrl(key))); + } + + private static URL getProxyUrl(HttpClientKey key) { + if (!key.hasProxy) { + return null; + } + String scheme = StringUtilities.isEmpty(key.proxyScheme) ? HTTPConstants.PROTOCOL_HTTP : key.proxyScheme; + try { + return new URL(scheme, key.proxyHost, key.proxyPort, ""); + } catch (MalformedURLException e) { + log.debug("Could not build a URL for proxy {}://{}:{}", scheme, key.proxyHost, key.proxyPort, e); + return null; + } } private static boolean isKerberos(Authorization authorization) { @@ -691,12 +735,15 @@ private static void configureTargetCredentials(URL url, /** * Sets up the {@code Negotiate} and {@code Kerberos} auth schemes for a single request, as * HttpClient 5 neither registers them nor prefers them by default. The credentials are taken - * from the JAAS {@link Subject} the {@link AuthManager} logged the user in with. + * from the JAAS {@link Subject} the {@link AuthManager} logged the user in with. The schemes + * are set up for the target, for the proxy, or for both, depending on which of them is + * covered by a Kerberos authorization. */ @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet - private void configureKerberos(URL url, + private void configureKerberos(URL url, HttpClientKey key, org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, - HttpClientContext context, CredentialsProvider credentialsProvider) { + HttpClientContext context, CredentialsProvider credentialsProvider, + boolean kerberosTarget, boolean kerberosProxy) { KerberosConfig kerberosConfig = KerberosConfig.custom() .setStripPort(isStripPort(url)) .setUseCanonicalHostname(AuthManager.USE_CANONICAL_HOST_NAME) @@ -711,12 +758,24 @@ private void configureKerberos(URL url, .register(StandardAuthScheme.SPNEGO, new SPNegoSchemeFactory(kerberosConfig, dnsResolver)) .register(StandardAuthScheme.KERBEROS, new KerberosSchemeFactory(kerberosConfig, dnsResolver)) .build()); - context.setCredentialsProvider( - new KerberosCredentialsProvider(getSubjectForUrl(url), credentialsProvider)); + context.setCredentialsProvider(new KerberosCredentialsProvider( + kerberosTarget ? getSubjectForUrl(url) : null, + kerberosProxy ? getProxyHost(key) : null, + kerberosProxy ? getSubjectForProxy(key) : null, + credentialsProvider)); RequestConfig requestConfig = request.getConfig() == null ? RequestConfig.DEFAULT : request.getConfig(); - request.setConfig(RequestConfig.copy(requestConfig) - .setTargetPreferredAuthSchemes(KERBEROS_PREFERRED_AUTH_SCHEMES) - .build()); + RequestConfig.Builder builder = RequestConfig.copy(requestConfig); + if (kerberosTarget) { + builder.setTargetPreferredAuthSchemes(KERBEROS_PREFERRED_AUTH_SCHEMES); + } + if (kerberosProxy) { + builder.setProxyPreferredAuthSchemes(KERBEROS_PROXY_PREFERRED_AUTH_SCHEMES); + } + request.setConfig(builder.build()); + } + + private static HttpHost getProxyHost(HttpClientKey key) { + return key.hasProxy ? new HttpHost(key.proxyScheme, key.proxyHost, key.proxyPort) : null; } /** @@ -733,7 +792,11 @@ private static boolean isStripPort(URL url) { private Subject getSubjectForUrl(URL url) { AuthManager authManager = getAuthManager(); - return authManager == null ? null : authManager.getSubjectForUrl(url); + return authManager == null || url == null ? null : authManager.getSubjectForUrl(url); + } + + private Subject getSubjectForProxy(HttpClientKey key) { + return getSubjectForUrl(getProxyUrl(key)); } private static Oid createOid(String oid) { @@ -753,12 +816,17 @@ private static Oid createOid(String oid) { */ private static final class KerberosCredentialsProvider implements CredentialsProvider { - private final Subject subject; + private final Subject targetSubject; + private final HttpHost proxy; + private final Subject proxySubject; private final CredentialsProvider delegate; private final Map credentialsCache = new HashMap<>(); - private KerberosCredentialsProvider(Subject subject, CredentialsProvider delegate) { - this.subject = subject; + private KerberosCredentialsProvider(Subject targetSubject, HttpHost proxy, Subject proxySubject, + CredentialsProvider delegate) { + this.targetSubject = targetSubject; + this.proxy = proxy; + this.proxySubject = proxySubject; this.delegate = delegate; } @@ -767,20 +835,28 @@ private KerberosCredentialsProvider(Subject subject, CredentialsProvider delegat public Credentials getCredentials(AuthScope authScope, HttpContext context) { String schemeName = authScope == null ? null : authScope.getSchemeName(); if (StandardAuthScheme.SPNEGO.equalsIgnoreCase(schemeName)) { - return getKerberosCredentials(schemeName, SPNEGO_OID); + return getKerberosCredentials(schemeName, SPNEGO_OID, authScope); } if (StandardAuthScheme.KERBEROS.equalsIgnoreCase(schemeName)) { - return getKerberosCredentials(schemeName, KERBEROS_OID); + return getKerberosCredentials(schemeName, KERBEROS_OID, authScope); } return delegate.getCredentials(authScope, context); } - private Credentials getKerberosCredentials(String schemeName, Oid oid) { - return credentialsCache.computeIfAbsent(schemeName, name -> createKerberosCredentials(oid)); + private Credentials getKerberosCredentials(String schemeName, Oid oid, AuthScope authScope) { + boolean forProxy = isProxy(authScope); + Subject subject = forProxy ? proxySubject : targetSubject; + return credentialsCache.computeIfAbsent(schemeName + (forProxy ? "@proxy" : "@target"), + name -> createKerberosCredentials(subject, oid)); + } + + private boolean isProxy(AuthScope authScope) { + return proxy != null && proxy.getHostName().equalsIgnoreCase(authScope.getHost()) + && (authScope.getPort() <= 0 || authScope.getPort() == proxy.getPort()); } @SuppressWarnings("deprecation") // KerberosCredentials is deprecated without a replacement - private Credentials createKerberosCredentials(Oid oid) { + private static Credentials createKerberosCredentials(Subject subject, Oid oid) { if (subject == null || oid == null) { return USE_JAAS_CREDENTIALS; } @@ -1115,7 +1191,7 @@ 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; + return getProxyHost(key); } @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 10064cf5e7d..e9601551938 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 @@ -405,6 +405,76 @@ void keepsDefaultAuthSchemesWithoutKerberosAuthorization() throws Exception { assertNull(request.getConfig(), "the request configuration should be left alone"); } + @Test + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + void negotiatesWithAProxyThatHasNoCredentialsConfigured() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setProxyHost("proxy.example.invalid"); + sampler.setProxyPortInt("8080"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://target.example.invalid/resource"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + Lookup authSchemes = context.getAuthSchemeRegistry(); + assertNotNull(authSchemes, "the Kerberos auth schemes have to be registered for the request"); + assertNotNull(authSchemes.lookup(StandardAuthScheme.SPNEGO), "Negotiate has to be supported"); + assertEquals(List.of(StandardAuthScheme.SPNEGO, StandardAuthScheme.KERBEROS, StandardAuthScheme.BEARER, + StandardAuthScheme.DIGEST, StandardAuthScheme.BASIC), + new ArrayList<>(request.getConfig().getProxyPreferredAuthSchemes()), + "a proxy challenging with Negotiate has to be answered with a Kerberos token"); + assertNull(request.getConfig().getTargetPreferredAuthSchemes(), + "the target is not covered by a Kerberos authorization"); + assertNotNull(context.getCredentialsProvider().getCredentials( + new AuthScope(null, "proxy.example.invalid", 8080, null, StandardAuthScheme.SPNEGO), context), + "the Negotiate scheme needs credentials to authenticate with"); + } + + @Test + @SuppressWarnings("deprecation") // The GSS based auth schemes of HttpClient 5 have no replacement yet + void negotiatesWithAProxyCoveredByAKerberosAuthorization() throws Exception { + AuthManager authManager = new AuthManager(); + authManager.set(-1, "http://proxy.example.invalid:8080", "user", "pass", "", "", + AuthManager.Mechanism.KERBEROS); + HTTPSamplerBase sampler = newSampler(); + sampler.setAuthManager(authManager); + sampler.setProxyHost("proxy.example.invalid"); + sampler.setProxyPortInt("8080"); + sampler.setProxyUser("user"); + sampler.setProxyPass("pass"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://target.example.invalid/resource"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + assertNotNull(context.getAuthSchemeRegistry(), "the Kerberos auth schemes have to be registered"); + assertEquals(StandardAuthScheme.SPNEGO, + new ArrayList<>(request.getConfig().getProxyPreferredAuthSchemes()).get(0), + "a Kerberos authorization for the proxy has to be preferred over the password based schemes"); + } + + @Test + void keepsDefaultAuthSchemesForAProxyWithCredentials() throws Exception { + HTTPSamplerBase sampler = newSampler(); + sampler.setProxyHost("proxy.example.invalid"); + sampler.setProxyPortInt("8080"); + sampler.setProxyUser("user"); + sampler.setProxyPass("pass"); + HTTPHC5Impl implementation = new HTTPHC5Impl(sampler); + URL url = new URL("http://target.example.invalid/resource"); + HttpUriRequestBase request = new HttpUriRequestBase(HTTPConstants.GET, url.toURI()); + + HttpClientContext context = + implementation.createHttpClientContext(url, implementation.createHttpClientKey(url), request); + + assertNull(context.getAuthSchemeRegistry(), "the client should use its default auth schemes"); + assertNull(request.getConfig(), "the request configuration should be left alone"); + } + @Test void authenticatesWithConfiguredProxyCredentials() throws Exception { WireMockServer server = createServer(); diff --git a/xdocs/changes.xml b/xdocs/changes.xml index ddc860401f1..bbc3537bc21 100644 --- a/xdocs/changes.xml +++ b/xdocs/changes.xml @@ -90,6 +90,7 @@ Summary
  • 6742Add the default User-Agent header of the HttpClient5 sampler implementation to the request itself, so it shows up in the sample result and in the sent bytes, and allow suppressing it with the new httpclient5.default_user_agent_disabled property, like httpclient4.default_user_agent_disabled does for HttpClient4.
  • 6742Add the default User-Agent header of the Java sampler implementation (Java/A.B.C for HTTP/1.1 and Java-http-client/A.B.C for HTTP/2) to the request itself, so the header the JDK sends anyway shows up in the sample result and in the sent bytes.
  • 6742Support the Kerberos mechanism of the HTTP Authorization Manager in the HttpClient5 sampler implementation. HttpClient 5 neither registers the Negotiate and Kerberos auth schemes nor prefers them by default, so they are now set up per request, and the request is executed with the JAAS subject the Authorization Manager logged the user in with. The kerberos.spnego.strip_port, kerberos.spnego.use_canonical_host_name and kerberos.spnego.delegate_cred properties are honoured as with HttpClient4.
  • +
  • 6742Answer a Proxy-Authenticate: Negotiate (SPNEGO/Kerberos) challenge of an enterprise proxy in the HttpClient5 sampler implementation, instead of failing the sample with 407 Proxy Authentication Required. The negotiation based auth schemes are now offered to a proxy which has no user name configured, or which is covered by a Kerberos entry of the HTTP Authorization Manager, while a proxy with a configured user name keeps using the password based schemes.
  • 6742Offer only the HTTP versions the selected implementation can use in the HTTP Version combo box of the HTTP Request sampler and the HTTP Request Defaults: HttpClient4 offers HTTP/1.1 only, as it ignores the HTTP version, Java adds HTTP/2 Negotiate, and HttpClient5 additionally offers the new HTTP/2 Strict value, which only offers h2 during protocol negotiation, so a server without HTTP/2 support fails the sample instead of being used with HTTP/1.1.