diff --git a/cloudplatform/cloudplatform-connectivity/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IsOnBehalfOf.java b/cloudplatform/cloudplatform-connectivity/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IsOnBehalfOf.java
new file mode 100644
index 000000000..0feff2310
--- /dev/null
+++ b/cloudplatform/cloudplatform-connectivity/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IsOnBehalfOf.java
@@ -0,0 +1,22 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import javax.annotation.Nullable;
+
+/**
+ * Interface to be implemented by classes that can provide information about the behalf upon which an action is run.
+ *
+ * @since 4.27.0
+ */
+interface IsOnBehalfOf
+{
+ /**
+ * Returns the behalf upon which an action is run.
+ *
+ * @return The behalf upon which an action is run, or {@code null} if no information about the behalf is available.
+ */
+ @Nullable
+ default OnBehalfOf getOnBehalfOf()
+ {
+ return null;
+ }
+}
diff --git a/cloudplatform/connectivity-apache-httpclient4/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/AbstractHttpClientCache.java b/cloudplatform/connectivity-apache-httpclient4/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/AbstractHttpClientCache.java
index 29c336cf0..ccecf7129 100644
--- a/cloudplatform/connectivity-apache-httpclient4/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/AbstractHttpClientCache.java
+++ b/cloudplatform/connectivity-apache-httpclient4/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/AbstractHttpClientCache.java
@@ -79,11 +79,8 @@ private Try tryGetOrCreateHttpClient(
final Try maybeKey = destination != null ? getCacheKey(destination) : getCacheKey();
if( maybeKey.isFailure() ) {
- return Try
- .failure(
- new HttpClientInstantiationException(
- "Failed to create cache key for HttpClient",
- maybeKey.getCause()));
+ final String msg = "Failed to create cache key for HttpClient";
+ return Try.failure(new HttpClientInstantiationException(msg, maybeKey.getCause()));
}
final Cache cache = maybeCache.get();
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5FactoryBuilder.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5FactoryBuilder.java
index 527249bb7..f8ee7277e 100644
--- a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5FactoryBuilder.java
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ApacheHttpClient5FactoryBuilder.java
@@ -8,18 +8,85 @@
import com.google.common.annotations.Beta;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
/**
* Builder class for a default implementation of the {@link ApacheHttpClient5Factory} interface.
*
* @since 4.20.0
*/
+@Accessors( fluent = true )
public class ApacheHttpClient5FactoryBuilder
{
+ /**
+ * The {@code Upgrade} header. Only {@link ProxyType#INTERNET} has the {@code Upgrade} header by default.
+ *
+ * {@link TlsUpgrade#DISABLED} only works for {@link ProxyType#INTERNET}
+ *
+ * {@link TlsUpgrade#ENABLED} only works for {@link ProxyType#ON_PREMISE}
+ *
+ * @since 5.14.0
+ */
+ @Setter
@Nonnull
- private Duration timeout = DefaultApacheHttpClient5Factory.DEFAULT_TIMEOUT;
private TlsUpgrade tlsUpgrade = TlsUpgrade.AUTOMATIC;
- private int maxConnectionsTotal = DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_TOTAL;
- private int maxConnectionsPerRoute = DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
+
+ /**
+ * The {@link ConnectionPoolSettings} to use for configuring connection pool managers and request timeouts.
+ *
+ * This replaces any previously configured settings from {@link #timeout(Duration)},
+ * {@link #maxConnectionsTotal(int)}, or {@link #maxConnectionsPerRoute(int)}.
+ *
+ *
+ * This is an optional parameter. By default, settings use the default values.
+ *
+ * This allows customization of how connection managers are created and cached. Use
+ * {@link ConnectionPoolManagerProviders} to obtain pre-built implementations with common caching strategies:
+ *
+ *
+ *
{@link ConnectionPoolManagerProviders#noCache()} - No caching (default behavior)
+ *
{@link ConnectionPoolManagerProviders#byTenant()} - Cache by current tenant
+ *
{@link ConnectionPoolManagerProviders#byDestinationName()} - Cache by destination name
+ *
{@link ConnectionPoolManagerProviders#global()} - Single global connection manager
+ *
+ * @see ConnectionPoolManagerProvider
+ * @see ConnectionPoolManagerProviders
+ * @since 5.27.0
+ */
+ @Setter( onMethod_ = @Beta )
+ @Nonnull
+ private ConnectionPoolManagerProvider connectionPoolManagerProvider = ConnectionPoolManagerProviders.noCache();
/**
* Enum to control the automatic TLS upgrade feature for insecure connections.
@@ -88,7 +155,8 @@ public ApacheHttpClient5FactoryBuilder timeoutInMilliseconds( final int timeoutI
@Nonnull
public ApacheHttpClient5FactoryBuilder timeout( @Nonnull final Duration timeout )
{
- this.timeout = timeout;
+ settings =
+ settings.withConnectTimeout(timeout).withSocketTimeout(timeout).withConnectionRequestTimeout(timeout);
return this;
}
@@ -106,23 +174,7 @@ public ApacheHttpClient5FactoryBuilder timeout( @Nonnull final Duration timeout
@Nonnull
public ApacheHttpClient5FactoryBuilder maxConnectionsTotal( final int maxConnectionsTotal )
{
- this.maxConnectionsTotal = maxConnectionsTotal;
- return this;
- }
-
- /**
- * Sets the {@code Upgrade} header. Only {@link ProxyType#INTERNET} has the {@code Upgrade} header by default.
- *
- * {@link TlsUpgrade#DISABLED} only works for {@link ProxyType#INTERNET}
- *
- * {@link TlsUpgrade#ENABLED} only works for {@link ProxyType#ON_PREMISE}
- *
- * @since 5.14.0
- */
- @Nonnull
- public ApacheHttpClient5FactoryBuilder tlsUpgrade( @Nonnull final TlsUpgrade tlsUpgrade )
- {
- this.tlsUpgrade = tlsUpgrade;
+ settings = settings.withMaxConnectionsTotal(maxConnectionsTotal);
return this;
}
@@ -141,7 +193,7 @@ public ApacheHttpClient5FactoryBuilder tlsUpgrade( @Nonnull final TlsUpgrade tls
@Nonnull
public ApacheHttpClient5FactoryBuilder maxConnectionsPerRoute( final int maxConnectionsPerRoute )
{
- this.maxConnectionsPerRoute = maxConnectionsPerRoute;
+ settings = settings.withMaxConnectionsPerRoute(maxConnectionsPerRoute);
return this;
}
@@ -153,11 +205,6 @@ public ApacheHttpClient5FactoryBuilder maxConnectionsPerRoute( final int maxConn
@Nonnull
public ApacheHttpClient5Factory build()
{
- return new DefaultApacheHttpClient5Factory(
- timeout,
- maxConnectionsTotal,
- maxConnectionsPerRoute,
- null,
- tlsUpgrade);
+ return new DefaultApacheHttpClient5Factory(settings, connectionPoolManagerProvider, null, tlsUpgrade);
}
}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvider.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvider.java
new file mode 100644
index 000000000..451b9c1ee
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvider.java
@@ -0,0 +1,66 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+
+import com.google.common.annotations.Beta;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException;
+
+/**
+ * Functional interface for creating or retrieving {@link HttpClientConnectionManager} instances.
+ *
+ * Implementations can choose to cache connection managers based on various strategies (e.g., by tenant, by destination
+ * name, globally) to reduce memory consumption. Each connection manager typically consumes around 100KB of memory.
+ *
+ *
+ * Use {@link ConnectionPoolManagerProviders} to obtain pre-built implementations with common caching strategies.
+ *
+ *
+ * @see ConnectionPoolManagerProviders
+ * @see ApacheHttpClient5FactoryBuilder#connectionPoolManagerProvider(ConnectionPoolManagerProvider)
+ * @since 5.27.0
+ */
+@Beta
+@FunctionalInterface
+public interface ConnectionPoolManagerProvider
+{
+ /**
+ * Gets or creates an {@link HttpClientConnectionManager} for the given destination.
+ *
+ * Implementations may cache connection managers based on destination properties, tenant context, or other criteria.
+ * The settings parameter provides the configuration for creating new connection managers.
+ *
+ *
+ * @param settings
+ * The connection pool settings to use when creating a new connection manager.
+ * @param destination
+ * The destination properties to create the connection manager for, or {@code null} for a generic
+ * connection manager.
+ * @return A connection manager suitable for the given destination.
+ * @throws HttpClientInstantiationException
+ * If the connection manager cannot be created.
+ */
+ @Nonnull
+ HttpClientConnectionManager getConnectionManager(
+ @Nonnull ConnectionPoolSettings settings,
+ @Nullable HttpDestinationProperties destination )
+ throws HttpClientInstantiationException;
+}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProviders.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProviders.java
new file mode 100644
index 000000000..7961f378f
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProviders.java
@@ -0,0 +1,382 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.time.Duration;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+
+import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
+import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
+import org.apache.hc.client5.http.ssl.TlsSocketStrategy;
+import org.apache.hc.core5.http.io.SocketConfig;
+import org.apache.hc.core5.util.Timeout;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.google.common.annotations.Beta;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException;
+import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
+import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
+import com.sap.cloud.sdk.cloudplatform.util.StringUtils;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Factory class providing pre-built {@link ConnectionPoolManagerProvider} implementations with various caching
+ * strategies.
+ *
+ * Connection pool managers can consume significant memory (~100KB each). By caching and reusing connection managers
+ * based on appropriate keys, applications can reduce memory consumption while maintaining proper isolation where
+ * needed.
+ *
+ *
+ * @see ConnectionPoolManagerProvider
+ * @see ConnectionPoolSettings
+ * @see ApacheHttpClient5FactoryBuilder#connectionPoolManagerProvider(ConnectionPoolManagerProvider)
+ * @since 5.27.0
+ */
+@Beta
+@Slf4j
+@NoArgsConstructor( access = AccessLevel.PRIVATE )
+public final class ConnectionPoolManagerProviders
+{
+ private static final Duration DEFAULT_CACHE_DURATION = DefaultApacheHttpClient5Cache.DEFAULT_DURATION;
+
+ /**
+ * Creates a provider that does not cache connection managers.
+ *
+ * A new {@link HttpClientConnectionManager} is created for each call. This is the default behavior and provides
+ * maximum isolation but highest memory consumption.
+ *
+ *
+ * @return A provider that creates a new connection manager for each http-client.
+ */
+ @Nonnull
+ public static ConnectionPoolManagerProvider noCache()
+ {
+ return ConnectionPoolManagerProviders::createConnectionManager;
+ }
+
+ /**
+ * Creates a builder for cached connection pool manager providers using a default {@link ConcurrentHashMap} as the
+ * cache.
+ *
+ * The returned builder allows selecting a caching strategy (by tenant, by destination name, global, or custom).
+ *
+ *
+ * @param cacheFunction
+ * A function that takes a cache key and a loader function, and returns the cached or newly computed
+ * value. The signature matches {@code ConcurrentMap::computeIfAbsent} and {@code Cache::get}.
+ * @return A builder for configuring the caching strategy.
+ */
+ @Nonnull
+ public static CachedProviderBuilder cached(
+ @Nonnull final BiFunction, HttpClientConnectionManager> cacheFunction )
+ {
+ Objects.requireNonNull(cacheFunction, "Cache function must not be null");
+ return new CachedProviderBuilder(cacheFunction);
+ }
+
+ /**
+ * Builder class for creating cached {@link ConnectionPoolManagerProvider} instances with various caching
+ * strategies.
+ *
+ * Use {@link ConnectionPoolManagerProviders#cached()} or {@link ConnectionPoolManagerProviders#cached(BiFunction)}
+ * to obtain an instance of this builder.
+ *
+ *
+ * @since 5.27.0
+ */
+ @Beta
+ @RequiredArgsConstructor( access = AccessLevel.PRIVATE )
+ public static final class CachedProviderBuilder
+ {
+ @Nonnull
+ private final BiFunction, HttpClientConnectionManager> cacheFunction;
+
+ /**
+ * Creates a provider that caches connection managers by the current tenant.
+ *
+ * Connection managers are shared among all destinations accessed within the same tenant context. This is useful
+ * when tenant isolation is required but destination-level isolation is not necessary.
+ *
+ *
+ * If no tenant is available in the current context, a new connection manager is used.
+ *
+ *
+ * @return A provider that caches connection managers by tenant.
+ * @see TenantAccessor#tryGetCurrentTenant()
+ */
+ @Nonnull
+ public ConnectionPoolManagerProvider byCurrentTenant()
+ {
+ return by(dest -> TenantAccessor.tryGetCurrentTenant().map(Tenant::getTenantId).getOrNull());
+ }
+
+ /**
+ * Creates a provider that caches connection managers by destination name.
+ *
+ * Connection managers are shared among all HttpClients and their requests to destinations with the same name.
+ * This is useful when different destinations may have different TLS or proxy configurations.
+ *
+ *
+ * If the destination has no name or is {@code null}, a new connection manager is used.
+ *
+ *
+ * @return A provider that caches connection managers by destination name.
+ */
+ @Nonnull
+ public ConnectionPoolManagerProvider byDestinationName()
+ {
+ return by(dest -> dest != null ? dest.get(DestinationProperty.NAME).getOrNull() : null);
+ }
+
+ /**
+ * Creates a provider that reuses the connection managers for different tenants if the destination is
+ * tenant-independent.
+ *
+ * The provider checks for the presence of {@link OnBehalfOf} indicators in the destination's custom header
+ * providers. If all indicators show that the destination is on behalf of a provider tenant, the same connection
+ * manager is reused across tenants. Otherwise, tenant information is included in the cache key to ensure
+ * isolation.
+ *
+ *
+ * @return A provider that reuses the connection managers for different tenants if the destination is
+ * tenant-independent.
+ */
+ @Nonnull
+ public ConnectionPoolManagerProvider byOnBehalfOf()
+ {
+ return by(destination -> {
+ // Check if the destination has any OnBehalfOf indicators in its custom header providers
+ if( !(destination instanceof final DefaultHttpDestination dest) ) {
+ return null;
+ }
+ final List headerProviders = dest.getCustomHeaderProviders();
+ final List behalfOfIndicators =
+ headerProviders
+ .stream()
+ .filter(IsOnBehalfOf.class::isInstance)
+ .map(d -> ((IsOnBehalfOf) d).getOnBehalfOf())
+ .filter(Objects::nonNull)
+ .toList();
+
+ // If no OnBehalfOf indicators are present, return null to avoid caching
+ if( behalfOfIndicators.isEmpty() && !headerProviders.isEmpty() ) {
+ return null;
+ }
+
+ final boolean indicatesProviderTenant =
+ behalfOfIndicators.stream().allMatch(b -> b == OnBehalfOf.TECHNICAL_USER_PROVIDER);
+
+ // If the destination indicates that it is on behalf of a provider tenant, return a cache key that does not include tenant information, allowing sharing across tenants if other properties match.
+ if( indicatesProviderTenant ) {
+ return dest;
+ }
+ // For other cases, include tenant information in the cache key to ensure proper isolation
+ return List.of(TenantAccessor.tryGetCurrentTenant().map(Tenant::getTenantId).getOrNull(), dest);
+ });
+ }
+
+ /**
+ * Creates a provider that caches connection managers using a custom cache key extractor.
+ *
+ * The cache key extractor function is called for each HttpClient to determine which cached connection manager
+ * to use. Calls that produce equal cache keys (via {@link Object#equals(Object)}) will share the same
+ * connection manager.
+ *
+ *
+ * Note: The cache key extractor should return consistent keys for destinations that can safely
+ * share a connection manager. Consider TLS configuration, proxy settings, and isolation requirements when
+ * designing the key extraction logic.
+ *
+ *
+ * @param cacheKeyExtractor
+ * A function that extracts a cache key from the destination. The function receives {@code null} when
+ * creating a generic (non-destination-specific) connection manager. The returned key may be
+ * {@code null} to indicate a new uncached entry.
+ * @return A provider that caches connection managers using the custom key extractor.
+ */
+ @Nonnull
+ public ConnectionPoolManagerProvider by(
+ @Nonnull final Function cacheKeyExtractor )
+ {
+ Objects.requireNonNull(cacheKeyExtractor, "Cache key extractor must not be null");
+ return ( settings, destination ) -> {
+ final Object rawKey = cacheKeyExtractor.apply(destination);
+ if( rawKey == null ) {
+ log.debug("Creating new uncached connection manager for destination: {}", destination);
+ return createConnectionManager(settings, destination);
+ }
+ return cacheFunction.apply(rawKey, key -> {
+ log.debug("Creating new cached connection manager for key: {}", rawKey);
+ return createConnectionManager(settings, destination);
+ });
+ };
+ }
+ }
+
+ /**
+ * Creates a new connection manager with the given settings and destination-specific TLS configuration.
+ */
+ @Nonnull
+ static HttpClientConnectionManager createConnectionManager(
+ @Nonnull final ConnectionPoolSettings settings,
+ @Nullable final HttpDestinationProperties destination )
+ throws HttpClientInstantiationException
+ {
+ try {
+ final Timeout timeoutSocket = Timeout.of(settings.getSocketTimeout());
+ final Timeout timeoutConnect = Timeout.of(settings.getConnectTimeout());
+
+ return PoolingHttpClientConnectionManagerBuilder
+ .create()
+ .setTlsSocketStrategy(getTlsSocketStrategy(destination))
+ .setDefaultSocketConfig(SocketConfig.custom().setSoTimeout(timeoutSocket).build())
+ .setDefaultConnectionConfig(
+ ConnectionConfig.custom().setConnectTimeout(timeoutConnect).setSocketTimeout(timeoutSocket).build())
+ .setMaxConnTotal(settings.getMaxConnectionsTotal())
+ .setMaxConnPerRoute(settings.getMaxConnectionsPerRoute())
+ .build();
+ }
+ catch( final GeneralSecurityException | IOException e ) {
+ throw new HttpClientInstantiationException("Failed to create HTTP client connection manager.", e);
+ }
+ }
+
+ @Nullable
+ private static TlsSocketStrategy getTlsSocketStrategy( @Nullable final HttpDestinationProperties destination )
+ throws GeneralSecurityException,
+ IOException
+ {
+ if( !supportsTls(destination) ) {
+ return null;
+ }
+
+ log.debug("The destination uses HTTPS for target \"{}\".", destination.getUri());
+ final SSLContext sslContext = new SSLContextFactory().createSSLContext(destination);
+ final HostnameVerifier hostnameVerifier = getHostnameVerifier(destination);
+
+ return new DefaultClientTlsStrategy(sslContext, hostnameVerifier);
+ }
+
+ private static HostnameVerifier getHostnameVerifier( @Nonnull final HttpDestinationProperties destination )
+ {
+ return destination.isTrustingAllCertificates() ? new NoopHostnameVerifier() : new DefaultHostnameVerifier();
+ }
+
+ private static boolean supportsTls( @Nullable final HttpDestinationProperties destination )
+ {
+ if( destination == null ) {
+ return false;
+ }
+ final String scheme = destination.getUri().getScheme();
+ return "https".equalsIgnoreCase(scheme) || StringUtils.isEmpty(scheme);
+ }
+}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolSettings.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolSettings.java
new file mode 100644
index 000000000..473a5e66b
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolSettings.java
@@ -0,0 +1,79 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+
+import com.google.common.annotations.Beta;
+
+/**
+ * Configuration settings for HTTP connection pool managers and HTTP client request configuration.
+ *
+ * These settings control the behavior of {@link org.apache.hc.client5.http.io.HttpClientConnectionManager} instances
+ * created by {@link ConnectionPoolManagerProvider} implementations, as well as the request-level timeout configuration.
+ *
+ *
+ * Use {@link DefaultConnectionPoolSettings#ofDefaults()} or {@link DefaultConnectionPoolSettings#builder()} to create
+ * instances. Users can also implement this interface to provide custom settings implementations.
+ *
+ *
+ * @see ConnectionPoolManagerProviders
+ * @see DefaultConnectionPoolSettings
+ * @since 5.27.0
+ */
+@Beta
+public interface ConnectionPoolSettings
+{
+ /**
+ * Default timeout of 2 minutes (applies to connect, socket, and connection request timeouts).
+ */
+ Duration DEFAULT_TIMEOUT = Duration.ofMinutes(2L);
+
+ /**
+ * Default maximum total connections of 200.
+ */
+ int DEFAULT_MAX_CONNECTIONS_TOTAL = 200;
+
+ /**
+ * Default maximum connections per route of 100.
+ */
+ int DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 100;
+
+ /**
+ * Returns the timeout until a new connection is fully established.
+ *
+ * @return The connect timeout.
+ */
+ @Nonnull
+ Duration getConnectTimeout();
+
+ /**
+ * Returns the default socket timeout value for I/O operations on connections.
+ *
+ * @return The socket timeout.
+ */
+ @Nonnull
+ Duration getSocketTimeout();
+
+ /**
+ * Returns the timeout when requesting a connection lease from the connection pool.
+ *
+ * @return The connection request timeout.
+ */
+ @Nonnull
+ Duration getConnectionRequestTimeout();
+
+ /**
+ * Returns the maximum number of total connections in the pool.
+ *
+ * @return The maximum total connections.
+ */
+ int getMaxConnectionsTotal();
+
+ /**
+ * Returns the maximum number of connections per route (e.g., per remote host).
+ *
+ * @return The maximum connections per route.
+ */
+ int getMaxConnectionsPerRoute();
+}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java
index f7660519b..cbf658215 100644
--- a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5Factory.java
@@ -1,51 +1,37 @@
package com.sap.cloud.sdk.cloudplatform.connectivity;
-import java.io.IOException;
import java.net.URI;
-import java.security.GeneralSecurityException;
-import java.time.Duration;
import java.util.Objects;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import javax.net.ssl.HostnameVerifier;
-import javax.net.ssl.SSLContext;
import org.apache.hc.client5.http.classic.HttpClient;
-import org.apache.hc.client5.http.config.ConnectionConfig;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
-import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
-import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
-import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier;
-import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
-import org.apache.hc.client5.http.ssl.TlsSocketStrategy;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.HttpRequestInterceptor;
-import org.apache.hc.core5.http.io.SocketConfig;
import org.apache.hc.core5.util.Timeout;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.DestinationAccessException;
import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException;
-import com.sap.cloud.sdk.cloudplatform.util.StringUtils;
import io.vavr.control.Option;
+import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
+@RequiredArgsConstructor
class DefaultApacheHttpClient5Factory implements ApacheHttpClient5Factory
{
- static final Duration DEFAULT_TIMEOUT = Duration.ofMinutes(2L);
- static final int DEFAULT_MAX_CONNECTIONS_TOTAL = 200;
- static final int DEFAULT_MAX_CONNECTIONS_PER_ROUTE = 100;
+ @Nonnull
+ private final ConnectionPoolSettings settings;
@Nonnull
- private final Timeout timeout;
- private final int maxConnectionsTotal;
- private final int maxConnectionsPerRoute;
+ private final ConnectionPoolManagerProvider connectionPoolManagerProvider;
@Nullable
private final HttpRequestInterceptor requestInterceptor;
@@ -53,20 +39,6 @@ class DefaultApacheHttpClient5Factory implements ApacheHttpClient5Factory
@Nonnull
private final ApacheHttpClient5FactoryBuilder.TlsUpgrade tlsUpgrade;
- DefaultApacheHttpClient5Factory(
- @Nonnull final Duration timeout,
- final int maxConnectionsTotal,
- final int maxConnectionsPerRoute,
- @Nullable final HttpRequestInterceptor requestInterceptor,
- @Nonnull final ApacheHttpClient5FactoryBuilder.TlsUpgrade tlsUpgrade )
- {
- this.timeout = toTimeout(timeout);
- this.maxConnectionsTotal = maxConnectionsTotal;
- this.maxConnectionsPerRoute = maxConnectionsPerRoute;
- this.requestInterceptor = requestInterceptor;
- this.tlsUpgrade = tlsUpgrade;
- }
-
@Nonnull
@Override
public HttpClient createHttpClient( @Nullable final HttpDestinationProperties destination )
@@ -83,14 +55,18 @@ public HttpClient createHttpClient( @Nullable final HttpDestinationProperties de
}
@Nonnull
+ @SuppressWarnings( "PMD.CloseResource" ) // The HttpClient instance and the connection manager instance are not being closed here.
private CloseableHttpClient buildHttpClient(
@Nullable final HttpDestinationProperties destination,
@Nonnull final RequestConfig requestConfig )
{
+ final HttpClientConnectionManager connManager =
+ connectionPoolManagerProvider.getConnectionManager(settings, destination);
+
final HttpClientBuilder builder =
HttpClients
.custom()
- .setConnectionManager(getConnectionManager(destination))
+ .setConnectionManager(connManager)
.setDefaultRequestConfig(requestConfig)
.setProxy(getProxy(destination));
@@ -101,69 +77,13 @@ private CloseableHttpClient buildHttpClient(
return builder.build();
}
- @Nonnull
- private HttpClientConnectionManager getConnectionManager( @Nullable final HttpDestinationProperties destination )
- {
- try {
- return PoolingHttpClientConnectionManagerBuilder
- .create()
- .setTlsSocketStrategy(getTlsSocketStrategy(destination))
- .setDefaultSocketConfig(SocketConfig.custom().setSoTimeout(timeout).build())
- .setDefaultConnectionConfig(
- ConnectionConfig.custom().setConnectTimeout(timeout).setSocketTimeout(timeout).build())
- .setMaxConnTotal(maxConnectionsTotal)
- .setMaxConnPerRoute(maxConnectionsPerRoute)
- .build();
- }
- catch( final GeneralSecurityException | IOException e ) {
- throw new HttpClientInstantiationException("Failed to create HTTP client connection manager.", e);
- }
- }
-
- @Nonnull
- private static Timeout toTimeout( @Nonnull final Duration duration )
- {
- return Timeout.ofMilliseconds(duration.toMillis());
- }
-
- @Nullable
- private TlsSocketStrategy getTlsSocketStrategy( @Nullable final HttpDestinationProperties destination )
- throws GeneralSecurityException,
- IOException
- {
- if( !supportsTls(destination) ) {
- return null;
- }
-
- log.debug("The destination uses HTTPS for target \"{}\".", destination.getUri());
- final SSLContext sslContext = new SSLContextFactory().createSSLContext(destination);
-
- final HostnameVerifier hostnameVerifier = getHostnameVerifier(destination);
-
- return new DefaultClientTlsStrategy(sslContext, hostnameVerifier);
- }
-
- private boolean supportsTls( @Nullable final HttpDestinationProperties destination )
- {
- if( destination == null ) {
- return false;
- }
- final String scheme = destination.getUri().getScheme();
- return "https".equalsIgnoreCase(scheme) || StringUtils.isEmpty(scheme);
- }
-
- private HostnameVerifier getHostnameVerifier( final HttpDestinationProperties destination )
- {
- return destination.isTrustingAllCertificates() ? new NoopHostnameVerifier() : new DefaultHostnameVerifier();
- }
-
@Nonnull
private RequestConfig getRequestConfig( @Nullable final HttpDestinationProperties destination )
{
return RequestConfig
.custom()
.setProtocolUpgradeEnabled(isProtocolUpgradeEnabled(destination))
- .setConnectionRequestTimeout(timeout)
+ .setConnectionRequestTimeout(Timeout.ofMilliseconds(settings.getConnectionRequestTimeout().toMillis()))
.build();
}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultConnectionPoolSettings.java b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultConnectionPoolSettings.java
new file mode 100644
index 000000000..353adc54d
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultConnectionPoolSettings.java
@@ -0,0 +1,103 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.time.Duration;
+
+import javax.annotation.Nonnull;
+
+import com.google.common.annotations.Beta;
+
+import lombok.Builder;
+import lombok.Value;
+import lombok.With;
+
+/**
+ * Default implementation of {@link ConnectionPoolSettings} using Lombok's {@code @Value} for immutability.
+ *
+ * Use {@link #ofDefaults()} to create an instance with default values, or {@link #builder()} to create instances with
+ * custom values.
+ *
+ *
+ * @see ConnectionPoolSettings
+ * @since 5.27.0
+ */
+@Beta
+@Value
+@Builder
+@With
+public class DefaultConnectionPoolSettings implements ConnectionPoolSettings
+{
+ /**
+ * The timeout until a new connection is fully established.
+ */
+ @Nonnull
+ @Builder.Default
+ Duration connectTimeout = DEFAULT_TIMEOUT;
+
+ /**
+ * The default socket timeout value for I/O operations on connections.
+ */
+ @Nonnull
+ @Builder.Default
+ Duration socketTimeout = DEFAULT_TIMEOUT;
+
+ /**
+ * The timeout when requesting a connection lease from the connection pool.
+ */
+ @Nonnull
+ @Builder.Default
+ Duration connectionRequestTimeout = DEFAULT_TIMEOUT;
+
+ /**
+ * The maximum number of total connections in the pool.
+ */
+ @Builder.Default
+ int maxConnectionsTotal = DEFAULT_MAX_CONNECTIONS_TOTAL;
+
+ /**
+ * The maximum number of connections per route (e.g., per remote host).
+ */
+ @Builder.Default
+ int maxConnectionsPerRoute = DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
+
+ /**
+ * Creates a new {@link DefaultConnectionPoolSettings} with default values.
+ *
+ * Default values:
+ *
+ *
Connect timeout: 2 minutes
+ *
Socket timeout: 2 minutes
+ *
Connection request timeout: 2 minutes
+ *
Max connections total: 200
+ *
Max connections per route: 100
+ *
+ *
+ *
+ * @return A new instance with default settings.
+ */
+ @Nonnull
+ public static DefaultConnectionPoolSettings ofDefaults()
+ {
+ return builder().build();
+ }
+}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvidersTest.java b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvidersTest.java
new file mode 100644
index 000000000..2e60f6414
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvidersTest.java
@@ -0,0 +1,391 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+
+import org.apache.hc.client5.http.io.HttpClientConnectionManager;
+import org.junit.jupiter.api.Test;
+
+import com.sap.cloud.sdk.cloudplatform.tenant.DefaultTenant;
+import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+class ConnectionPoolManagerProvidersTest
+{
+ private static final ConnectionPoolSettings DEFAULT_SETTINGS = DefaultConnectionPoolSettings.ofDefaults();
+
+ @Test
+ void testNoCacheCreatesNewManagerEachTime()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.noCache();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager2).isNotNull();
+ assertThat(manager1).isNotSameAs(manager2);
+ }
+
+ @Test
+ void testCachedGlobalReturnsSameManagerForAllCalls()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().by(destination -> true);
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ final HttpDestinationProperties destination = DefaultHttpDestination.builder("http://example.com").build();
+ final HttpClientConnectionManager manager3 = provider.getConnectionManager(DEFAULT_SETTINGS, destination);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isSameAs(manager2);
+ assertThat(manager1).isSameAs(manager3);
+ }
+
+ @Test
+ void testCachedByDestinationNameCachesByName()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
+
+ final HttpDestinationProperties dest1 =
+ DefaultHttpDestination.builder("http://example1.com").name("dest-a").build();
+ final HttpDestinationProperties dest2 =
+ DefaultHttpDestination.builder("http://example2.com").name("dest-a").build();
+ final HttpDestinationProperties dest3 =
+ DefaultHttpDestination.builder("http://example3.com").name("dest-b").build();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, dest1);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, dest2);
+ final HttpClientConnectionManager manager3 = provider.getConnectionManager(DEFAULT_SETTINGS, dest3);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isSameAs(manager2); // Same name "dest-a"
+ assertThat(manager1).isNotSameAs(manager3); // Different name "dest-b"
+ }
+
+ @Test
+ void testCachedByDestinationNameHandlesNullDestination()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isNotSameAs(manager2);
+ }
+
+ @Test
+ void testCachedByDestinationNameHandlesUnnamedDestination()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
+
+ final HttpDestinationProperties unnamedDest = DefaultHttpDestination.builder("http://example.com").build();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, unnamedDest);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ // Both should use the same "null key" bucket
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isNotSameAs(manager2);
+ }
+
+ @Test
+ void testCachedByTenantCachesByTenant()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byCurrentTenant();
+
+ final HttpClientConnectionManager managerTenant1 =
+ TenantAccessor
+ .executeWithTenant(
+ new DefaultTenant("tenant-1"),
+ () -> provider.getConnectionManager(DEFAULT_SETTINGS, null));
+
+ final HttpClientConnectionManager managerTenant1Again =
+ TenantAccessor
+ .executeWithTenant(
+ new DefaultTenant("tenant-1"),
+ () -> provider.getConnectionManager(DEFAULT_SETTINGS, null));
+
+ final HttpClientConnectionManager managerTenant2 =
+ TenantAccessor
+ .executeWithTenant(
+ new DefaultTenant("tenant-2"),
+ () -> provider.getConnectionManager(DEFAULT_SETTINGS, null));
+
+ assertThat(managerTenant1).isNotNull();
+ assertThat(managerTenant1).isSameAs(managerTenant1Again); // Same tenant
+ assertThat(managerTenant1).isNotSameAs(managerTenant2); // Different tenant
+ }
+
+ @Test
+ void testCachedByTenantHandlesNoTenant()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byCurrentTenant();
+
+ // Without tenant context
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isNotSameAs(manager2);
+ }
+
+ @Test
+ void testCachedWithCacheKeyCustomExtractor()
+ {
+ // Custom extractor that uses the URI host as cache key
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().by(dest -> {
+ if( dest == null ) {
+ return "no-destination";
+ }
+ return dest.getUri().getHost();
+ });
+
+ final HttpDestinationProperties dest1 = DefaultHttpDestination.builder("http://host-a.com/path1").build();
+ final HttpDestinationProperties dest2 = DefaultHttpDestination.builder("http://host-a.com/path2").build();
+ final HttpDestinationProperties dest3 = DefaultHttpDestination.builder("http://host-b.com/path1").build();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, dest1);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, dest2);
+ final HttpClientConnectionManager manager3 = provider.getConnectionManager(DEFAULT_SETTINGS, dest3);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isSameAs(manager2); // Same host "host-a.com"
+ assertThat(manager1).isNotSameAs(manager3); // Different host "host-b.com"
+ }
+
+ @Test
+ void testCachedWithCustomConcurrentMap()
+ {
+ // Use a custom ConcurrentMap
+ final ConcurrentMap customCache = new ConcurrentHashMap<>();
+
+ final ConnectionPoolManagerProvider provider =
+ ConnectionPoolManagerProviders.cached(customCache::computeIfAbsent).byDestinationName();
+
+ final HttpDestinationProperties dest =
+ DefaultHttpDestination.builder("http://example.com").name("my-dest").build();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, dest);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, dest);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isSameAs(manager2);
+
+ // Verify the cache was used
+ assertThat(customCache).hasSize(1);
+ assertThat(customCache).containsKey("my-dest");
+ assertThat(customCache.get("my-dest")).isSameAs(manager1);
+ }
+
+ @Test
+ void testCachedWithCustomCacheFunction()
+ {
+ // Simulate a Caffeine-like cache with a custom BiFunction
+ final ConcurrentMap backingMap = new ConcurrentHashMap<>();
+ final AtomicInteger loadCount = new AtomicInteger(0);
+
+ final BiFunction, HttpClientConnectionManager> cacheFunction =
+ ( key, loader ) -> {
+ return backingMap.computeIfAbsent(key, k -> {
+ loadCount.incrementAndGet();
+ return loader.apply(k);
+ });
+ };
+
+ final ConnectionPoolManagerProvider provider =
+ ConnectionPoolManagerProviders.cached(cacheFunction).by(destination -> true);
+
+ // First call should load
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ assertThat(loadCount.get()).isEqualTo(1);
+
+ // Second call should use cache
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ assertThat(loadCount.get()).isEqualTo(1); // Still 1, no new load
+
+ assertThat(manager1).isSameAs(manager2);
+ }
+
+ @Test
+ void testNullCacheKeyExtractorThrowsException()
+ {
+ assertThatNullPointerException()
+ .isThrownBy(() -> ConnectionPoolManagerProviders.cached().by(null))
+ .withMessageContaining("Cache key extractor must not be null");
+ }
+
+ @Test
+ void testNullCacheFunctionThrowsException()
+ {
+ assertThatNullPointerException()
+ .isThrownBy(() -> ConnectionPoolManagerProviders.cached(null))
+ .withMessageContaining("Cache function must not be null");
+ }
+
+ @Test
+ void testFunctionalInterfaceCanBeUsedWithLambda()
+ {
+ // Verify that ConnectionPoolManagerProvider can be used as a lambda
+ final ConnectionPoolManagerProvider lambdaProvider =
+ ( settings, dest ) -> ConnectionPoolManagerProviders.noCache().getConnectionManager(settings, dest);
+
+ final HttpClientConnectionManager manager = lambdaProvider.getConnectionManager(DEFAULT_SETTINGS, null);
+ assertThat(manager).isNotNull();
+ }
+
+ @Test
+ void testCachedByOnBehalfOfWithCurrentTenantHeaderProvider()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
+
+ // Create a header provider that indicates NAMED_USER_CURRENT_TENANT
+ final DestinationHeaderProvider namedUserProvider =
+ new TestHeaderProvider(OnBehalfOf.NAMED_USER_CURRENT_TENANT);
+
+ // Create destinations with the header provider
+ final DefaultHttpDestination destTenant1 =
+ DefaultHttpDestination.builder("http://example.com").headerProviders(namedUserProvider).build();
+
+ final DefaultHttpDestination destTenant2 =
+ DefaultHttpDestination.builder("http://example.com").headerProviders(namedUserProvider).build();
+
+ // Same destination with same tenant should return same manager
+ final DefaultTenant tenant1 = new DefaultTenant("tenant-1");
+ final HttpClientConnectionManager managerTenant1 =
+ TenantAccessor
+ .executeWithTenant(tenant1, () -> provider.getConnectionManager(DEFAULT_SETTINGS, destTenant1));
+
+ final HttpClientConnectionManager managerTenant1Again =
+ TenantAccessor
+ .executeWithTenant(tenant1, () -> provider.getConnectionManager(DEFAULT_SETTINGS, destTenant2));
+
+ // Different tenant should return different manager
+ final DefaultTenant tenant2 = new DefaultTenant("tenant-2");
+ final HttpClientConnectionManager managerTenant2 =
+ TenantAccessor
+ .executeWithTenant(tenant2, () -> provider.getConnectionManager(DEFAULT_SETTINGS, destTenant1));
+
+ assertThat(managerTenant1).isNotNull();
+ assertThat(managerTenant1).isSameAs(managerTenant1Again); // Same tenant, same destination
+ assertThat(managerTenant1).isNotSameAs(managerTenant2); // Different tenant
+ }
+
+ @Test
+ void testCachedByOnBehalfOfWithTechnicalUserCurrentTenant()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
+
+ final DefaultHttpDestination dest =
+ DefaultHttpDestination
+ .builder("http://example.com")
+ .headerProviders(new TestHeaderProvider(OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT))
+ .build();
+
+ // Different tenants should get different managers
+ final DefaultTenant tenant1 = new DefaultTenant("tenant-1");
+ final HttpClientConnectionManager managerTenant1 =
+ TenantAccessor.executeWithTenant(tenant1, () -> provider.getConnectionManager(DEFAULT_SETTINGS, dest));
+
+ final DefaultTenant tenant2 = new DefaultTenant("tenant-2");
+ final HttpClientConnectionManager managerTenant2 =
+ TenantAccessor.executeWithTenant(tenant2, () -> provider.getConnectionManager(DEFAULT_SETTINGS, dest));
+
+ assertThat(managerTenant1).isNotNull();
+ assertThat(managerTenant2).isNotNull();
+ assertThat(managerTenant1).isNotSameAs(managerTenant2); // Different tenant
+ }
+
+ @Test
+ void testCachedByOnBehalfOfWithProviderUserSharesAcrossTenants()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
+
+ // Create a header provider that indicates TECHNICAL_USER_PROVIDER (not current tenant)
+ final DefaultHttpDestination dest =
+ DefaultHttpDestination
+ .builder("http://example.com")
+ .headerProviders(new TestHeaderProvider(OnBehalfOf.TECHNICAL_USER_PROVIDER))
+ .build();
+
+ // Different tenants should share the same manager since it's not on behalf of current tenant
+ final DefaultTenant tenant1 = new DefaultTenant("tenant-1");
+ final HttpClientConnectionManager managerTenant1 =
+ TenantAccessor.executeWithTenant(tenant1, () -> provider.getConnectionManager(DEFAULT_SETTINGS, dest));
+
+ final DefaultTenant tenant2 = new DefaultTenant("tenant-2");
+ final HttpClientConnectionManager managerTenant2 =
+ TenantAccessor.executeWithTenant(tenant2, () -> provider.getConnectionManager(DEFAULT_SETTINGS, dest));
+
+ assertThat(managerTenant1).isNotNull();
+ assertThat(managerTenant1).isSameAs(managerTenant2); // Same manager shared across tenants
+ }
+
+ @Test
+ void testCachedByOnBehalfOfWithNoHeaderProvider()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
+
+ // Destination without any header provider
+ final DefaultHttpDestination dest = DefaultHttpDestination.builder("http://example.com").build();
+
+ // Different tenants should share the same manager since there's no on-behalf-of indication
+ final DefaultTenant tenant1 = new DefaultTenant("tenant-1");
+ final HttpClientConnectionManager managerTenant1 =
+ TenantAccessor.executeWithTenant(tenant1, () -> provider.getConnectionManager(DEFAULT_SETTINGS, dest));
+
+ final DefaultTenant tenant2 = new DefaultTenant("tenant-2");
+ final HttpClientConnectionManager managerTenant2 =
+ TenantAccessor.executeWithTenant(tenant2, () -> provider.getConnectionManager(DEFAULT_SETTINGS, dest));
+
+ assertThat(managerTenant1).isNotNull();
+ assertThat(managerTenant1).isSameAs(managerTenant2); // Same manager shared across tenants
+ }
+
+ @Test
+ void testCachedByOnBehalfOfWithNonDefaultHttpDestination()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
+
+ // Non-DefaultHttpDestination should return null key and create new manager each time
+ final HttpDestinationProperties nonDefaultDest = DefaultHttpDestination.builder("http://example.com").build();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, nonDefaultDest);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, nonDefaultDest);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager2).isNotNull();
+ assertThat(manager1).isSameAs(manager2); // New manager each time for non-DefaultHttpDestination
+ }
+
+ /**
+ * Test implementation of DestinationHeaderProvider that also implements IsOnBehalfOf.
+ */
+ @RequiredArgsConstructor
+ private static class TestHeaderProvider implements DestinationHeaderProvider, IsOnBehalfOf
+ {
+ @Getter
+ private final OnBehalfOf onBehalfOf;
+
+ @Nonnull
+ @Override
+ public List getHeaders( @Nonnull final DestinationRequestContext requestContext )
+ {
+ return Collections.emptyList();
+ }
+ }
+}
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5CacheTest.java b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5CacheTest.java
index 7295ec9d9..b9e9e5303 100644
--- a/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5CacheTest.java
+++ b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5CacheTest.java
@@ -46,9 +46,8 @@ class DefaultApacheHttpClient5CacheTest
private static final ApacheHttpClient5Factory FACTORY =
new DefaultApacheHttpClient5Factory(
- DefaultApacheHttpClient5Factory.DEFAULT_TIMEOUT,
- DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_TOTAL,
- DefaultApacheHttpClient5Factory.DEFAULT_MAX_CONNECTIONS_PER_ROUTE,
+ DefaultConnectionPoolSettings.ofDefaults(),
+ ConnectionPoolManagerProviders.noCache(),
null,
ApacheHttpClient5FactoryBuilder.TlsUpgrade.AUTOMATIC);
private static final long NANOSECONDS_IN_MINUTE = 60_000_000_000L;
diff --git a/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5FactoryTest.java b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5FactoryTest.java
index 80a9e6e01..02e342f53 100644
--- a/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5FactoryTest.java
+++ b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/DefaultApacheHttpClient5FactoryTest.java
@@ -80,11 +80,11 @@ void setup()
requestInterceptor = mock(HttpRequestInterceptor.class);
doNothing().when(requestInterceptor).process(any(), any(), any());
+ final ConnectionPoolSettings settings = DefaultConnectionPoolSettings.ofDefaults();
sut =
new DefaultApacheHttpClient5Factory(
- CLIENT_TIMEOUT,
- MAX_CONNECTIONS,
- MAX_CONNECTIONS_PER_ROUTE,
+ settings,
+ ConnectionPoolManagerProviders.noCache(),
requestInterceptor,
AUTOMATIC);
}
@@ -95,19 +95,31 @@ void testHttpClientUsesTimeout()
{
WIRE_MOCK_SERVER.stubFor(get(urlEqualTo("/timeout")).willReturn(ok().withFixedDelay(5_000)));
+ final Duration tooLittleTimeout = Duration.ofSeconds(3L);
+ final ConnectionPoolSettings settingsTooLittle =
+ DefaultConnectionPoolSettings
+ .ofDefaults()
+ .withConnectTimeout(tooLittleTimeout)
+ .withSocketTimeout(tooLittleTimeout)
+ .withConnectionRequestTimeout(tooLittleTimeout);
final ApacheHttpClient5Factory factoryWithTooLittleTimeout =
new DefaultApacheHttpClient5Factory(
- Duration.ofSeconds(3L),
- MAX_CONNECTIONS,
- MAX_CONNECTIONS_PER_ROUTE,
+ settingsTooLittle,
+ ConnectionPoolManagerProviders.noCache(),
requestInterceptor,
AUTOMATIC);
+ final Duration enoughTimeout = Duration.ofSeconds(7L);
+ final ConnectionPoolSettings settingsEnough =
+ DefaultConnectionPoolSettings
+ .ofDefaults()
+ .withConnectTimeout(enoughTimeout)
+ .withSocketTimeout(enoughTimeout)
+ .withConnectionRequestTimeout(enoughTimeout);
final ApacheHttpClient5Factory factoryWithEnoughTimeout =
new DefaultApacheHttpClient5Factory(
- Duration.ofSeconds(7L),
- MAX_CONNECTIONS,
- MAX_CONNECTIONS_PER_ROUTE,
+ settingsEnough,
+ ConnectionPoolManagerProviders.noCache(),
requestInterceptor,
AUTOMATIC);
@@ -132,11 +144,19 @@ void testHttpClientUsesMaxConnections()
WIRE_MOCK_SERVER.stubFor(get(urlEqualTo("/max-connections-1")).willReturn(ok()));
WIRE_MOCK_SERVER.stubFor(get(urlEqualTo("/max-connections-2")).willReturn(ok()));
+ final Duration timeout = Duration.ofSeconds(3L); // this timeout is also used for the connection lease
+ final ConnectionPoolSettings settings =
+ DefaultConnectionPoolSettings
+ .builder()
+ .connectTimeout(timeout)
+ .socketTimeout(timeout)
+ .connectionRequestTimeout(timeout)
+ .maxConnectionsTotal(1)
+ .build();
final ApacheHttpClient5Factory sut =
new DefaultApacheHttpClient5Factory(
- Duration.ofSeconds(3L), // this timeout is also used for the connection lease
- 1,
- MAX_CONNECTIONS_PER_ROUTE,
+ settings,
+ ConnectionPoolManagerProviders.noCache(),
requestInterceptor,
AUTOMATIC);
@@ -155,11 +175,19 @@ void testHttpClientUsesMaxConnectionsPerRoute()
WIRE_MOCK_SERVER.stubFor(get(urlEqualTo("/max-connections-per-route")).willReturn(ok()));
SECOND_WIRE_MOCK_SERVER.stubFor(get(urlEqualTo("/max-connections-per-route")).willReturn(ok()));
+ final Duration timeout = Duration.ofSeconds(3L); // this timeout is also used for the connection lease
+ final ConnectionPoolSettings settings =
+ DefaultConnectionPoolSettings
+ .builder()
+ .connectTimeout(timeout)
+ .socketTimeout(timeout)
+ .connectionRequestTimeout(timeout)
+ .maxConnectionsPerRoute(1)
+ .build();
final ApacheHttpClient5Factory sut =
new DefaultApacheHttpClient5Factory(
- Duration.ofSeconds(3L), // this timeout is also used for the connection lease
- MAX_CONNECTIONS,
- 1,
+ settings,
+ ConnectionPoolManagerProviders.noCache(),
requestInterceptor,
AUTOMATIC);
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2HeaderProvider.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2HeaderProvider.java
index 3aa246367..48d5c650f 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2HeaderProvider.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2HeaderProvider.java
@@ -5,6 +5,7 @@
import java.util.List;
import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
@@ -13,13 +14,21 @@
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
-class OAuth2HeaderProvider implements DestinationHeaderProvider
+class OAuth2HeaderProvider implements DestinationHeaderProvider, IsOnBehalfOf
{
@Nonnull
private final OAuth2Service oauth2service;
+
@Nonnull
private final String authHeaderName;
+ @Nullable
+ @Override
+ public OnBehalfOf getOnBehalfOf()
+ {
+ return oauth2service.getOnBehalfOf();
+ }
+
@Nonnull
@Override
public List getHeaders( @Nonnull final DestinationRequestContext requestContext )
diff --git a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
index ea3b29d8a..00155666a 100644
--- a/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
+++ b/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java
@@ -54,7 +54,7 @@
*/
@RequiredArgsConstructor( access = AccessLevel.PACKAGE )
@Slf4j
-class OAuth2Service
+class OAuth2Service implements IsOnBehalfOf
{
/**
* Cache to reuse OAuth2TokenService and with that reuse the underlying response cache.
@@ -83,6 +83,7 @@ class OAuth2Service
@Nonnull
private final ClientIdentity identity;
@Nonnull
+ @Getter
private final OnBehalfOf onBehalfOf;
@Nonnull
private final TenantPropagationStrategy tenantPropagationStrategy;
diff --git a/release_notes.md b/release_notes.md
index bc140eba2..3e7be64eb 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -14,6 +14,16 @@
### ✨ New Functionality
- [OpenAPI] Cloud SDK OpenAPI Generator now supports `apache-httpclient` library besides Spring RestTemplate through the newly introduced module `openapi-core-apache`.
+- [Connectivity HttpClient5] _(Experimental)_ Added opt-in API for caching HTTP connection pool managers to reduce memory consumption.
+ Connection pool managers can consume ~100KB each, and this feature allows sharing them based on configurable caching strategies:
+ ```java
+ ApacheHttpClient5Factory factory = new ApacheHttpClient5FactoryBuilder()
+ .connectionPoolManagerProvider(ConnectionPoolManagerProviders.noCache()) // new API (default behavior)
+ .connectionPoolManagerProvider(ConnectionPoolManagerProviders.cached().byOnBehalfOf()) // new API
+ .build();
+ ```
+ Available caching strategies include `byCurrentTenant()`, `byDestinationName()`, `byOnBehalfOf()`, and custom key extractors via `by(Function)`.
+ The `byOnBehalfOf()` strategy intelligently determines tenant isolation requirements based on the destination's `OnBehalfOf` indication.
### 📈 Improvements