Repository navigation
Add HttpClient5 sampler implementation with HTTP/2 support - #6742
andreaslind01 wants to merge 68 commits into
Conversation
milamberspace
left a comment
There was a problem hiding this comment.
Not a full review of this PR — just a scoped, timely note on the dependency versions.
Apache HttpComponents Client just released 5.6.4: "Corrects application of SSL parameters in the async TLS upgrade method" (RELEASE_NOTES-5.6.x.txt). This PR pins httpclient5:5.5.1, and HTTPHC5Impl is exactly the kind of code that exercises that path — it uses the async H2 client with HttpVersionPolicy.NEGOTIATE for HTTP/2-over-TLS via ALPN, i.e. an async TLS upgrade. Worth pulling in the fix before this lands, rather than shipping the new HTTP/2 sampler with a known bug in SSL-parameter application during that exact upgrade.
See inline comment for the concrete version bump (and the matching httpcore5 pairing, since httpclient5:5.6.4 is built/tested against httpcore5:5.4.3, not 5.3.4).
This review was drafted by an AI-assisted tool and confirmed by an Apache JMeter maintainer.
milamberspace
left a comment
There was a problem hiding this comment.
Found a real bug while manually testing the HTTP/2 sampler against a long-running server: intermittent
java.io.IOException: Could not execute HTTP/2 request
Caused by: org.apache.hc.core5.http2.impl.nio.ConnectionClosedException: Connection is closed
at org.apache.hc.core5.http2.impl.nio.H2Streams.shutdownAndReleaseAll(H2Streams.java:149)
at org.apache.hc.core5.http2.impl.nio.AbstractH2StreamMultiplexer.onOutput(...)
...
Root cause
createHttp2Client() never configures ConnectionConfig.validateAfterInactivity on the PoolingAsyncClientConnectionManagerBuilder, and ConnectionConfig.DEFAULT documents it as null (undefined). In PoolingAsyncClientConnectionManager#lease(), the re-validation step (an HTTP/2 PING before handing out a pooled connection, StaleCheckCommand for HTTP/1.1) is gated by:
final TimeValue timeValue = connectionConfig.getValidateAfterInactivity();
if (connection.isOpen() && TimeValue.isNonNegative(timeValue)) { ... }TimeValue.isNonNegative(null) is false, so with the default config this block never runs — pooled async connections are leased straight out of the pool with zero liveness check.
Concretely: HTTP_2_CLIENTS caches the async client per JMeter thread and reuses it across iterations. If the server (or an idle load balancer/NAT) closes an idle pooled HTTP/2 connection between two samples, the next sample picks it from the pool as-is; the I/O reactor only discovers it's dead when it tries to write to it, surfacing as ConnectionClosedException deep in H2Streams.
This is made worse by disableAutomaticRetries() (called on both the classic and async builders) — correctly disabled so JMeter doesn't silently mask real server behavior/timing from the sample result, but it also removes HttpClient5's own safety net for exactly this failure mode. Without proactive pool validation, there's nothing left to catch it, and it surfaces as a hard sampler failure instead of a transparent retry.
Suggested fix
See inline comment — add .setValidateAfterInactivity(...) to the ConnectionConfig built in createHttp2Client() (worth doing for createClient()'s classic-transport config too, same gap applies there).
This review was drafted by an AI-assisted tool and confirmed by an Apache JMeter maintainer.
|
Following up on the stale-connection bug above with a concrete repro I ran manually against a real server (own domain, browsing-style scenario: 5 threads, 3 loops, ~800–2800ms think time between transactions on the same reused HTTP/2 connection). Worth a regression test to prove Suggested shape for the test:
Happy to be wrong about the exact mechanics of forcing step 3 cleanly with whatever test HTTP/2 server this project already has infrastructure for ( |
|
Thanks @milamberspace - fixed and covered by a regression test. Fix: One correction on the mechanics, which changed how the test had to be shaped. Tracing httpclient5 5.6.4 / httpcore5 5.4.3: a cleanly closed connection isn't what fails. What the test does instead, keeping the idle-gap framing: A raw TCP relay sits in front of WireMock's h2/TLS port, so the test owns the client-facing socket. It can mark a connection doomed: it stays open, but is dropped as soon as the client writes to it again - what an idle server/LB timeout looks like to a client that hasn't noticed yet.
On asserting it fails without the fix: verified directly by forcing the default to
|
|
Thanks @andreaslind01 — manual testing against the built distribution (browsing-style scenario, HTTP/2, same idle-gap pattern as the original repro) is conclusive: the stale-connection failure is gone. Great fix, and a genuinely clever regression test. Two smaller, non-blocking points from going through the GUI while testing: 1. The HTTP Version combo doesn't reflect what each implementation actually supportsIn HTTP Request → Advanced (and HTTP Request Defaults), the Implementation combo offers Would be worth either:
Relevant: 2. Advanced-tab screenshot is stale
|
|
A few notes on Missing
|
Thanks for the testing and the detailed review @milamberspace. Regarding the HTTP Version selector: I actually tend towards removing it rather than introducing implementation-specific enable/disable logic. The selected implementation already determines the effective protocol behavior (HttpClient4 → HTTP/1.1, HttpClient5/Java → HTTP/2 with HTTP/1.1 fallback). A tooltip explaining this behavior would likely be clearer than allowing users to choose combinations that are effectively ignored. For the few cases where HTTP/1.1 must be enforced, I would prefer dedicated properties in jmeter.properties rather than additional GUI complexity. |
|
Following up on removing the HTTP Version field — I'd argue the opposite: keep it, but make its options implementation-aware instead of static. Removing it loses something a load-testing tool specifically benefits from. Why keep it: comparative testing on the same targetThe whole point of a field like this in JMeter is to let a single test plan run the same request against the same target under different explicit protocol conditions — e.g. HttpClient5 forced to HTTP/1.1 vs HttpClient5 negotiating HTTP/2 vs HttpClient5 strictly requiring HTTP/2, all as separate samplers/thread groups in one scenario, to compare latency, throughput or behavior side by side. Or Java HTTP/1.1 vs Java HTTP/2 Negotiate. That's a legitimate, common load-testing use case (validating an HTTP/2 migration, quantifying its actual performance benefit, or just making sure a "should be HTTP/2" test really is one — which is exactly the failure mode my own manual test just ran into: Losing per-sampler control to gain a tooltip trades away a real capability for a cosmetic simplification. Proposal: implementation-aware options, not a removed fieldRather than a static 3-item combo shared by all implementations, populate it based on the currently selected Implementation, dynamically (an
|
|
Thanks for the detailed feedback and for laying out the use case so clearly. I agree that removing the field entirely would sacrifice a legitimate and valuable load-testing capability, particularly for side-by-side protocol comparisons within a single test plan. I've implemented the proposed direction and the changes are now available:
This keeps per-sampler protocol control available while eliminating the previous silent no-op combinations that motivated the original discussion. Thanks again for the suggestion. I think this results in a clearer UI without reducing functionality. |
|
Manual testing against the latest build is OK on these points:
Also did a clean :src:dist:assemble build from this commit to get a good distributable archive (using for my tests) The last change is to update ./xdocs/images/screenshots/http-request-advanced-tab.png with the capture posted on this discussion (and change the component_reference.xml with the good dimension of screenshot: width="1734" height="669") Thanks again for you PR / work. |
|
Thanks a lot for the thorough testing. Both screenshots are updated: I exported them at 950×391 (and set |
milamberspace
left a comment
There was a problem hiding this comment.
@andreaslind01 Hi, the constants review. Thanks
| * 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 |
| 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"; |
There was a problem hiding this comment.
Move to constants variable
|
|
||
| private static String getEntityPreview(HttpEntity entity, String contentEncoding) throws IOException { | ||
| if (!entity.isRepeatable()) { | ||
| return "<Entity was not repeatable, cannot view what was sent>"; |
There was a problem hiding this comment.
Constant or translate string ?
| if (HTTPConstants.HTTP_VERSION_2_STRICT.equalsIgnoreCase(httpVersion)) { | ||
| return HttpVersionPolicy.FORCE_HTTP_2; | ||
| } | ||
| return HTTPConstants.HTTP_VERSION_2.equals(httpVersion) || "2".equals(httpVersion) |
There was a problem hiding this comment.
Where the httpVersion can have the value "2"?
| static boolean isHttp2(String samplerHttpVersion, String defaultHttpVersion) { | ||
| String httpVersion = StringUtilities.isBlank(samplerHttpVersion) ? defaultHttpVersion : samplerHttpVersion; | ||
| // 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) |
There was a problem hiding this comment.
Same question about the value "2"
| cacheManager.saveDetails(response, res); | ||
| } | ||
|
|
||
| res.setSentBytes(calculateSentBytes(url, method, "HTTP/2", |
There was a problem hiding this comment.
Constant for "HTTP/2" here
| if (res.getEndTime() == 0) { | ||
| res.sampleEnd(); | ||
| } | ||
| res.setSentBytes(calculateSentBytes(url, method, "HTTP/2", |
|
|
||
| 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$ |
| uri = ""; | ||
| } | ||
| org.apache.hc.core5.http.ProtocolVersion version = request.getVersion(); | ||
| String versionStr = version != null ? version.toString() : "HTTP/1.1"; |
vlsi
left a comment
There was a problem hiding this comment.
Review comments — PR #6742 "Add HttpClient5 sampler implementation with HTTP/2 support"
Reviewed at head 919b9125, against merge base ad6ecbd1.
Two independent reviewers (Codex and a Claude subagent) went through the diff; the items below are the merged, verified
result. Line numbers refer to the PR head.
Blockers
B1. HTTPHC5Impl ends the sample before the body is read, so elapsed excludes the download and latency exceeds elapsed
HTTPHC5Impl.java:444-448 calls result.sampleEnd() right after executeRequest() returns. For the classic transport
that point is "status line and headers received" — the body is read afterwards, in updateResult() →
readResponse() (HTTPHC5Impl.java:904). HTTPSamplerBase.readResponse() calls sampleResult.latencyEnd() on the
first body byte (HTTPSamplerBase.java:1972), which now happens after endTime was stamped.
GET a 100 MB file over HTTP/1.1 with HttpClient5: headers arrive after 50 ms, the body takes 10 s. The sample reports
elapsed ≈ 50 ms and latency ≈ 10050 ms. Elapsed, throughput, the Latency column, the aggregate report and the HTML
dashboard are all wrong, and latency > elapsed is a state JMeter's own model treats as impossible.
HTTPHC4Impl does the opposite order deliberately — HTTPHC4Impl.java:667-672, res.sampleEnd(); // Done with the sampling proper. The new HTTPJavaImpl HTTP/2 path also gets it right, so HC5 is the odd one out.
Suggested fix: read the entity inside the try block, then sampleEnd(), then fill in status code, headers, sizes and
redirect location. Add a regression test asserting result.getLatency() <= result.getTime() against a WireMock stub
with withChunkedDribbleDelay.
B2. The HttpClient5 HTTP/1.1 transport ignores SSLManager: no trust-all, no client keystore, no https.socket.protocols
HTTPHC5Impl.createClient() (HTTPHC5Impl.java:1043-1066) builds the classic connection manager without installing any
TLS socket strategy, so HttpClient falls back to SSLContexts.createSystemDefault(). JMeter's per-thread SSLContext
from JsseSSLManager — which carries the https.keyStore client certificates, the trust-all trust manager,
https.socket.protocols and https.cipherSuites — is never used, and HttpClient 5.4+ defaults
HostnameVerificationPolicy to BOTH.
Since an empty HTTP Version maps to FORCE_HTTP_1, this is the default path. Three failures follow:
- an HTTPS target with a self-signed or internal-CA certificate fails with
SSLHandshakeException, while the same plan
works withHttpClient4andJava; - client-certificate authentication configured through the JMeter keystore silently sends no certificate;
https.socket.protocolsandhttps.cipherSuiteshave no effect.
Note the asymmetry inside the same class: the async client does install a TLS strategy
(HTTP_2_TLS_STRATEGY, HTTPHC5Impl.java:391-405), so the same URL behaves differently depending on the selected HTTP
version. HTTPHC4Impl installs LazyLayeredConnectionSocketFactory (HTTPHC4Impl.java:1106), which wraps
HttpSSLProtocolSocketFactory → JsseSSLManager.
No test covers this: every HTTPS test in TestHTTPHC5Features sets setHttpVersion("HTTP/2") and routes to the async
client.
Suggested fix: build one TLS strategy from ((JsseSSLManager) SSLManager.getInstance()).getContext() plus the two
cipher/protocol properties and NoopHostnameVerifier, and install it on both connection managers
(setTlsSocketStrategy / setTlsStrategy). Resolve the SSLContext lazily per client so the per-thread context and
resetContext() keep working.
B3. createHttp2TlsStrategy() builds a trust-all context and drops the configured client certificate
HTTPHC5Impl.java:391-405 always creates a fresh SSLContexts.custom().loadTrustMaterial(null, TrustAllStrategy…)
context. Nothing from JsseSSLManager reaches it, so HTTP/2 requests to a mutual-TLS endpoint present no client
certificate and fail TLS authentication, unlike every existing implementation.
This is the same root cause as B2 seen from the other side: there is one JMeter SSLContext and neither transport uses
it. Fixing B2 and B3 together with a single shared strategy is the right shape.
B4. HTTPJavaImpl now sends every Header Manager header twice on the HTTP/1.1 path
setupConnection() already calls setConnectionHeaders(conn, u, getHeaderManager(), getCacheManager())
(HTTPJavaImpl.java:440). The diff adds a second call in sample() (HTTPJavaImpl.java:816), and
setConnectionHeaders uses conn.addRequestProperty(n, v), which appends rather than replaces.
A plan on the legacy Java implementation with a Header Manager entry Accept: application/json now puts
Accept: application/json,application/json on the wire. Cookie, Range and Authorization are duplicated the same
way. It is silent, because res.setRequestHeaders(...) is computed inside setupConnection before the second call, so
the sample result still shows one copy.
This regresses the existing HTTP/1.1 Java sampler for everyone, and the only reason for the second call is to obtain
securityHeaders for the new calculateSentBytes.
Suggested fix: return the security headers from setupConnection (or hold them in a field) instead of re-running
setConnectionHeaders. Add a WireMock test asserting the header arrives exactly once.
B5. SHARED_HTTP_2_CLIENTS grows without bound: keyed by a per-thread SSLContext, never evicted, never closed
getHttpClient(URL) puts the SSLContext returned by ((JsseSSLManager) SSLManager.getInstance()).getContext() into
the cache key, and SSLContext does not override equals/hashCode, so the key compares by identity.
JsseSSLManager.getContext() returns a per-thread context by default (https.sessioncontext.shared defaults to false).
So with the default http.java.h2.multiplexing=true, the static SHARED_HTTP_2_CLIENTS map holds one
java.net.http.HttpClient per JMeter thread rather than one shared client — the stated multiplexing goal is not met —
and each client owns a selector thread and a connection pool.
It gets worse across iterations: HTTPHC5Impl/HTTPHC4Impl call resetContext() on every thread-group iteration when
"same user on next iteration" is off (HTTPHC5Impl.java:1281). The next HTTPS HTTP/2 sample then gets a new
SSLContext, a new map key and a new HttpClient, while the old one stays in the static map forever.
200 threads × 500 iterations against an HTTPS target accumulates up to 100 000 HttpClient instances and their selector
threads. The JVM dies with OutOfMemoryError: unable to create native thread.
Nothing ever removes entries — there is no threadFinished()/testEnded() hook for these maps — and HTTP_2_EXECUTOR
(an unbounded cached pool) is never shut down either. HTTPHC5Impl does clean up in threadFinished(); HTTPJavaImpl
does not.
Suggested fix: keep the SSLContext instance out of the key, add a threadFinished()/testEnded() hook that closes
the clients and shuts the executor down, and add a test that samples HTTPS twice with a resetContext() in between and
asserts the map does not grow.
B6. The release-jar manifest is not updated, so :src:dist:verifyReleaseDependencies fails
src/dist/src/dist/expected_release_jars.csv has no entries for httpclient5, httpcore5 or httpcore5-h2, and the
PR does not touch the file. CI already reports it:
External dependencies differ (you could update
src/dist/src/dist/expected_release_jars.csvif you run
:src:dist:verifyReleaseDependencies -PupdateExpectedJars)
Run that task and commit the result. Please also confirm the new jars carry the license and NOTICE coverage the ASF
release requires.
Major
M1. Neither HTTP/2 path can be interrupted, so "Stop Test Now" leaves threads blocked
HTTPHC5Impl.interrupt() (HTTPHC5Impl.java:1304-1312) cancels currentRequest, but the request actually executed
over HTTP/2 is a copy — SimpleHttpRequest asyncRequest = SimpleHttpRequest.copy(request)
(HTTPHC5Impl.java:1142). The classic request never becomes the cancellable dependency of the async exchange, so
cancel() is a no-op while the sampler thread sits in responseFuture.get(...).
HTTPJavaImpl has the mirror problem: interrupt() only touches savedConn, which sampleHttp2() never assigns, so
it returns false and the thread stays blocked in client.httpClient.send(...).
Against a server that accepts the connection and never answers, an HC5 HTTP/2 sampler with no Response Timeout blocks
for the hard-coded 60 s of getHttp2ExecutionTimeoutMillis() and a Java HTTP/2 sampler blocks forever. Neither
"Stop Test Now" nor the JMeter thread interrupt shortens it.
Suggested fix: hold the Future in a volatile field and cancel it from interrupt(); for HTTPJavaImpl, use
sendAsync and keep the CompletableFuture. While you are there, drop the arbitrary 60 s ceiling — HttpClient enforces
the configured response timeout itself, and a legitimate 90-second sample currently fails at 60 s on HTTP/2 but succeeds
on HTTP/1.1.
M2. Both HTTP/2 paths buffer the whole request and response body in heap
HTTPHC5Impl.executeHttp2() converts the request entity with EntityUtils.toByteArray(requestEntity)
(HTTPHC5Impl.java:1144-1149) and executes a SimpleHttpRequest, whose SimpleHttpResponse holds the entire body as a
byte array; createClassicResponse() then wraps that array in a ByteArrayEntity — a third copy.
HTTPJavaImpl.sampleHttp2() has the same shape on the request side: CapturingHttpURLConnection.getOutputStream()
returns a ByteArrayOutputStream, so sendPostData/sendPutData write the whole upload into heap before
BodyPublishers.ofByteArray(...) is built.
Two concrete failures: a POST that uploads a 2 GB file with Files Upload dies with OutOfMemoryError on both HTTP/2
paths while it succeeds on HTTP/1.1; and downloading a 1 GB response over HTTP/2 keeps the full gigabyte in heap even
when httpsampler.max_bytes_to_store_per_request is set, because that limit is applied later, inside
HTTPSamplerBase.readResponse, when the bytes are already materialized. Under load this multiplies by the thread count.
Suggested fix: use the streaming async API — AsyncRequestBuilder with a FileEntityProducer/BasicRequestProducer,
and an AbstractBinResponseConsumer that feeds HTTPSamplerBase.readResponse's truncation logic. For HTTPJavaImpl,
publish with BodyPublishers.ofFile/ofInputStream.
M3. HTTPHC5Impl never updates the sample URL after an automatic redirect
The request config enables HttpClient's own redirect handling (HTTPHC5Impl.java:520), but sample() never writes the
final URI back into the result — there is no result.setURL(...) anywhere in the class. HTTPHC4Impl does exactly that
(HTTPHC4Impl.java:708-718).
Consequences:
saveConnectionCookies(response, result.getURL(), getCookieManager())runs with the original URL, so aSet-Cookie
issued by the redirect target is stored against the original host. A login flow
http://www.example.com/login→https://auth.example.com/sessionstores the session cookie forwww.example.com,
and every later request is unauthenticated.cacheManager.saveDetails(response, result)usesres.getUrlAsString(), so the ETag of the redirect target is cached
under the original URL.- Listeners show the pre-redirect URL.
The same method also drops a redirect without a Location header silently, where HC4 raises IllegalArgumentException
(HTTPHC4Impl.java:684-686).
M4. ConnectTimeTracker stamps connectEnd() onto unrelated in-flight samples
ConnectTimeTracker.recordConnectEnd() walks all entries of activeSamples and calls result.connectEnd() on each.
The tracker belongs to an Http2Client, and with http.java.h2.multiplexing=true that client lives in the static
SHARED_HTTP_2_CLIENTS map — one instance for every JMeter thread and every origin, since HttpClientKey carries no
host or port.
Thread A samples https://a.example over an already-pooled connection while thread B opens a new connection to
https://b.example; B's TLS handshake stamps a connect time into A's result.
Separately, ConnectTimeMeasuringExecutor.execute() calls tracker.connectionEstablished() on every task the JDK
client submits — body delivery, stream callbacks, retries — not only on connection establishment, so the first such task
after a sample starts is reported as its connect time even on a fully pooled connection.
recordsConnectTimeForEveryMultiplexedSample (TestHTTPJavaFeatures.java:219-242) asserts this behavior rather than the
invariant, so it locks the defect in. There is also a cross-thread write to a SampleResult owned by another thread.
Suggested fix: scope the tracker to a connection rather than to a client, drop the executor heuristic, and change the
test to assert that a sample served by an established connection reports connectTime == 0.
M5. HTTPHC5Impl reports the decompressed length as bodySize
updateResult() sets bodySize = body.length where body is the decoded payload
(HTTPHC5Impl.java:901-914). HTTPHC4Impl derives it from connection metrics —
res.setBodySize(totalBytes - headerBytes) — that is, wire bytes; and the new HTTPJavaImpl HTTP/2 path uses a
CountingInputStream around the compressed stream.
A gzip response of 20 KB on the wire that expands to 400 KB is reported as 400 KB by HttpClient5 and as 20 KB by
HttpClient4 and Java. Switching a plan's implementation changes the Bytes column and any bandwidth SLA by a factor of
20, with no warning. headersSize is approximated differently too.
Suggested fix: wrap the raw entity stream in org.apache.jorphan.io.CountingInputStream before the decompressing
wrapper — the class HTTPJavaImpl already uses — and compute headersSize the way HC4 does. TestDecompression is
already parameterized across all three implementations; an assertion on getBytesAsLong() there would cover it.
M6. HTTPJavaImpl sent-bytes counts the human-readable body preview for file uploads
The HTTP/1.1 path computes postBodyBytes = getBytes(postBody) where postBody is the String returned by
sendPostData/sendPutData — the display form JMeter puts in the Request Body tab. For a file upload that is the
placeholder <actual file content, not shown here> plus the multipart boundary preview, not the payload.
calculateSentBytes then takes the postBodyBytes.length > 0 branch and never falls back to Content-Length.
POST a 10 MB file with Files Upload and the sample reports a few hundred sent bytes — the sent-bytes graph and the
dashboard understate upload traffic by three orders of magnitude.
The requestHeaders snapshot for the same computation is taken from conn.getRequestProperties() before
setPostHeaders/setPutHeaders has added Content-Length/Content-Type, so the header portion is under-counted too.
Suggested fix: count what is actually written by wrapping conn.getOutputStream() in a counting stream, falling back to
Content-Length.
M7. HTTPJavaImpl mutates global system properties from a static initializer
HTTPJavaImpl has static { applyHttp2SystemProperties(JMeterUtils.getJMeterProperties(), System.getProperties()); },
which writes jdk.httpclient.hpack.maxheadertablesize, jdk.httpclient.maxstreams, jdk.httpclient.windowsize,
jdk.httpclient.connectionWindowSize, jdk.httpclient.maxframesize, jdk.httpclient.keepalive.timeout.h2 and —
unconditionally — jdk.httpclient.enablepush into the JVM-wide table, as a side effect of loading the class.
Selecting the legacy Java implementation for a plain HTTP/1.1 sampler, or running TestDecompression (which
instantiates every implementation), flips jdk.httpclient.enablepush to 0 for every other component in the JVM:
JSR223 scripts, plugins, the backend listener, anything that builds its own java.net.http.HttpClient. In a
distributed-test server process the change survives across runs and cannot be undone. The setUnlessDefined guard only
protects values that are already present.
JMeter already has system.properties for exactly this. Suggested fix: apply the properties lazily and idempotently
when the first HTTP/2 sample is about to be taken, skip jdk.httpclient.enablepush when the JMeter property is absent,
or document that these belong on the command line and drop the mutation.
Design and reuse
D1. HTTPHC5Impl re-implements HTTPHC4Impl rather than extracting the shared logic
Roughly 700 of HTTPHC5Impl's 1499 lines are HTTPHC4Impl with the types substituted: getRequestHeaders,
getAllHeadersExceptCookie, setConnectionCookie, saveConnectionCookies, getOnlyCookieFromHeaders,
setConnectionHeaders, setupRequestEntity/createNameValuePairs, HttpClientKey (12 fields plus equals/
hashCode), the Kerberos SPN and strip-port logic, the route planner, and the connect-time-measuring connection
manager. CountingOutputStream (HTTPHC5Impl.java:997-1013) is derived a third time inside HTTPJavaImpl's
calculateSentBytes.
This is not a theoretical cost: B1, M3 and M5 are all cases where the HC5 copy drifted from the HC4 original within a
single PR. Lifting the cookie, entity and sent-bytes helpers into HTTPHCAbstractImpl (or a package-private utility
shared by HC4 and HC5) would have made those three impossible.
D2. CacheManager grows a third and fourth parallel API
CacheManager now carries three near-identical saveDetails overloads (HC4 HttpResponse, HC5 ClassicHttpResponse,
java.net.http.HttpResponse), two inCache, two setHeaders and two asHeaders adapters. A fix to the Vary or
cache-control handling now has to land in three places, and a core config element acquires a compile-time dependency on
both HttpClient 4 and HttpClient 5 — which also pins those versions into JMeter's public API for plugin authors.
Suggested shape: one saveDetails(ResponseHeaderSource, HTTPSampleResult), one inCache(URL, Header[]) and one
setHeaders(URL, RequestHeaderSink), with the adapters at the three call sites. The existing
CacheManager.Header/HeaderAdapter pair is already most of that abstraction.
D3. Is HTTPJavaImpl the right home for a second full HTTP/2 client?
HTTPJavaImpl grows from ~350 to ~1800 lines. Around 250 of those are a hand-written delegating SSLContext /
SSLContextSpi / SSLEngine triple whose only purpose is to observe when the TLS handshake finished, and ~60 more are
a hand-maintained IANA reason-phrase table (HTTP_REASON_PHRASES) — EnglishReasonPhraseCatalog is already on this
module's classpath.
The legacy Java implementation exists as the minimal, dependency-free fallback. Putting a second production HTTP/2
stack inside it means two independent HTTP/2 implementations to maintain, with different bugs (compare M4 against the
HC5 connect-time path) and different semantics (equals vs equalsIgnoreCase on the same stored value, see N5).
Worth deciding explicitly before merge: either HTTPJavaImpl gets HTTP/2 as a thin java.net.http passthrough without
the connect-time instrumentation and the multiplexing machinery, or the HTTP/2 support lives only in HTTPHC5Impl and
this PR shrinks by about a third. Splitting the PR along that line would also make it reviewable — 4855 lines across two
new HTTP stacks, a new GUI component, new core API and a dependency bump is a lot to land at once.
D4. HTTP/2 multiplexing across virtual users changes what the test measures
http.java.h2.multiplexing and httpclient5.h2.multiplexing both default to true, and for the Java implementation
that means one HttpClient shared by every JMeter thread. A load test then drives N virtual users over a single TCP
connection, which is not what N real browser users do — connection-level effects (congestion window, server-side
per-connection limits, TLS handshake cost) disappear from the measurement.
Multiplexing within one thread's embedded-resource downloads is clearly right. Sharing across threads is a modeling
decision that deserves a default of false, or at least an explicit note in component_reference.xml about what it
does to the results.
Minor and nits
N1. Response stream leaked when obey_contentlength short-circuits
In readResponse(HttpResponse<InputStream>, SampleResult) the if (contentLength == 0 && OBEY_CONTENT_LENGTH) branch
returns NULL_BA without closing in (response.body()). The JDK client releases an HTTP/2 stream only once the body
stream is consumed or closed, so with httpsampler.obey_contentlength=true against a target that answers
Content-Length: 0 every sample leaks a stream. Restructure as try-with-resources around response.body().
N2. Test quality
Several of the 1756 new test lines cannot fail when the behavior they name breaks:
enablesMessageMultiplexingWithoutRequiringHttpClient55,doesNotRequireProtocolUpgradeConfigurationand
doesNotRequireHttpAsyncClassicAdapterreadHTTPHC5Impl.classbytes and run a 90-line hand-written constant-pool
parser (TestHTTPHC5Features.java:784-846). They assert on the shape of the compiled artifact, throw
IOException("Unknown class-file constant-pool tag")on any tag a future JVM adds, and would pass if multiplexing
were looked up reflectively but never invoked.appliesHttp2ProtocolSettingsasserts the literals 8192/250/65535/65536, which are httpcore5's ownH2Config
defaults — it re-asserts the library, breaks on a dependency bump, and never covers the JMeter property override it
exists for.setsSentBytesCorrectlyForGetRequestasserts onlygetSentBytes() > 0, and the POST variant only
> "hello world".length(), so the sent-bytes arithmetic this PR adds is effectively untested. Assert the exact count
against WireMock's request journal.multiplexesConcurrentRequestsOverASingleHttp2Connection(both copies) asserts four HTTP/2 200s and would pass with
four separate connections. Count accepted connections instead.TestHTTPHC5FeaturessleepsvalidateAfterInactivityMillis() + 500(2.5 s) andThread.sleep(50); neither suite
closes the static clients it creates, so I/O reactor and selector threads accumulate for the life of the test JVM.
Related: the reflective findMessageMultiplexingSetter() lookup and its "JMeter still runs with older HttpClient
versions" comment are dead weight — build.gradle.kts pins httpclient5 at 5.6.4, so the method is always present.
Dropping the reflection removes the three class-file tests with it.
N3. HttpVersionComboBox hard-codes an English label next to a localized one
HttpVersionComboBox.java:47 defines "HTTP/2 Negotiate" as a Java constant with $NON-NLS-1$, while the label beside
it goes through JMeterUtils.getResString("http_version") and was translated into all 12 bundles. A user running JMeter
in German or Japanese sees a translated field label followed by an untranslated value, and translators have no key.
component_reference.xml documents the string as user-visible, so it is UI text.
Also, http_version was inserted out of alphabetical order in all 12 bundles (messages.properties:476 sits between
html_assertion_title and html_report; messages_de.properties:19 sits right after about).
N4. HttpDefaultsGui writes httpVersion unconditionally
HttpDefaultsGui.java:157 does config.set(httpSchema.getHttpVersion(), String.valueOf(httpVersion.getSelectedItem()))
while HttpTestSampleGui.java:183-185 normalizes a blank selection to null. Every HTTP Request Defaults element
opened and saved in the GUI therefore grows an empty HTTPSampler.httpVersion property — spurious diffs in
version-controlled JMX files — and String.valueOf turns a null selection into the literal "null". Neither GUI resets
the combo in clearGui().
N5. Duplicated constants and inconsistent version matching in the new API
HTTPConstantsInterfacegains bothHTTP_VERSION_2 = "HTTP/2"andHTTP_2 = "HTTP/2"— two public constants with
identical values and overlapping Javadoc. Callers already mix them.HTTPSamplerBase.HTTP_VERSION = "HTTPSampler.httpVersion"shadowsHTTPHCAbstractImpl.HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", "1.1")by simple name inside the same hierarchy: unqualified
HTTP_VERSIONmeans the property value in one class and the property name in the other. The rest of
HTTPSamplerBasenow derives such constants from the schema —HTTPSamplerBaseSchema.INSTANCE.getHttpVersion() .getName()would keep that convention.HTTPHC5Impl.getHttpVersionPolicycomparesHTTP_VERSION_2_STRICTwithequalsIgnoreCasebutHTTP_VERSION_2with
equals, sohttp/2selects HTTP/1.1 whilehttp/2 strictselects HTTP/2.HTTPJavaImpl.isHttp2uses
equalsIgnoreCasefor both, so the two implementations disagree about the same stored value.
N6. calculateSentBytes uses the platform default charset and models HTTP/2 as HTTP/1.1 text
Every getBytes(Charset.defaultCharset()) in HTTPHC5Impl.calculateSentBytes (HTTPHC5Impl.java:957-976) measures
header names and values in the platform encoding rather than the ISO-8859-1 the wire format uses, so the same plan
reports different sent bytes on a non-UTF-8 JVM.
More fundamentally, the method reconstructs an HTTP/1.1 request line and CRLF-separated headers even when the request
went out over HTTP/2, where headers are HPACK-compressed pseudo-headers — the reported value is systematically too high
once HPACK indexing kicks in on the second request to a host. HTTPHC4Impl takes the real count from connection
metrics (HTTPHC4Impl.java:701); HC5 exposes the same through HttpClientContext.getEndpointDetails() .getSentBytesCount().
Also: for a repeatable entity the method calls entity.writeTo(counter), which re-reads the whole upload from disk on
every sample purely to count it.
N7. httpclient.version is re-purposed but two existing readers still expect the old values
bin/jmeter.properties and properties_reference.xml now document httpclient.version as taking HTTP/1.1, HTTP/2
or HTTP/2 Strict. Two existing readers still expect 1.0/1.1:
AjpSampler.java:180—JMeterUtils.getPropDefault("httpclient.version","1.1").equals("1.0");HTTPHCAbstractImpl.java:80—protected static final String HTTP_VERSION = JMeterUtils.getPropDefault( "httpclient.version", "1.1").
So the documentation is now wrong for AJP, and a user who set httpclient.version=1.1 for the old meaning gets an
unrecognized value for HC5/Java. Either introduce a separate property for the new domain, or accept both spellings and
say so in the docs.
N8. Documentation gaps
changes.xmlnever announces the headline feature. All seven entries describe HTTP/2 details and assume
HttpClient5already exists; there is no entry for the new sampler implementation itself or for the new
HTTPSampler.httpVersionproperty.- The HTTP Request Defaults
<properties>block incomponent_reference.xmlgains noHTTP Versionentry, although
HttpDefaultsGuiadds the field. <dt><code>HTTPClient5</code></dt>(component_reference.xml:145) uses casing that does not match the alias
HttpClient5— it mirrors the pre-existingHTTPClient4typo, so fixing both is optional but welcome.
|
Thanks for the review @vlsi. All findings have been addressed. Concerning D3, the changes in HTTPJavaImpl have been reverted. The additional HTTP/2 implementation based on java.net.http is therefore no longer part of this PR. |
Code reviewFound 3 issues, all minor relative to the rest of the PR:
|
milamberspace
left a comment
There was a problem hiding this comment.
Re-review at 009fa5ff48. First, thanks: the previous round is in very good shape. Every blocker from @vlsi's review (B1–B6) is either fixed or moot after the HTTPJavaImpl revert, my 2026-08-14 constant / @since threads are addressed, and the 2026-08-19 points (elapsed time on setup failure, stale Javadoc) are fixed with tests. Going through the HttpClient 5 internals more deeply, though, I found two issues that would hit users of the new HTTP/2 path directly, plus a few behaviour gaps compared with HttpClient4, so I'm requesting changes.
The branch currently conflicts with master and needs a rebase before this can merge: master already has httpclient5:5.6.4 in src/bom-thirdparty/build.gradle.kts, so the resolution is simply to keep that line and add the two httpcore5 / httpcore5-h2 5.4.3 lines. The last CI run also has one red job (:src:dist-check:batchServerBatchTestLocal on Windows / JDK 17), which may well be the usual flaky batch test, but it needs a green re-run after the rebase.
Blocking — the response timeout is ignored once HTTP/2 is negotiated (HTTPHC5Impl.java:1295)
setupRequest puts the Response timeout into RequestConfig (line 622), and executeHttp2 then waits with responseFuture.get() with no timeout. In httpclient5 5.6.4, InternalHttpAsyncExecRuntime only applies RequestConfig.getResponseTimeout() to the endpoint (setSocketTimeout) when the negotiated protocol is below HTTP/2 (checked in the bytecode: protocol.greaterEquals(HttpVersion.HTTP_2) skips the call). No ConnectionConfig.setSocketTimeout or IOReactorConfig socket timeout is set either, so the effective timeout is infinite.
With HTTP/2 Negotiate or HTTP/2 Strict and a response timeout of e.g. 5000 ms, a server that never answers (or sends HEADERS and then stalls) blocks the sampler thread until Stop Test Now. The same element over HTTP/1.1 times out correctly. Suggested fix: responseFuture.get(responseTimeout, MILLISECONDS) when a timeout is set, cancel(true) on TimeoutException, and map it to a SocketTimeoutException so the sample fails like on HTTP/1.1. A test with an h2 server that accepts the stream but never responds would lock this in.
Blocking — client certificates are not presented on the async (HTTP/2) transport (HTTPHC5Impl.java:452)
The async client handshakes through an SSLEngine, so JSSE asks the key manager for chooseEngineClientAlias(...). JMeter's JsseSSLManager.WrappedX509KeyManager (src/core/.../util/JsseSSLManager.java, not touched by this PR) only overrides chooseClientAlias(..., Socket); the inherited X509ExtendedKeyManager.chooseEngineClientAlias returns null. So with a Keystore Configuration and a server requiring mutual TLS, HTTP/2 (and HTTP/2 Negotiate falling back to 1.1 on the same client) presents no certificate and the handshake fails. The classic HTTP/1.1 path uses SSLSocket and is fine.
Simply overriding chooseEngineClientAlias is not quite enough: JmeterKeyStore.getAlias() resolves the alias from JMeterContextService.getContext().getVariables(), and on the I/O reactor thread that is not the sampler's context. The alias should be picked on the sampler thread (e.g. carried in the HttpClientContext / per-request TLS attachment) and read back by the engine variant. The changes.xml entry currently lists "client certificates" as supported, so this needs either a fix plus a client-auth test, or an explicit documented limitation for HTTP/2.
Automatic redirects stop at any cross-origin hop when a Cookie or Authorization header is present (HTTPHC5Impl.java:620)
No RedirectStrategy is configured, so HttpClient 5's DefaultRedirectStrategy is used. Its isRedirectAllowed refuses to follow a redirect to a different authority when the request carries a Cookie or Authorization header (or any sensitive header), and RedirectExec / AsyncRedirectExec then return the 3xx as the final response. JMeter sets cookies (Cookie Manager) and pre-emptive Basic auth as explicit request headers, so a typical SSO flow (app.example.com → 302 → idp.example.com) with Redirect Automatically records a successful 302 and never reaches the IdP, whereas HTTPHC4Impl (LaxRedirectStrategy) follows it. Suggested fix: a redirect strategy that allows the hop (and ideally re-computes Cookie / drops Authorization per hop, as the Cookie/Auth managers would for the new host), plus setMaxRedirects(HTTPSamplerBase.MAX_REDIRECTS) so httpsampler.max_redirects is honoured as in HC4 (HC5 currently uses its own default of 50).
Received bytes are wrong for uncompressed HTTP/1.1 bodies when truncation or MD5 is active (HTTPHC5Impl.java:1054)
On the classic path the body counter only exists when a Content-Encoding triggered decompression, so an uncompressed response falls back to body.length, i.e. what readResponse stored: truncated to httpsampler.max_bytes_to_store_per_request, or the 32-character hex digest when Save response as MD5 hash is on. A 50 MB download with MD5 enabled then reports ~32 bytes of body, and Received KB/sec in the reports is wrong. Wrapping every HTTP/1.1 entity in CountingEntity (not only encoded ones) and always using its count would align it with HC4, which reports wire bytes.
One I/O reactor with availableProcessors() threads per (JMeter thread × client key) (HTTPHC5Impl.java:1233)
createHttp2Client sets no IOReactorConfig, so each async client starts Runtime.availableProcessors() I/O dispatcher threads plus its main reactor thread. Clients are cached per JMeterContext and per HttpClientKey (which includes the target authority), and when the Thread Group's Same user on each iteration is unchecked (with httpclient.reset_state_on_thread_group_iteration=true, the default) they are closed and rebuilt every iteration. 1,000 threads on a 16-core injector hitting 3 hosts means ~51,000 platform threads — recreated each iteration in that mode. IOReactorConfig.custom().setIoThreadCount(1) per client would already make this proportional to the thread count; sharing one reactor per JMeter thread would be better still.
NTLM and the Authorization Manager domain/realm are not supported (HTTPHC5Impl.java:889)
Target and proxy credentials are always UsernamePasswordCredentials scoped to host:port: the Domain / Realm columns of the Authorization Manager and http.proxyDomain are ignored, and no NTLMSchemeFactory is registered. HC4 registers NTLM and uses NTCredentials for both target and proxy, so an IIS site or corporate proxy that only offers NTLM answers 401/407 with HttpClient5. Since the changes.xml entry says HC5 "supports the features of the existing HttpClient4 implementation", either register NTLM (deprecated in HC5 but still shipped) and build NTCredentials when a domain is set, or document the gap.
Smaller observations
Inline comments on HTTPHC5Impl.java:241, :531, :1207, HTTPHC4Impl.java:526, HTTPHCAbstractImpl.java:94 and xdocs/changes.xml:84; the remaining ones:
HTTPHC5Impl.java:1061— after automatic redirects the reported request headers and sent bytes describe the first hop, while the URL is the final one (HC4 reads the executed request from the context). Headers added by HttpClient itself (Digest / NegotiateAuthorization) are also not shown.HTTPHC5Impl.java:427— if a parallel-download pool thread is the first to need a client for a key, the TLS strategy captures that pool thread's thread-localSSLContext, not the JMeter thread's.HTTPHC4Impl.java:1167— the per-key map is now explicitly shared with the parallel-download threads (hence theConcurrentHashMap), but lookup (line 1066) and insert are still separateget/put, so two concurrent downloads to a new authority can both build a client and the overwritten one is never closed.computeIfAbsentorputIfAbsent+ close-the-loser would fix it.@since 6.0is still missing on most new public/protected API:HTTPHC5Impl,HTTPSamplerFactory.IMPL_HTTP_CLIENT5/getHttpVersions,HTTPConstantsInterface.HTTP_VERSION_1_1/HTTP_VERSION_2/HTTP_VERSION_2_STRICT,HTTPSamplerBase.HTTP_VERSION/setHttpVersion/getHttpVersion,HTTPAbstractImpl.HTTP_VERSION_PROPERTY/readDefaultHttpVersion,HTTPHCAbstractImpl.DEFAULT_HTTP_VERSIONand the new protected helpers. Also,HTTP_VERSION_1_1duplicates the existingHTTPConstantsInterface.HTTP_1_1value.messages.properties:481—http_version_2_negotiateexists only in English whilehttp_versionwas added to 11 locales; at least a French entry would be nice.- Remaining literal
"<actual file content, not shown here>"is repeated inHTTPHC5Impl(lines 720, 778) andHTTPHC4Impl— a shared constant would close the last of my earlier threads. TheHTTPJavaImplthreads from 2026-08-14 are moot after the revert and can be resolved.
This review was drafted by an AI-assisted tool and
confirmed by an Apache JMeter maintainer. After you've
addressed the points above and pushed an update, an Apache JMeter
maintainer — a real person — will take the next look
at the PR. The findings cite the project's review criteria;
if you think one of them is mis-applied, please reply on the
PR and a maintainer will weigh in.More on how Apache JMeter handles maintainer review:
Contributing guide.
| responseFuture.cancel(true); | ||
| } | ||
| try { | ||
| return responseFuture.get(); |
There was a problem hiding this comment.
Blocking — this get() has no timeout, and on an HTTP/2 connection httpclient5 5.6.4 never applies RequestConfig.getResponseTimeout() (InternalHttpAsyncExecRuntime only calls setSocketTimeout when the protocol is below HTTP/2). A stalled server blocks the sampler thread until Stop Test Now.
Reproduced against a real HTTP/2 server: with a 1 ms response timeout, HttpClient4 and HttpClient5/HTTP/1.1 fail with Read timed out, while HttpClient5/HTTP/2 returns the full response after ~2 s.
Suggestion: responseFuture.get(responseTimeout, TimeUnit.MILLISECONDS) when a timeout is set, cancel(true) on TimeoutException, and rethrow as SocketTimeoutException.
There was a problem hiding this comment.
Thanks for catching this.
Rebase is done, and I’ve addressed the timeout handling in commit cd1b63b. This should now be resolved.
| } | ||
|
|
||
| private static TlsStrategy createTlsStrategy() { | ||
| return createTlsStrategyBuilder().buildAsync(); |
There was a problem hiding this comment.
Blocking — the async transport handshakes through an SSLEngine, so JSSE calls chooseEngineClientAlias, which JsseSSLManager.WrappedX509KeyManager does not override (the inherited default returns null). With a Keystore Configuration and a server requiring client auth, HTTP/2 presents no certificate. Note the alias must be chosen on the sampler thread: JmeterKeyStore.getAlias() reads the JMeter variables of the current context, which is wrong on the I/O reactor thread. See the review body for details.
There was a problem hiding this comment.
This has been addressed and should be resolved by commit 2e83dd3. Thanks for pointing this out.
| HttpVersionPolicy httpVersionPolicy = getHttpVersionPolicy(testElement.getHttpVersion(), DEFAULT_HTTP_VERSION, | ||
| url.getProtocol()); | ||
| RequestConfig.Builder config = RequestConfig.custom() | ||
| .setRedirectsEnabled(getAutoRedirects() && !areFollowingRedirect); |
There was a problem hiding this comment.
No RedirectStrategy is set, so DefaultRedirectStrategy.isRedirectAllowed refuses a hop to a different scheme/host/port whenever the request carries Cookie or Authorization, and the 3xx becomes the final result. Reproduced: http://host/ → 302 → https://host/ reaches 200 with HttpClient4 and with HttpClient5 without cookies, but stays on 302 with HttpClient5 + a Cookie Manager cookie or an Authorization header.
Also missing here: setMaxRedirects(HTTPSamplerBase.MAX_REDIRECTS) so httpsampler.max_redirects is honoured as in HC4.
There was a problem hiding this comment.
Thanks, this has been addressed and should be resolved by commit be3a804.
| Long bodySize = (Long) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_SIZE); | ||
| CountingEntity bodyCounter = | ||
| (CountingEntity) context.getAttribute(CONTEXT_ATTRIBUTE_RESPONSE_BODY_COUNTER); | ||
| result.setBodySize(bodySize != null ? bodySize |
There was a problem hiding this comment.
Without Content-Encoding the classic path has no bodyCounter, so this falls back to body.length — what readResponse stored, i.e. the truncated body or the 32-char MD5 hex. Reproduced with Save response as MD5 hash: bodySize=32 with HttpClient5/HTTP/1.1 vs 8377 with HttpClient4 and 8124 with HttpClient5/HTTP/2 on the same page. Wrapping every HTTP/1.1 entity in CountingEntity would fix it.
There was a problem hiding this comment.
Thanks, this has been addressed and should be resolved by commit f60c76e.
| .build(); | ||
| } | ||
|
|
||
| private static CloseableHttpAsyncClient createHttp2Client(HttpClientKey key) { |
There was a problem hiding this comment.
No IOReactorConfig here, so each async client starts availableProcessors() dispatcher threads + a main reactor thread, per JMeter thread and per HttpClientKey. Measured: 20 VUs on 8 CPUs → 160 httpclient-dispatch + 20 httpclient-main threads, and 320 created after 2 iterations when Same user on each iteration is unchecked. IOReactorConfig.custom().setIoThreadCount(1) would already make it proportional to the thread count.
There was a problem hiding this comment.
Thanks, this has been addressed and should be resolved by commit b1aa5eb.
| return JMeterUtils.getPropDefault(DISABLE_DEFAULT_UA_PROPERTY, false); | ||
| } | ||
|
|
||
| private static CloseableHttpClient createClient(HttpClientKey key) { |
There was a problem hiding this comment.
Minor: the classic PoolingHttpClientConnectionManager keeps HttpClient's defaults (5 per route / 25 total). Parallel embedded-resource downloads now share the parent thread's client and JMeter's default concurrent pool is 6, so extra downloads wait for a lease and inflate elapsed times. Consider setMaxConnPerRoute ≥ the concurrent pool size.
| * materialized as a byte array. | ||
| */ | ||
| private static final int HTTP_2_BODY_SPILL_THRESHOLD = | ||
| JMeterUtils.getPropDefault("httpclient5.h2.body_spill_threshold", 256 * 1024); |
There was a problem hiding this comment.
Nit: httpclient5.h2.body_spill_threshold is not documented in bin/jmeter.properties nor in properties_reference.xml.
| } | ||
| }; | ||
| private static final Map<JMeterContext, Map<HttpClientKey, HttpClientState>> | ||
| HTTPCLIENTS_CACHE_PER_THREAD_AND_HTTPCLIENTKEY = Collections.synchronizedMap(new WeakHashMap<>()); |
There was a problem hiding this comment.
Minor: HttpClient4 is the default implementation and every HC4 sample now goes through this single JVM-wide synchronizedMap (computeIfAbsent, plus WeakHashMap.expungeStaleEntries under the lock), where the previous InheritableThreadLocal had no lock. The fix itself is right; keeping the map on the JMeterContext (e.g. in getSamplerContext()) would give the same per-context semantics without global contention.
|
|
||
| protected static final String HTTP_VERSION = JMeterUtils.getPropDefault("httpclient.version", "1.1"); | ||
| /** Default value of the sampler {@code HTTPSampler.httpVersion} property, see {@code httpclient.version}. */ | ||
| protected static final String DEFAULT_HTTP_VERSION = readDefaultHttpVersion(); |
There was a problem hiding this comment.
Nit: the protected static final String HTTP_VERSION this replaces is gone, which breaks third-party subclasses referencing it (compile error / NoSuchFieldError). A @Deprecated HTTP_VERSION kept alongside would be safer. Also @since 6.0 on the new protected API.
| <li><issue>6250</issue>Avoid adding "; charset=" automatically to <code>multipart/form-data</code> requests to align behavior with modern HTTP clients.</li> | ||
| <li><issue>6080</issue>Preserve the original HTTP method when following 307 and 308 redirects according to the HTTP specification. Contributed by LeeJiWon (github.com/dlwldnjs1009)</li> | ||
| <li><issue>6267</issue><pr>6268</pr>Add a space between key and value after <code>:</code> in View Results Tree > Sampler result tab for better readability.</li> | ||
| <li><pr>6742</pr>Add the new <code>HttpClient5</code> sampler implementation, which is based on Apache HttpComponents HttpClient 5.x and can be selected in the <code>Implementation</code> combo box of the HTTP Request sampler and the HTTP Request Defaults, or with the <code>jmeter.httpsampler</code> property. It supports the features of the existing <code>HttpClient4</code> implementation (cookie, cache, header and authorization management, proxies, client certificates, WebDav methods, DNS Cache Manager, connection reuse and retries) and adds HTTP/2 support.</li> |
There was a problem hiding this comment.
"connection reuse and retries": automatic retries are disabled (disableAutomaticRetries() in both builders), and client certificates don't currently work over HTTP/2. Please also add <issue>5081</issue> / <issue>4023</issue> for the issues this PR fixes.
…HTTPHC5Impl` for DNS resolution
…proxies, and authentication
…ng, caching, proxies, and user authentication
…ownloads Both HTTP/2 sampler paths materialized entire bodies in heap: HTTPHC5Impl converted the request entity with EntityUtils.toByteArray() and held the response as a byte array (copied again into a ByteArrayEntity), while HTTPJavaImpl captured uploads into a ByteArrayOutputStream before building BodyPublishers.ofByteArray(). Large uploads died with OutOfMemoryError where HTTP/1.1 succeeded, and httpsampler.max_bytes_to_store_per_request could not help downloads because it is applied only after the bytes exist. Requests now stream: HTTPHC5Impl uses AsyncRequestBuilder with a FileEntityProducer over a new memory-first SpillOutputStream (256 KB in heap, then a temp file), and HTTPJavaImpl publishes with BodyPublishers.ofFile. Direct file uploads stream with no copy at all via PathEntity. Responses now truncate at the source: the async response consumer stores only max_bytes_to_store_per_request bytes while still counting the full body, so the bytes beyond the limit never reach the heap. Truncation is skipped when the body is content-encoded (a partial gzip stream cannot be decoded) and when MD5 or recording needs the whole body; the limit is resolved on the sampler thread because the I/O reactor threads cannot see JMeter's ThreadLocal context. That gives the same peak heap usage as the HTTP/1.1 transport, which reads and truncates the body as it arrives. Separately, HTTP/2 downloads were about three times slower than the same request over HTTP/1.1 or with the Java implementation, because httpclient5.h2.initial_window_size defaulted to the 64 kB minimum the HTTP/2 specification mandates. The stream flow control window caps the throughput of a response at the window divided by the round trip time, no matter how much bandwidth is available. JMeter now announces the same 16 MB window the JDK client uses, which is already the default of the Java implementation. On a 100 MB download this cut the time from 15 s to 4 s. The connection level window needs no configuration, as HttpCore maximizes it on its own. Also fixed along the way: * multipart previews materialized the whole uploaded file in heap * response bodies were dropped when the server sent no Content-Type * gzip HTTP/2 responses were not decoded * spilled upload files were deleted between internal redirect attempts * setBodySize() reported the truncated length rather than the wire length Extract Http2CapturingHttpURLConnection from HTTPJavaImpl and add SpillOutputStream; commons-io is not a dependency of this module.
…s and ensure location header presence
…meTracker to measure connection establishment duration and update sample results accordingly
… size and response bytes accounting across HTTP implementations. Update tests to validate compressed body size handling.
…oduce CountingOutputHttpURLConnection to track request body size accurately. Update sent bytes calculation and add Content-Length header when missing. Add tests for large file uploads.
- Remove eager static initializer in `HTTPJavaImpl` that mutated global JVM system properties on class loading. - Apply HTTP/2 `jdk.httpclient.*` system properties lazily and idempotently when the first HTTP/2 client instance is created. - Skip setting `jdk.httpclient.enablepush` when `http.java.h2.push_enabled` is not explicitly defined in JMeter properties, preserving JDK defaults for other JVM components. - Update `TestHTTPJavaFeatures` to verify JDK defaults remain intact when properties are unset.
…th is enabled Wrap the HTTP/2 response body stream in a try-with-resources block in HTTPJavaImpl#readResponse(HttpResponse<InputStream>, SampleResult). Previously, when httpsampler.obey_contentlength was set to true and the server returned Content-Length: 0, the method returned early without closing the response stream, leaking JDK HTTP/2 streams.
Replace tests that could not fail with ones that pin down the behaviour they name: - Drop the reflective setMessageMultiplexing() lookup, httpclient5 is pinned at 5.6.4, and remove the three tests that parsed the compiled class file to check for it. - Assert the exact sent bytes against WireMock's request journal. This uncovered that calculateSentBytes() ignored the headers the protocol layer writes itself, so Host, Content-Type, Content-Encoding and Content-Length/Transfer-Encoding are now accounted for as well. - Count the accepted TCP connections in both multiplexing tests instead of only checking four HTTP/2 200s. The relay that does this moved to a shared TcpRelay test helper. - Assert JMeter's own HTTP/2 settings and the property overrides they exist for, instead of re-asserting the H2Config defaults of httpcore5. - Close the clients both suites create, shorten the re-validation wait from 2.5 s to 0.5 s and drop a pointless sleep.
…rce keys - Replace hardcoded "HTTP/2 Negotiate" string in HttpVersionComboBox with `http_version_2_negotiate` resource string - Add `http_version_2_negotiate` to `messages.properties` - Sort `http_version` property alphabetically across all 12 message bundles - Add unit test in `TestHttpVersionComboBox` verifying HTTP/2 label rendering
…ombos in clearGui - Normalize blank `httpVersion` and `httpImplementation` selections to `null` in `HttpDefaultsGui` and `HttpTestSampleGui` - Reset implementation and version combo boxes in `clearGui()` for both HTTP GUI components - Add unit tests in `TestHttpDefaultsGui` and `TestHttpTestSampleGui`
Remove HTTPConstants.HTTP_2, which duplicated HTTP_VERSION_2. Derive HTTPSamplerBase.HTTP_VERSION from the schema and rename HTTPHCAbstractImpl.HTTP_VERSION to DEFAULT_HTTP_VERSION, so the same simple name no longer means both a property name and its value. HTTPHC5Impl compared "HTTP/2" exactly but "HTTP/2 Strict" ignoring case, so a stored "http/2" fell back to HTTP/1.1 and disagreed with HTTPJavaImpl. Compare both ignoring case and cover it with a test.
The httpclient.version property now selects HTTP/1.1, HTTP/2 or HTTP/2 Strict, but it historically only took 1.0 and 1.1. Two readers were left behind: AjpSampler compared against the old spellings, so the documented values were wrong for AJP, and HTTPHCAbstractImpl defaulted to "1.1", which is not a recognized version for the HttpClient5 and Java implementations. Read the property through HTTPAbstractImpl.readDefaultHttpVersion(), which maps the legacy values 1.0 and 1.1 to HTTP/1.1, and let AjpSampler accept both 1.0 and HTTP/1.0 for its request line. Document both spellings and the AJP behaviour in jmeter.properties, properties_reference.xml and changes.xml.
changes.xml described only the HTTP/2 details and assumed the HttpClient5 implementation already existed, so the headline features of this release were missing: the new sampler implementation itself and the per-sampler HTTP Version field backed by the new HTTPSampler.httpVersion property. Add a changes.xml entry for each, and document HTTP Version in the HTTP Request Defaults properties block of component_reference.xml, which HttpDefaultsGui gained but the reference never listed. Also spell the implementations HttpClient4 and HttpClient5 as the aliases do, instead of the pre-existing HTTPClient4/HTTPClient5 typo, in component_reference.xml and in the properties_reference.xml section title.
… sampler implementations HTTPHC5Impl carried its own copy of the HttpClient4 cookie, header and form entity handling, and CountingOutputStream existed a fourth time. Lift the type-independent parts into HTTPHCAbstractImpl behind a HeaderIterable seam, add HTTPMessageSizes for the wire lengths shared with HTTPJavaImpl, and reuse jorphan's CountingOutputStream. Along the way the copies that had drifted are unified: HttpClient5 now strips a default port from a Host header of the Header Manager, multiple Cookie headers are joined, sent bytes are measured with ISO-8859-1, and jorphan's CountingOutputStream counts single byte writes again.
Replace the per implementation saveDetails/inCache/setHeaders overloads with saveDetails(ResponseHeaderSource, HTTPSampleResult), inCache(URL, RequestHeaderSource) and setHeaders(URL, RequestHeaderSink), so the Vary and cache-control handling lives in one place and the config element no longer compiles against HttpClient 5. The sampler implementations adapt their requests and responses at the call sites, and the HttpClient 4 typed methods stay as deprecated delegators.
The Java implementation shared one HttpClient with all threads, so a test drove N virtual users over a single connection and lost connection level effects. The clients are now held in an InheritableThreadLocal, so a thread still multiplexes its own requests and the parallel downloads of its embedded resources over one connection per origin, while each thread connects on its own. The former behaviour is available with the new http.java.h2.share_connections_between_threads property, which replaces http.java.h2.multiplexing and defaults to false.
…efore closing clients
The threads that download embedded resources in parallel are pooled and shared by all JMeter threads, so an InheritableThreadLocal bound them to whichever JMeter thread created them. They kept using that thread's HTTP client, which failed samples of other threads with IOException, "Socket closed" or "Connection pool shut down" as soon as the owning thread finished or started a new iteration. Look the clients up by the JMeterContext the downloader thread adopts from the JMeter thread it works for, in the HttpClient4, HttpClient5 and Java sampler implementations. This keeps HTTP/2 multiplexing between a sample and its embedded resources and stops a thread from closing clients another one is still using.
JMeter hands out an SSLContext per thread, so a pooled thread downloading embedded resources considered the HTTP/2 client of the JMeter thread it works for outdated, replaced it and shut it down while the sample and its sibling downloads were still using it, failing them with "java.io.IOException: shutdownNow".
The Java sampler implementation is left as it was, so HTTPJavaImpl keeps using HttpURLConnection and the HTTP Version combo box offers HTTP/1.1 only for it, as for HttpClient4. HTTP/2 stays with the HttpClient5 implementation. Drop the helpers only that extension needed (ConnectTimeTracker, CountingOutputHttpURLConnection, Http2CapturingHttpURLConnection, the HTTPAbstractImpl testEnded hook), the http.java.h2.* properties and the documentation of both. The shared parts of the earlier bugfixes stay: HTTPMessageSizes, SpillOutputStream, the HTTP client neutral CacheManager API and the jorphan CountingOutputStream fix.
If setupRequest() throws an exception (such as an invalid charset in Content-Encoding or an unresolvable upload file) before result.sampleStart() is called, the catch block calls result.sampleEnd() with startTime still at 0. This causes the error sample to report an elapsed time calculated from the Unix epoch rather than the actual duration. Ensure result.sampleStart() is invoked in the catch block if startTime has not yet been set. Also update stale Javadoc on HttpClientKey in HTTPHC4Impl to reflect that it keys a map rather than a ThreadLocal, and clarify the class Javadoc of HTTPMessageSizes.
HttpClient 5 applies RequestConfig.getResponseTimeout() as socket timeout of the connection, which it skips once HTTP/2 was negotiated, as all streams share the connection. executeHttp2 waited for the response without a timeout, so a server that accepted the stream but never answered, or sent the headers and then stalled, blocked the sampler thread until the test was stopped, while the same element timed out over HTTP/1.1. Enforce the timeout on the sampler thread with the new AsyncResponseTimeout. The timeout starts when a request is handed to the connection and every part of the request body sent and of the response received restarts it, so like the HTTP/1.1 socket timeout it applies to each wait for the server, and a response arriving in chunks may take longer in total. It covers the exchanges of redirects and authentication challenges, and it is suspended while a connection is leased or connected. On expiry the exchange is cancelled and the sample fails with a SocketTimeoutException, and a SocketTimeoutException HttpClient detects itself is no longer wrapped in a plain IOException, so both are reported like with the HTTP/1.1 transport. Add tests with servers that never respond, over negotiated and strict HTTP/2, with one that stalls after the response headers, and with a response that trickles in for longer than the timeout.
Rebasing onto master re-added the "Update Rhino JavaScript engine to 1.8.0" entry to the non-functional changes, next to the "1.9.1" entry master has. The HttpClient 5 update entry was anchored on that line, which master had changed, and git kept the old line without reporting a conflict.
009fa5f to
9257484
Compare
The asynchronous client, which the HttpClient5 implementation uses for HTTP/2, performs the TLS handshake with an SSLEngine on its I/O threads. JSSE then asks the key manager for chooseEngineClientAlias, which JsseSSLManager.WrappedX509KeyManager did not override, so the inherited implementation returned null and no client certificate was presented. A server requiring mutual TLS failed the handshake over HTTP/2, and over HTTP/1.1 negotiated by the same client, while the classic HTTP/1.1 transport, which uses an SSLSocket, worked. Choose the alias for an SSLEngine like for a socket. The alias variable of the Keystore Configuration is resolved with the variables of the current JMeterContext, which on an I/O thread is not the one of the sampler, so let the threads of an HTTP/2 client share the context of the JMeter thread the client belongs to, like the threads which download embedded resources in parallel do. The alias is thus still chosen during the handshake with the variables of the sampler, as over HTTP/1.1. Add a test with a server that requires a client certificate and trusts only the one the alias variable selects, over HTTP/1.1, HTTP/2 and HTTP/2 Strict.
HttpClient 5 copies the headers of the initial request to every redirect, and its DefaultRedirectStrategy refuses to follow a redirect to another origin when they include a Cookie or an Authorization header. JMeter sets both itself, from the Cookie Manager and for pre-emptive Basic auth, so with "Redirect Automatically" a redirect to an identity provider ended the sample with the 3xx response, while HTTPHC4Impl follows it. HttpClient also applied its own default of 50 redirects instead of httpsampler.max_redirects. Install AutoRedirectStrategy on both clients. It follows the redirect and sends each hop with what the managers hold for its own URL, like a sample of "Follow Redirects": the cookies a redirect sets are stored in the Cookie Manager, the Cookie header holds the cookies for the URL of the hop, and the Authorization header the pre-emptive Basic credentials of the Authorization Manager for it, whose credentials also answer a challenge of the new host. A Cookie or Authorization header of the Header Manager is only sent to the origin of the sampled URL, so it does not leak to another host. Limit the redirects by httpsampler.max_redirects, as HTTPHC4Impl does. Disable the cookie management of HttpClient. It stored every Set-Cookie in a cookie store of its own and sent those cookies whenever a request had no Cookie header, so cookies were sent without a Cookie Manager, unlike with the other implementations, and a redirect could get cookies the Cookie Manager does not hold. Add tests for a login flow through an identity provider on another origin, for httpsampler.max_redirects and for sending no cookies without a Cookie Manager, over HTTP/1.1 and HTTP/2.
…TTPHC5Impl The HTTP/1.1 transport only counted the response body when a Content-Encoding made it decode the body, and otherwise reported the size of what readResponse stored. That is the body truncated to httpsampler.max_bytes_to_store_per_request, or the 32 characters of its digest when "Save response as MD5 hash" is selected, so a large download reported a fraction of its size and Received KB/sec was wrong. Count every response body as it is read off the connection, before it is decoded, like HTTPHC4Impl reports the bytes it received. The HTTP/2 transport already counts the body while it streams it. Add a test that stores only the MD5 digest of plain and gzip encoded bodies, over HTTP/1.1 and HTTP/2, with HttpClient4 as the reference.
HTTPHC5Impl built its HTTP/2 clients without an IOReactorConfig, so each client started an I/O thread per processor, plus the thread HttpClient starts the I/O reactor from. The clients were kept per JMeter thread and per target host, so 1,000 threads sampling 3 hosts on a 16-core injector ran some 51,000 threads, rebuilt every iteration when the thread does not keep the same user. Start a single I/O thread per client, as a JMeter thread only has a few requests in flight, its embedded resources included. Leave the scheme and authority out of the key of the HTTP/2 clients, as their pool keeps the connections per route and nothing else about them depends on the host, so a JMeter thread usually has a single HTTP/2 client whatever the hosts it samples. Name the threads of a client after the JMeter thread it belongs to, so a thread dump tells which thread they work for. Add a test that samples two hosts over HTTP/2 from one JMeter thread and checks the threads of its client, and that they end when the client is closed.
… HTTPHC5Impl HTTPHC5Impl offered neither NTLM nor credentials for it: target and proxy credentials were always UsernamePasswordCredentials scoped to host and port, so the Domain and Realm of the Authorization Manager and the http.proxyDomain property were ignored. An IIS site or a corporate proxy that only offers NTLM answered 401 or 407, while HTTPHC4Impl authenticates. Pre-emptive Basic credentials were also set as an Authorization header of the request, which keeps HttpClient from answering any challenge, so an entry with the BASIC mechanism, the default of new entries, could not fall back to NTLM or Digest either. Set up authentication like HTTPHC4Impl does, in the new HC5Authentication: register NTLM, which HttpClient 5 deprecates but still ships, and prefer it ahead of Digest and Basic. Add NTCredentials with the domain of the entry, or of http.proxyDomain for the proxy, and scope the credentials of an entry with a realm to that realm. Have HttpClient send the pre-emptive Basic credentials itself, so a challenge for another scheme can still be answered, on redirects as well. They are still reported with the request and counted in the sent bytes. Document how NTLM is answered, and that it needs HTTP/1.1. Add tests for an NTLM server and an NTLM proxy, over HTTP/1.1 and HTTP/2, for a Digest challenge to an entry with the BASIC mechanism, for the realm of an entry and for the reporting of the pre-emptive credentials.
HTTPHC5Impl looked up its HttpClient only when it executed the request, after result.sampleStart(). The first sample of a thread to a host, and of each iteration when the thread does not keep the same user, therefore included building the client, with its connection pool, the SSL context of the thread and, for HTTP/2, the start of its I/O reactor. HTTPHC4Impl sets its client up before the sample starts. Look up, or build, the client for the request before sampleStart(), and execute the request with that client afterwards. The connect and the TLS handshake stay part of the sample.

Description
This PR adds a new HTTP sampler implementation,
HttpClient5, based on Apache HttpComponents HttpClient 5.x, and introduces a configurable HTTP Version setting (HTTP/1.1/HTTP/2) for both theHttpClient5and theJavaimplementation.Main changes:
HTTPHC5Impl(HTTPSamplerFactory.IMPL_HTTP_CLIENT5, selectable asHttpClient5in the GUI and in JMX files):HttpVersionPolicy(FORCE_HTTP_1/NEGOTIATE), with automatic fallback to HTTP/1.1 when the server does not offer h2 via ALPN.AuthManager(BASIC/DIGEST, pre-emptive BASIC),CacheManager(conditional requests viaIf-Modified-Since/If-None-Match),CookieManager,DNSCacheManager, response decompression (gzip/deflate/brotli), and retry handling.SampleResultmetrics:sentBytes,connectTime(measured for both HTTP/1.1 and HTTP/2, including TLS),latency, headers and response code/message.HTTPJavaImpl: HTTP/2 support via the JDKjava.net.http.HttpClientwhenHTTP/2is selected, including caching, proxies, user authentication,sentBytesaccounting, connect-time measurement, reason-phrase derivation (HTTP/2 has no reason phrase) and preservation ofAuthorization/Proxy-Authorizationheaders.HTTPSampler.httpVersion(HTTPSamplerBaseSchema.httpVersion, getter/setter onHTTPSamplerBase) with a new combo box in HTTP Request and HTTP Request Defaults (http_versionresource key added to allmessages_*.properties).CacheManager: new overloads for HC5 (ClassicHttpRequest/ClassicHttpResponse/org.apache.hc.core5.http.Header[]) and for the JDKjava.net.http.HttpResponse.httpclient.versionre-purposed as the default HTTP version (HTTP/1.1|HTTP/2) used when the sampler's HTTP Version field is empty.httpclient5andhttpcore5added tosrc/protocol/httpand to the third-party BOM (httpcore5:5.3.4).component_reference.xml,properties_reference.xml,get-started.xml,bin/jmeter.properties.Motivation and Context
JMeter's HTTP samplers currently only support HTTP/1.1: the
HttpClient4implementation is built on the HttpComponents 4.x line, which will not receive HTTP/2 support, and theJavaimplementation used the legacyHttpURLConnection. Modern web applications and APIs are increasingly served over HTTP/2, so load tests against them either could not be executed at all or did not represent realistic client behaviour (multiplexing, HPACK header compression, single connection per origin).This change gives users a supported migration path to HttpComponents 5.x and makes it possible to run load tests over HTTP/2 — either with the fully featured
HttpClient5implementation or, for lightweight scenarios, with the JDK client in theJavaimplementation. Existing test plans are unaffected:HttpClient4remains the default and an empty HTTP Version falls back to the previous HTTP/1.1 behaviour.Fixes:
How Has This Been Tested?
TestHTTPHC5Features(16 tests): version selection and precedence (sampler value vs.httpclient.versionvs. unsupported value), HTTP/2 usage, fallback to HTTP/1.1 when the server does not support h2, HTTP/2 via proxy,sentBytesfor GET/POST, conditional requests throughCacheManager, BASIC credentials fromAuthManager, proxy authentication, andconnectTimefor HTTP/1.1 and HTTP/2.TestHTTPJavaFeatures(~16 tests): version selection, HTTP/2 requests (incl. via proxy), response message / reason-phrase handling for HTTP/2,sentBytesfor GET/POST in both versions,Authorizationheader from theHeaderManager, andconnectTimefor HTTP/1.1, HTTP/2 plaintext and HTTP/2 over TLS.TestHTTPSamplerFactory: creation and lookup of the newHttpClient5implementation, plus the unchanged behaviour for the existing aliases../gradlew classes style— compiles cleanly and reports no style/checkstyle/autostyle violations.src:protocol:httptest suite (includingJMeterTest, extended byhttpVersionin the ignored-properties list) still passes.HTTP/2in the View Results Tree.JMeter 6.0.0-SNAPSHOT.Screenshots (if appropriate):
Types of changes
Checklist: