milamberspace commented on code in PR #6742: URL: https://github.com/apache/jmeter/pull/6742#discussion_r3757052416
########## src/protocol/http/src/main/java/org/apache/jmeter/protocol/http/sampler/HTTPHC5Impl.java: ########## @@ -0,0 +1,1186 @@ +/* + * 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.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +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; + +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; +import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +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.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.async.CloseableHttpAsyncClient; +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.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.HostnameVerificationPolicy; +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; +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.HttpRequestInterceptor; +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.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; +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; +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; +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; +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; +import org.apache.jorphan.util.StringUtilities; +import org.brotli.dec.BrotliInputStream; +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); + + /** 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<Map<HttpClientKey, CloseableHttpClient>> HTTP_CLIENTS = + new InheritableThreadLocal<>() { + @Override + protected Map<HttpClientKey, CloseableHttpClient> 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<Map<HttpClientKey, CloseableHttpAsyncClient>> HTTP_2_CLIENTS = + new InheritableThreadLocal<>() { + @Override + protected Map<HttpClientKey, CloseableHttpAsyncClient> 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); + + /** + * 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(); + + 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<InputStreamFactory> CONTENT_DECODERS = RegistryBuilder.<InputStreamFactory>create() + .register("br", BROTLI) + .register("gzip", GZIPInputStreamFactory.getInstance()) + .register("x-gzip", GZIPInputStreamFactory.getInstance()) + .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(); + if (requestConfig == null) { + 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 (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; + + 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) + .buildAsync(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Could not create HTTP/2 TLS strategy", e); + } + } + + 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); + } + + @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; + ClassicHttpResponse response = null; + try { + resetStateIfNeeded(); + request = createRequest(url.toURI(), method); + setupRequest(url, request, result, areFollowingRedirect); + result.sampleStart(); + + CacheManager cacheManager = getCacheManager(); + if (cacheManager != null && HTTPConstants.GET.equalsIgnoreCase(method)) { + if (cacheManager.inCache(url, request.getHeaders())) { + return updateSampleResultForResourceInCache(result); + } + } + + 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); + 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) { + if (result.getEndTime() == 0) { + result.sampleEnd(); + } + if (request != null) { + result.setRequestHeaders(getRequestHeaders(request)); + result.setSentBytes(calculateSentBytes(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 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); + } + + 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, + url.getProtocol()); + RequestConfig.Builder config = RequestConfig.custom() + .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); + int responseTimeout = getResponseTimeout(); + if (responseTimeout > 0) { + config.setResponseTimeout(Timeout.ofMilliseconds(responseTimeout)); + } + request.setConfig(config.build()); + if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1) { + request.setHeader(HTTPConstants.HEADER_CONNECTION, + getUseKeepAlive() ? HTTPConstants.KEEP_ALIVE : HTTPConstants.CONNECTION_CLOSE); + } else if (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_2) { + request.setVersion(HttpVersion.HTTP_2); + } + setConnectionHeaders(request, getHeaderManager(), httpVersionPolicy); + setDefaultUserAgent(request); + CacheManager cacheManager = getCacheManager(); + if (cacheManager != null) { + cacheManager.setHeaders(url, request); + } + + 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 = "<actual file content, not shown here>"; + } 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<NameValuePair> createNameValuePairs(String contentEncoding) throws IOException { + List<NameValuePair> 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 "<Entity was not repeatable, cannot view what was sent>"; + } + ByteArrayOutputStream output = new ByteArrayOutputStream(); + entity.writeTo(output); + return output.toString(contentEncoding); + } + + private static void setConnectionHeaders(org.apache.hc.client5.http.classic.methods.HttpUriRequestBase request, + HeaderManager headerManager, HttpVersionPolicy httpVersionPolicy) { + 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()) + && (httpVersionPolicy == HttpVersionPolicy.FORCE_HTTP_1 + || !HTTPConstants.HEADER_CONNECTION.equalsIgnoreCase(header.getName()))) { + request.addHeader(header.getName(), header.getValue()); + } + } + } + + 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) { + return null; + } + String cookies = cookieManager.getCookieHeaderForURL(url); + if (cookies != null) { + request.setHeader(HTTPConstants.HEADER_COOKIE, cookies); + } + 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)); + result.setSentBytes(calculateSentBytes(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(ClassicHttpResponse 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 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(); + } + + private static void saveConnectionCookies(ClassicHttpResponse response, URL url, CookieManager cookieManager) { + if (cookieManager == null) { + return; + } + for (Header header : response.getHeaders(HTTPConstants.HEADER_SET_COOKIE)) { + cookieManager.addCookieFromHeader(header.getValue(), url); + } + } + + private static CloseableHttpClient getClient(HttpClientKey key) { + Map<HttpClientKey, CloseableHttpClient> clients = HTTP_CLIENTS.get(); + return clients.computeIfAbsent(key, HTTPHC5Impl::createClient); + } + + private static CloseableHttpAsyncClient getHttp2Client(HttpClientKey key) { + Map<HttpClientKey, CloseableHttpAsyncClient> clients = HTTP_2_CLIENTS.get(); + 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) + .build()); + if (key.dnsCacheManager != null) { + connectionManagerBuilder.setDnsResolver(createDnsResolver(key.dnsCacheManager)); + } + if (key.connectTimeout > 0) { + connectionManagerBuilder + .setDefaultConnectionConfig(ConnectionConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)) + .build()); + } + builder.setConnectionManager(new ConnectTimeMeasuringConnectionManager(connectionManagerBuilder.build())); + builder.setRoutePlanner(createRoutePlanner(key)); + return builder.disableContentCompression() + .addExecInterceptorFirst("response-content-encoding", RESPONSE_CONTENT_ENCODING) + .build(); + } + + 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) { + // 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() + .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)); + } + if (key.connectTimeout > 0) { Review Comment: Worth folding a `setValidateAfterInactivity` into this `ConnectionConfig` (unconditionally, not just under the `connectTimeout > 0` guard) so pooled HTTP/2 connections get an HTTP/2 PING liveness check before reuse instead of being handed out straight from the pool: ```java ConnectionConfig.Builder connectionConfig = ConnectionConfig.custom() .setValidateAfterInactivity(TimeValue.ofSeconds(2)); if (key.connectTimeout > 0) { connectionConfig.setConnectTimeout(Timeout.ofMilliseconds(key.connectTimeout)); } connectionManagerBuilder.setDefaultConnectionConfig(connectionConfig.build()); ``` Without it, `ConnectionConfig.DEFAULT.getValidateAfterInactivity()` is `null`, and `PoolingAsyncClientConnectionManager#lease()` skips its re-validation step entirely (`TimeValue.isNonNegative(null)` is `false`), so a connection the server already closed gets reused as-is and fails mid-write with `ConnectionClosedException` instead of being transparently discarded and replaced. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
