diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/VirtualThreadSupport.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/VirtualThreadSupport.java
new file mode 100644
index 0000000000..b77a9c53b7
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/VirtualThreadSupport.java
@@ -0,0 +1,102 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl;
+
+import java.lang.reflect.Method;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ThreadFactory;
+
+import org.apache.hc.core5.annotation.Internal;
+
+/**
+ * Utilities for working with JDK 21 virtual threads without introducing a hard runtime dependency.
+ *
+ *
+ * All methods use reflection to detect and construct virtual-thread components so that the client
+ *
+ * remains source- and binary-compatible with earlier JDKs. On runtimes where virtual threads are
+ *
+ * unavailable, the helpers either return {@code false} (for detection) or throw
+ *
closeables;
+ private boolean useVirtualThreads;
+ private String virtualThreadNamePrefix = "hc-vt-";
+ private ExecutorService virtualThreadExecutor;
+ private boolean shutdownVirtualThreadExecutor = true;
+ private TimeValue virtualThreadShutdownWait = TimeValue.ofSeconds(2);
+
+ private boolean virtualThreadRunHandler;
+
public static HttpClientBuilder create() {
return new HttpClientBuilder();
}
@@ -808,6 +818,130 @@ public final HttpClientBuilder setProxySelector(final ProxySelector proxySelecto
return this;
}
+ /**
+ * Enables or disables execution of the transport layer on virtual threads (JDK 21+).
+ *
+ * When enabled and no custom executor is supplied via
+ * {@link #virtualThreadExecutor(java.util.concurrent.ExecutorService) virtualThreadExecutor(..)},
+ * the builder will create a per-task virtual-thread executor at build time.
+ *
+ *
+ * If virtual threads are not available at runtime and no custom executor is provided,
+ * {@link #build()} may throw {@link UnsupportedOperationException} depending on configuration.
+ *
+ *
+ * @return this instance.
+ * @since 5.6
+ */
+ public HttpClientBuilder useVirtualThreads() {
+ this.useVirtualThreads = true;
+ return this;
+ }
+
+ /**
+ * Sets the thread name prefix for virtual threads created by this builder.
+ *
+ * This prefix is only applied when the builder creates the virtual-thread executor itself.
+ * If a custom executor is supplied via {@link #virtualThreadExecutor(java.util.concurrent.ExecutorService)},
+ * the prefix is ignored.
+ *
+ *
+ * @param prefix the desired name prefix; if {@code null}, {@code "hc-vt-"} is used.
+ * @return this instance.
+ * @since 5.6
+ */
+ public HttpClientBuilder virtualThreadNamePrefix(final String prefix) {
+ this.virtualThreadNamePrefix = prefix != null ? prefix : "hc-vt-";
+ return this;
+ }
+
+ /**
+ * Supplies a custom executor to run transport work (typically a per-task virtual-thread executor).
+ *
+ * Passing a custom executor automatically enables virtual-thread execution. Ownership semantics are
+ * controlled by {@code shutdownOnClose}:
+ *
+ *
+ * - {@code true}: the client will shut down the executor during {@code close()}.
+ * - {@code false}: the executor is treated as shared and will not be shut down by the client.
+ *
+ *
+ * This method does not validate that the supplied executor actually creates virtual threads; callers are
+ * responsible for providing an appropriate executor.
+ *
+ *
+ * @param exec the executor to use for transport work.
+ * @param shutdownOnClose whether the client should shut down the executor on close.
+ * @return this instance.
+ * @since 5.6
+ */
+ public HttpClientBuilder virtualThreadExecutor(final ExecutorService exec, final boolean shutdownOnClose) {
+ this.virtualThreadExecutor = exec;
+ this.shutdownVirtualThreadExecutor = shutdownOnClose;
+ this.useVirtualThreads = true; // ensure VT path is active
+ return this;
+ }
+
+ /**
+ * Supplies a custom executor to run transport work (typically a per-task virtual-thread executor).
+ *
+ * Passing a custom executor automatically enables virtual-thread execution and treats the executor as
+ * shared (it will not be shut down by the client). To change ownership semantics, use
+ * {@link #virtualThreadExecutor(java.util.concurrent.ExecutorService, boolean)}.
+ *
+ *
+ * This method does not validate that the supplied executor actually creates virtual threads; callers are
+ * responsible for providing an appropriate executor.
+ *
+ *
+ * @param exec the executor to use for transport work.
+ * @return this instance.
+ * @since 5.6
+ */
+ public final HttpClientBuilder virtualThreadExecutor(final ExecutorService exec) {
+ return virtualThreadExecutor(exec, false);
+ }
+
+ /**
+ * Configures the maximum time to wait for the virtual-thread executor to terminate during
+ * a graceful {@link CloseableHttpClient#close() close()} (i.e., {@code CloseMode.GRACEFUL}).
+ *
+ * This value is only used when a virtual-thread executor is in use and the client owns it
+ * (i.e., {@code shutdownVirtualThreadExecutor == true}). For immediate close, the executor
+ * is shut down without waiting.
+ *
+ *
+ * @param waitTime the time to await executor termination; may be {@code null} to use the default.
+ * @return this instance.
+ * @since 5.6
+ */
+ public final HttpClientBuilder virtualThreadShutdownWait(final TimeValue waitTime) {
+ this.virtualThreadShutdownWait = waitTime;
+ return this;
+ }
+
+
+ /**
+ * Configures the client to run the user-supplied {@link org.apache.hc.core5.http.io.HttpClientResponseHandler}
+ *
+ * on a virtual thread as well as the transport layer. By default, the response handler runs on the caller thread.
+ *
+ *
+ * This has an effect only when virtual threads are enabled via {@link #useVirtualThreads()} or
+ *
+ * {@link #virtualThreadExecutor(java.util.concurrent.ExecutorService)}.
+ *
+ *
+ * @return this builder
+ * @since 5.6
+ */
+ public HttpClientBuilder virtualThreadsRunHandler() {
+ this.virtualThreadRunHandler = true;
+ return this;
+ }
+
+
+
/**
* Request exec chain customization and extension.
*
@@ -1126,7 +1260,7 @@ public CloseableHttpClient build() {
closeablesCopy.add(connManagerCopy);
}
- return new InternalHttpClient(
+ final CloseableHttpClient base = new InternalHttpClient(
connManagerCopy,
requestExecCopy,
execChain,
@@ -1138,6 +1272,20 @@ public CloseableHttpClient build() {
contextAdaptor(),
defaultRequestConfig != null ? defaultRequestConfig : RequestConfig.DEFAULT,
closeablesCopy);
+
+ // VT on? wrap, otherwise return base
+ if (useVirtualThreads) {
+ final ExecutorService vtExecToUse = virtualThreadExecutor != null
+ ? virtualThreadExecutor
+ : (VirtualThreadSupport.isAvailable()
+ ? VirtualThreadSupport.newVirtualThreadPerTaskExecutor(virtualThreadNamePrefix)
+ : null);
+ if (vtExecToUse != null) {
+ return new VirtualThreadCloseableHttpClient(base, vtExecToUse, shutdownVirtualThreadExecutor, virtualThreadShutdownWait, virtualThreadRunHandler);
+ }
+ }
+ return base;
+
}
-}
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java
index 55ccb58309..c449d544fa 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClients.java
@@ -29,6 +29,7 @@
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.core5.util.TimeValue;
/**
* Factory methods for {@link CloseableHttpClient} instances.
@@ -81,4 +82,75 @@ public static MinimalHttpClient createMinimal(final HttpClientConnectionManager
return new MinimalHttpClient(connManager);
}
+ /**
+ * Creates a client with default configuration executing transport on virtual threads (JDK 21+).
+ *
Response handlers run on the caller thread. If virtual threads are unavailable at runtime,
+ * this method falls back to classic execution (same as {@link #createDefault()}).
+ * @since 5.6
+ */
+ public static CloseableHttpClient createVirtualThreadDefault() {
+ return HttpClientBuilder.create()
+ .useVirtualThreads()
+ .build();
+ }
+
+ /**
+ * Same as {@link #createVirtualThreadDefault()} but honors system properties.
+ * If virtual threads are unavailable at runtime, falls back to classic execution.
+ * @since 5.6
+ */
+ public static CloseableHttpClient createVirtualThreadSystem() {
+ return HttpClientBuilder.create()
+ .useSystemProperties()
+ .useVirtualThreads()
+ .build();
+ }
+
+ /**
+ * Returns a builder preconfigured to execute transport on virtual threads (JDK 21+).
+ * If virtual threads are unavailable at runtime, the built client falls back to classic execution.
+ * @since 5.6
+ */
+ public static HttpClientBuilder customVirtualThreads() {
+ return HttpClientBuilder.create()
+ .useVirtualThreads();
+ }
+
+ /**
+ * Returns a builder preconfigured to execute transport on virtual threads with a custom thread name prefix.
+ * If virtual threads are unavailable at runtime, the built client falls back to classic execution and the
+ * prefix is ignored.
+ * @since 5.6
+ */
+ public static HttpClientBuilder customVirtualThreads(final String namePrefix) {
+ return HttpClientBuilder.create()
+ .useVirtualThreads()
+ .virtualThreadNamePrefix(namePrefix);
+ }
+
+ /**
+ * Creates a virtual-thread client with a custom thread name prefix.
+ * If virtual threads are unavailable at runtime, falls back to classic execution and the prefix is ignored.
+ * @since 5.6
+ */
+ public static CloseableHttpClient createVirtualThreadDefault(final String namePrefix) {
+ return HttpClientBuilder.create()
+ .useVirtualThreads()
+ .virtualThreadNamePrefix(namePrefix)
+ .build();
+ }
+
+ /**
+ * Creates a virtual-thread client with a custom graceful-shutdown wait.
+ * @since 5.6
+ */
+ public static CloseableHttpClient createVirtualThreadDefault(final TimeValue shutdownWait) {
+ return HttpClientBuilder.create()
+ .useVirtualThreads()
+ .virtualThreadShutdownWait(shutdownWait)
+ .build();
+ }
+
+
+
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/VirtualThreadCloseableHttpClient.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/VirtualThreadCloseableHttpClient.java
new file mode 100644
index 0000000000..c5ed36f908
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/VirtualThreadCloseableHttpClient.java
@@ -0,0 +1,313 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.classic;
+
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+
+import org.apache.hc.client5.http.ClientProtocolException;
+import org.apache.hc.core5.annotation.Contract;
+import org.apache.hc.core5.annotation.Experimental;
+import org.apache.hc.core5.annotation.ThreadingBehavior;
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.HttpException;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.HttpClientResponseHandler;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.apache.hc.core5.util.Args;
+import org.apache.hc.core5.util.TimeValue;
+
+/**
+ * {@code CloseableHttpClient} decorator that runs the classic (blocking) transport
+ * on JDK 21 virtual threads while preserving the classic client contract.
+ *
+ * Execution model
+ *
+ * - By default, only the transport runs on a virtual thread; the response handler
+ * (for {@code execute(..., HttpClientResponseHandler)}) runs on the caller thread,
+ * exactly like the base implementation.
+ * - If {@link #handlerOnVirtualThread} is set to {@code true}, both the transport and
+ * the response handler run inside a virtual thread, and the entity is consumed there
+ * before returning.
+ *
+ *
+ * Error semantics
+ *
+ * - Transport failures propagate as {@link IOException}.
+ * - {@link RuntimeException} and {@link Error} propagate unchanged.
+ * - Executor rejection is mapped to {@code IOException("client closed")}.
+ * - Thread interruption cancels the VT task, restores the interrupt flag, and throws
+ * {@link InterruptedIOException}.
+ *
+ *
+ * Lifecycle
+ *
+ * - If this client owns the provided executor, it is shut down on {@link #close()} /
+ * {@link #close(CloseMode)} with a bounded wait.
+ *
+ *
+ * @since 5.6
+ */
+@Contract(threading = ThreadingBehavior.SAFE)
+@Experimental
+public final class VirtualThreadCloseableHttpClient extends CloseableHttpClient {
+
+ /**
+ * Underlying classic client used for actual I/O.
+ */
+ private final CloseableHttpClient delegate;
+
+ /**
+ * Executor that creates/runs virtual threads (typically per-task VT executor).
+ */
+ private final ExecutorService vtExecutor;
+
+ /**
+ * Whether this instance manages (shuts down) {@link #vtExecutor} on close.
+ */
+ private final boolean shutdownVtExec;
+
+ /**
+ * Maximum time to wait for executor termination on graceful close.
+ */
+ private final TimeValue vtShutdownWait;
+
+ /**
+ * If {@code true}, both transport and {@link HttpClientResponseHandler} execute on a VT.
+ * If {@code false}, only the transport runs on a VT and the handler runs on the caller thread.
+ */
+ private final boolean handlerOnVirtualThread;
+
+ /**
+ * Constructs a VT client that owns the executor, waits up to 2 seconds for graceful shutdown,
+ * and keeps the response handler on the caller thread.
+ */
+ public VirtualThreadCloseableHttpClient(
+ final CloseableHttpClient delegate,
+ final ExecutorService vtExecutor) {
+ this(delegate, vtExecutor, true, null, false);
+ }
+
+ /**
+ * Convenience constructor matching the builder usage: handler remains on caller thread.
+ */
+ public VirtualThreadCloseableHttpClient(
+ final CloseableHttpClient delegate,
+ final ExecutorService vtExecutor,
+ final boolean shutdownVtExec,
+ final TimeValue vtShutdownWait) {
+ this(delegate, vtExecutor, shutdownVtExec, vtShutdownWait, false);
+ }
+
+ /**
+ * Full constructor with explicit ownership, shutdown wait and handler execution mode.
+ *
+ * @param delegate underlying client used to perform I/O
+ * @param vtExecutor executor that creates and runs virtual threads
+ * @param shutdownVtExec whether this client should shut down {@code vtExecutor} on close
+ * @param vtShutdownWait maximum time to await executor termination on graceful close;
+ * if {@code null}, defaults to 2 seconds
+ * @param handlerOnVirtualThread whether to run the response handler on a VT as well
+ */
+ public VirtualThreadCloseableHttpClient(
+ final CloseableHttpClient delegate,
+ final ExecutorService vtExecutor,
+ final boolean shutdownVtExec,
+ final TimeValue vtShutdownWait,
+ final boolean handlerOnVirtualThread) {
+ this.delegate = Args.notNull(delegate, "delegate");
+ this.vtExecutor = Args.notNull(vtExecutor, "vtExecutor");
+ this.shutdownVtExec = shutdownVtExec;
+ this.vtShutdownWait = vtShutdownWait != null ? vtShutdownWait : TimeValue.ofSeconds(2);
+ this.handlerOnVirtualThread = handlerOnVirtualThread;
+ }
+
+ /**
+ * Executes the request on a virtual thread by delegating to {@link #delegate}'s
+ * {@link CloseableHttpClient#executeOpen(HttpHost, ClassicHttpRequest, HttpContext)}.
+ * The method blocks until the VT task completes, preserving classic blocking semantics.
+ */
+ @Override
+ protected CloseableHttpResponse doExecute(
+ final HttpHost target,
+ final ClassicHttpRequest request,
+ final HttpContext context) throws IOException {
+
+ final Future f;
+ try {
+ f = vtExecutor.submit(() -> delegate.executeOpen(target, request, context));
+ } catch (final RejectedExecutionException rex) {
+ throw new IOException("client closed", rex);
+ }
+
+ try {
+ final ClassicHttpResponse rsp = f.get();
+ return CloseableHttpResponse.adapt(rsp);
+ } catch (final InterruptedException ie) {
+ f.cancel(true);
+ Thread.currentThread().interrupt();
+ final InterruptedIOException iox = new InterruptedIOException("interrupted");
+ iox.initCause(ie);
+ throw iox;
+ } catch (final ExecutionException ee) {
+ final Throwable cause = ee.getCause();
+ if (cause instanceof RejectedExecutionException) {
+ throw new IOException("client closed", cause);
+ }
+ if (cause instanceof IOException) {
+ throw (IOException) cause;
+ }
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ if (cause instanceof Error) {
+ throw (Error) cause;
+ }
+ throw new IOException(cause);
+ }
+ }
+
+ /**
+ * Optionally runs both transport and the response handler on a virtual thread.
+ * If {@link #handlerOnVirtualThread} is {@code false}, defers to the base implementation
+ * which keeps the handler on the caller thread (while transport still runs on a VT via {@link #doExecute}).
+ */
+ @Override
+ public T execute(
+ final HttpHost target,
+ final ClassicHttpRequest request,
+ final HttpContext context,
+ final HttpClientResponseHandler extends T> responseHandler) throws IOException {
+
+ Objects.requireNonNull(responseHandler, "Response handler");
+
+ if (!handlerOnVirtualThread) {
+ // Transport still runs on VT via doExecute(); handler runs on caller thread.
+ return super.execute(target, request, context, responseHandler);
+ }
+
+ final Future f;
+ try {
+ f = vtExecutor.submit(() -> {
+ final ClassicHttpResponse rsp = delegate.executeOpen(target, request, context);
+ try (final CloseableHttpResponse ch = CloseableHttpResponse.adapt(rsp)) {
+ try {
+ final T out = responseHandler.handleResponse(ch);
+ EntityUtils.consume(ch.getEntity()); // salvage connection
+ return out;
+ } catch (final HttpException t) {
+ try {
+ EntityUtils.consume(ch.getEntity());
+ } catch (final Exception ignore) {
+ }
+ throw new ClientProtocolException(t);
+ } catch (final IOException | RuntimeException t) {
+ try {
+ EntityUtils.consume(ch.getEntity());
+ } catch (final Exception ignore) {
+ }
+ throw t;
+ }
+ }
+ });
+ } catch (final RejectedExecutionException rex) {
+ throw new IOException("client closed", rex);
+ }
+
+ try {
+ return f.get();
+ } catch (final InterruptedException ie) {
+ f.cancel(true);
+ Thread.currentThread().interrupt();
+ final InterruptedIOException iox = new InterruptedIOException("interrupted");
+ iox.initCause(ie);
+ throw iox;
+ } catch (final ExecutionException ee) {
+ final Throwable c = ee.getCause();
+ if (c instanceof RejectedExecutionException) {
+ throw new IOException("client closed", c);
+ }
+ if (c instanceof IOException) {
+ throw (IOException) c;
+ }
+ if (c instanceof RuntimeException) {
+ throw (RuntimeException) c;
+ }
+ if (c instanceof Error) {
+ throw (Error) c;
+ }
+ throw new IOException(c);
+ }
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ if (shutdownVtExec && vtExecutor != null) {
+ if (closeMode == CloseMode.IMMEDIATE) {
+ vtExecutor.shutdownNow();
+ } else {
+ vtExecutor.shutdown();
+ try {
+ vtExecutor.awaitTermination(
+ vtShutdownWait.getDuration(),
+ vtShutdownWait.getTimeUnit());
+ } catch (final InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ delegate.close(closeMode);
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ if (shutdownVtExec && vtExecutor != null) {
+ vtExecutor.shutdown();
+ try {
+ vtExecutor.awaitTermination(
+ vtShutdownWait.getDuration(),
+ vtShutdownWait.getTimeUnit());
+ } catch (final InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ } finally {
+ vtExecutor.shutdownNow(); // harmless if already terminated
+ }
+ }
+ } finally {
+ delegate.close();
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientClassicWithVirtualThreads.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientClassicWithVirtualThreads.java
new file mode 100644
index 0000000000..a613f9b15b
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClientClassicWithVirtualThreads.java
@@ -0,0 +1,86 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.client5.http.classic.methods.HttpGet;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.HttpEntity;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+
+/**
+ * Demonstrates enabling Virtual Threads for the classic {@link CloseableHttpClient}.
+ *
+ * Requirements: run on JDK 21 or newer. The feature is disabled by default;
+ * you enable it via the builder. When enabled, the client performs the transport layer
+ * (connection lease, send, receive, entity streaming) on Virtual Threads while preserving the
+ * classic blocking API and error semantics.
+ *
+ *
+ * Notes:
+ *
+ * - You can choose a thread-name prefix (useful for diagnostics).
+ * - For production, prefer reusing a single {@code CloseableHttpClient} and a connection pool.
+ *
+ *
+ * @since 5.6
+ */
+public class ClientClassicWithVirtualThreads {
+
+ public static void main(final String[] args) throws Exception {
+ try (final CloseableHttpClient httpclient = HttpClients.custom()
+ .useVirtualThreads()
+ .virtualThreadNamePrefix("hc-vt-")
+ .virtualThreadsRunHandler()
+ .build()) {
+
+ final HttpGet httpget = new HttpGet("http://httpbin.org/get");
+
+ httpclient.execute(httpget, response -> {
+
+ final HttpEntity entity = response.getEntity();
+ if (entity != null) {
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(entity.getContent(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ // keep output minimal; comment out next line if you don't want body echoed
+ System.out.println(line);
+ }
+ } finally {
+ EntityUtils.consume(entity);
+ }
+ }
+ return null;
+ });
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestVirtualThreadCloseableHttpClient.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestVirtualThreadCloseableHttpClient.java
new file mode 100644
index 0000000000..870b27f9fa
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestVirtualThreadCloseableHttpClient.java
@@ -0,0 +1,264 @@
+/*
+ * ====================================================================
+ * 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.classic;
+
+import static org.junit.jupiter.api.condition.JRE.JAVA_21;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.hc.core5.http.ClassicHttpRequest;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
+import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
+import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
+import org.apache.hc.core5.http.protocol.HttpContext;
+import org.apache.hc.core5.io.CloseMode;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+
+@EnabledForJreRange(min = JAVA_21)
+class TestVirtualThreadCloseableHttpClient {
+
+ static final class StubClient extends CloseableHttpClient {
+ ClassicHttpResponse next;
+ IOException nextIo;
+ RuntimeException nextRt;
+ Error nextErr;
+
+ @Override
+ protected CloseableHttpResponse doExecute(final HttpHost target,
+ final ClassicHttpRequest request,
+ final HttpContext context) throws IOException {
+ if (nextIo != null) {
+ throw nextIo;
+ }
+ if (nextRt != null) {
+ throw nextRt;
+ }
+ if (nextErr != null) {
+ throw nextErr;
+ }
+ final ClassicHttpResponse r = next != null ? next : new BasicClassicHttpResponse(200);
+ if (r.getEntity() == null) {
+ r.setEntity(new StringEntity("ok", ContentType.TEXT_PLAIN));
+ }
+ return CloseableHttpResponse.adapt(r);
+ }
+
+ @Override
+ public void close(final CloseMode closeMode) {
+ // no-op for tests
+ }
+
+ @Override
+ public void close() {
+ // no-op
+ }
+ }
+
+ static final class TrackExec extends AbstractExecutorService {
+ volatile boolean shutdown;
+ volatile boolean shutdownNow;
+ volatile long awaitCalls;
+ volatile Thread lastExecThread;
+
+ @Override
+ public void shutdown() {
+ shutdown = true;
+ }
+
+ @Override
+ public List shutdownNow() {
+ shutdownNow = true;
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return shutdown || shutdownNow;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return isShutdown();
+ }
+
+ @Override
+ public boolean awaitTermination(final long timeout, final TimeUnit unit) throws InterruptedException {
+ awaitCalls++;
+ return true;
+ }
+
+ @Override
+ public void execute(final Runnable command) {
+ lastExecThread = Thread.currentThread();
+ command.run();
+ }
+ }
+
+ static final class RejectingExec extends AbstractExecutorService {
+ @Override
+ public void shutdown() {
+ }
+
+ @Override
+ public List shutdownNow() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return false;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return false;
+ }
+
+ @Override
+ public boolean awaitTermination(final long timeout, final TimeUnit unit) {
+ return true;
+ }
+
+ @Override
+ public void execute(final Runnable command) {
+ throw new RejectedExecutionException("rejected");
+ }
+ }
+
+ @Test
+ void basic_execute_ok() throws Exception {
+ final StubClient base = new StubClient();
+ final TrackExec exec = new TrackExec();
+ try (final CloseableHttpClient client = new VirtualThreadCloseableHttpClient(base, exec)) {
+ final ClassicHttpRequest req = new BasicClassicHttpRequest("GET", "/");
+ final Integer code = client.execute(req, null, response -> response.getCode());
+ Assertions.assertEquals(200, code.intValue());
+ }
+ }
+
+ @Test
+ void io_exception_unwrapped() throws Exception {
+ final StubClient base = new StubClient();
+ base.nextIo = new IOException("boom");
+ final ExecutorService exec = new TrackExec();
+ try (final CloseableHttpClient client = new VirtualThreadCloseableHttpClient(base, exec)) {
+ final ClassicHttpRequest req = new BasicClassicHttpRequest("GET", "/");
+ Assertions.assertThrows(IOException.class, () ->
+ client.execute(req, null, r -> null));
+ }
+ }
+
+ @Test
+ void runtime_exception_propagates() throws Exception {
+ final StubClient base = new StubClient();
+ base.nextRt = new IllegalStateException("bad");
+ final ExecutorService exec = new TrackExec();
+ try (final CloseableHttpClient client = new VirtualThreadCloseableHttpClient(base, exec)) {
+ final ClassicHttpRequest req = new BasicClassicHttpRequest("GET", "/");
+ Assertions.assertThrows(IllegalStateException.class, () ->
+ client.execute(req, null, r -> null));
+ }
+ }
+
+ @Test
+ void error_propagates() throws Exception {
+ final StubClient base = new StubClient();
+ base.nextErr = new AssertionError("err");
+ final ExecutorService exec = new TrackExec();
+ try (final CloseableHttpClient client = new VirtualThreadCloseableHttpClient(base, exec)) {
+ final ClassicHttpRequest req = new BasicClassicHttpRequest("GET", "/");
+ Assertions.assertThrows(AssertionError.class, () ->
+ client.execute(req, null, r -> null));
+ }
+ }
+
+ @Test
+ void rejected_execution_bubbles_as_runtime() throws Exception {
+ final CloseableHttpClient base = new StubClient();
+ final ExecutorService rejecting = new RejectingExec();
+
+ try (final CloseableHttpClient client =
+ new VirtualThreadCloseableHttpClient(base, rejecting)) {
+
+ final ClassicHttpRequest req = ClassicRequestBuilder.get("http://localhost/").build();
+
+ final IOException ex = Assertions.assertThrows(IOException.class, () ->
+ client.execute(req, response -> null));
+
+ Assertions.assertTrue(ex.getCause() instanceof RejectedExecutionException);
+ Assertions.assertEquals("client closed", ex.getMessage());
+ }
+ }
+
+ @Test
+ void close_immediate_shuts_down_now() throws Exception {
+ final StubClient base = new StubClient();
+ final TrackExec exec = new TrackExec();
+ try (final VirtualThreadCloseableHttpClient client = new VirtualThreadCloseableHttpClient(base, exec)) {
+ client.close(CloseMode.IMMEDIATE);
+ Assertions.assertTrue(exec.shutdownNow);
+ }
+ }
+
+ @Test
+ void close_graceful_shuts_down_and_awaits() throws Exception {
+ final StubClient base = new StubClient();
+ final TrackExec exec = new TrackExec();
+ try (final CloseableHttpClient client = new VirtualThreadCloseableHttpClient(base, exec)) {
+ // rely on try-with-resources to call close() (graceful)
+ }
+ Assertions.assertTrue(exec.shutdown);
+ Assertions.assertEquals(1, exec.awaitCalls);
+ }
+
+ @Test
+ void createVirtualThreadDefault_builds_and_closes() throws Exception {
+ try (final CloseableHttpClient client = HttpClients.createVirtualThreadDefault()) {
+ Assertions.assertNotNull(client);
+ }
+ }
+
+ @Test
+ void createVirtualThreadSystem_builds_and_closes() throws Exception {
+ try (final CloseableHttpClient client = HttpClients.createVirtualThreadSystem()) {
+ Assertions.assertNotNull(client);
+ }
+ }
+
+}