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

+ * {@link UnsupportedOperationException} (for construction). + *

+ * + **/ +@Internal +public final class VirtualThreadSupport { + + private VirtualThreadSupport() { + } + + public static boolean isAvailable() { + try { + Class.forName("java.lang.Thread$Builder$OfVirtual", false, + VirtualThreadSupport.class.getClassLoader()); + Class.forName("java.lang.Thread").getMethod("ofVirtual"); + return true; + } catch (final Throwable t) { + return false; + } + } + + /** + * Prefer JDK’s per-task executors when present; otherwise fail. + */ + public static ExecutorService newVirtualThreadPerTaskExecutor(final String namePrefix) { + if (!isAvailable()) { + throw new UnsupportedOperationException("Virtual threads are not available on this runtime"); + } + try { + final Class executors = Class.forName("java.util.concurrent.Executors"); + try { + final Method m = executors.getMethod("newThreadPerTaskExecutor", ThreadFactory.class); + final ThreadFactory vtFactory = newVirtualThreadFactory(namePrefix); + return (ExecutorService) m.invoke(null, vtFactory); + } catch (final NoSuchMethodException ignore) { + final Method m = executors.getMethod("newVirtualThreadPerTaskExecutor"); + return (ExecutorService) m.invoke(null); + } + } catch (final Throwable t) { + throw new UnsupportedOperationException("Failed to initialize virtual thread per-task executor", t); + } + } + + public static ThreadFactory newVirtualThreadFactory(final String ignored) { + if (!isAvailable()) { + throw new UnsupportedOperationException("Virtual threads are not available on this runtime"); + } + try { + final Class threadClass = Class.forName("java.lang.Thread"); + final Object builder = threadClass.getMethod("ofVirtual").invoke(null); + final Class ofVirtualClass = Class.forName("java.lang.Thread$Builder$OfVirtual"); + return (ThreadFactory) ofVirtualClass.getMethod("factory").invoke(builder); + } catch (final Throwable t) { + throw new UnsupportedOperationException("Failed to initialize virtual thread factory", t); + } + } +} diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java index e065881535..1694a1ca0c 100644 --- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java +++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/HttpClientBuilder.java @@ -38,6 +38,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.concurrent.ExecutorService; import java.util.function.Function; import java.util.function.UnaryOperator; @@ -69,6 +70,7 @@ import org.apache.hc.client5.http.impl.DefaultUserTokenHandler; import org.apache.hc.client5.http.impl.IdleConnectionEvictor; import org.apache.hc.client5.http.impl.NoopUserTokenHandler; +import org.apache.hc.client5.http.impl.VirtualThreadSupport; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.auth.BasicSchemeFactory; import org.apache.hc.client5.http.impl.auth.BearerSchemeFactory; @@ -240,6 +242,14 @@ private ExecInterceptorEntry( private List 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}: + *

+ * + *

+ * 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 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); + } + } + +}