+ * {@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.XX.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..4f39189e5
--- /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.XX.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..c7c940acc
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProviders.java
@@ -0,0 +1,269 @@
+package com.sap.cloud.sdk.cloudplatform.connectivity;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.net.ssl.HostnameVerifier;
+import javax.net.ssl.SSLContext;
+
+import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
+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.google.common.annotations.Beta;
+import com.sap.cloud.sdk.cloudplatform.connectivity.exception.HttpClientInstantiationException;
+import com.sap.cloud.sdk.cloudplatform.tenant.TenantAccessor;
+import com.sap.cloud.sdk.cloudplatform.util.StringUtils;
+
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+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.XX.0
+ */
+@Beta
+@Slf4j
+@NoArgsConstructor( access = AccessLevel.PRIVATE )
+public final class ConnectionPoolManagerProviders
+{
+ // Constant keys for cache entries
+ private static final String GLOBAL_KEY = "__GLOBAL__";
+ private static final String NULL_KEY = "__NULL__";
+
+ /**
+ * 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 request.
+ */
+ @Nonnull
+ public static ConnectionPoolManagerProvider noCache()
+ {
+ return ConnectionPoolManagerProviders::createConnectionManager;
+ }
+
+ /**
+ * 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 shared "no-tenant" connection manager is used.
+ *
+ *
+ * @return A provider that caches connection managers by tenant.
+ * @see TenantAccessor#tryGetCurrentTenant()
+ */
+ @Nonnull
+ public static ConnectionPoolManagerProvider byTenant()
+ {
+ return withCacheKey(dest -> TenantAccessor.tryGetCurrentTenant().map(Tenant::getTenantId).getOrNull());
+ }
+
+ /**
+ * Creates a provider that caches connection managers by destination name.
+ *
+ * Connection managers are shared among all 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 shared "unnamed" connection manager is used.
+ *
+ *
+ * @return A provider that caches connection managers by destination name.
+ */
+ @Nonnull
+ public static ConnectionPoolManagerProvider byDestinationName()
+ {
+ return withCacheKey(dest -> dest != null ? dest.get(DestinationProperty.NAME).getOrNull() : null);
+ }
+
+ /**
+ * Creates a provider that uses a single global connection manager for all destinations.
+ *
+ * This provides the lowest memory consumption but no isolation between tenants or destinations. Use this only when
+ * all destinations have compatible TLS configurations and isolation is not required.
+ *
+ *
+ * Warning: This strategy does not support destination-specific TLS configurations. All
+ * destinations will use the default TLS settings.
+ *
+ *
+ * @return A provider that uses a single global connection manager.
+ */
+ @Nonnull
+ public static ConnectionPoolManagerProvider global()
+ {
+ return withCacheKey(dest -> GLOBAL_KEY);
+ }
+
+ /**
+ * Creates a provider that caches connection managers using a custom cache key extractor.
+ *
+ * The cache key extractor function is called for each request to determine which cached connection manager to use.
+ * Requests 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 shared "null-key" bucket.
+ * @return A provider that caches connection managers using the custom key extractor.
+ */
+ @Nonnull
+ public static ConnectionPoolManagerProvider withCacheKey(
+ @Nonnull final Function cacheKeyExtractor )
+ {
+ Objects.requireNonNull(cacheKeyExtractor, "Cache key extractor must not be null");
+ return new CachingProvider(cacheKeyExtractor);
+ }
+
+ /**
+ * Provider that caches connection managers using a custom key extractor.
+ */
+ private static final class CachingProvider implements ConnectionPoolManagerProvider
+ {
+ private final Function cacheKeyExtractor;
+ private final ConcurrentMap
+ *
+ * @return A new instance with default settings.
+ */
+ @Nonnull
+ public static DefaultConnectionPoolSettings ofDefaults()
+ {
+ return builder().build();
+ }
+}
\ No newline at end of file
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..f2b1c2582
--- /dev/null
+++ b/cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/ConnectionPoolManagerProvidersTest.java
@@ -0,0 +1,183 @@
+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.net.URI;
+
+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;
+
+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 testGlobalReturnsSameManagerForAllCalls()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.global();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ final HttpDestinationProperties destination =
+ DefaultHttpDestination.builder(URI.create("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 testByDestinationNameCachesByName()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byDestinationName();
+
+ final HttpDestinationProperties dest1 =
+ DefaultHttpDestination.builder(URI.create("http://example1.com")).name("dest-a").build();
+ final HttpDestinationProperties dest2 =
+ DefaultHttpDestination.builder(URI.create("http://example2.com")).name("dest-a").build();
+ final HttpDestinationProperties dest3 =
+ DefaultHttpDestination.builder(URI.create("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 testByDestinationNameHandlesNullDestination()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byDestinationName();
+
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isSameAs(manager2);
+ }
+
+ @Test
+ void testByDestinationNameHandlesUnnamedDestination()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byDestinationName();
+
+ final HttpDestinationProperties unnamedDest =
+ DefaultHttpDestination.builder(URI.create("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).isSameAs(manager2);
+ }
+
+ @Test
+ void testByTenantCachesByTenant()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byTenant();
+
+ 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 testByTenantHandlesNoTenant()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byTenant();
+
+ // Without tenant context
+ final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+ final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
+
+ assertThat(manager1).isNotNull();
+ assertThat(manager1).isSameAs(manager2);
+ }
+
+ @Test
+ void testWithCacheKeyCustomExtractor()
+ {
+ // Custom extractor that uses the URI host as cache key
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.withCacheKey(dest -> {
+ if( dest == null ) {
+ return "no-destination";
+ }
+ return dest.getUri().getHost();
+ });
+
+ final HttpDestinationProperties dest1 =
+ DefaultHttpDestination.builder(URI.create("http://host-a.com/path1")).build();
+ final HttpDestinationProperties dest2 =
+ DefaultHttpDestination.builder(URI.create("http://host-a.com/path2")).build();
+ final HttpDestinationProperties dest3 =
+ DefaultHttpDestination.builder(URI.create("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 testNullCacheKeyExtractorThrowsException()
+ {
+ assertThatNullPointerException()
+ .isThrownBy(() -> ConnectionPoolManagerProviders.withCacheKey(null))
+ .withMessageContaining("Cache key extractor 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();
+ }
+}
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);
From 4e7f1ed1dcfa72c8daa598c1d2c96fb6fa27f28c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 18:04:11 +0100
Subject: [PATCH 2/8] Improve APi
---
.../ConnectionPoolManagerProviders.java | 259 +++++++++++-------
.../ConnectionPoolManagerProvidersTest.java | 115 ++++++--
2 files changed, 256 insertions(+), 118 deletions(-)
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
index c7c940acc..95f3358d8 100644
--- 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
@@ -2,9 +2,10 @@
import java.io.IOException;
import java.security.GeneralSecurityException;
+import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
+import java.util.function.BiFunction;
import java.util.function.Function;
import javax.annotation.Nonnull;
@@ -12,7 +13,8 @@
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
-import com.sap.cloud.sdk.cloudplatform.tenant.Tenant;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
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;
@@ -25,11 +27,13 @@
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;
/**
@@ -45,24 +49,33 @@
*
*
* {@code
- * // Cache connection managers by tenant
+ * // No caching (default behavior)
+ * ApacheHttpClient5Factory factory =
+ * new ApacheHttpClient5FactoryBuilder()
+ * .connectionPoolManagerProvider(ConnectionPoolManagerProviders.noCache())
+ * .build();
+ *
+ * // Cache connection managers by tenant using default ConcurrentHashMap
* ApacheHttpClient5Factory factory =
* new ApacheHttpClient5FactoryBuilder()
- * .connectionPoolManagerProvider(ConnectionPoolManagerProviders.byTenant())
+ * .connectionPoolManagerProvider(ConnectionPoolManagerProviders.cached().byTenant())
* .build();
*
- * // Use a single global connection manager
+ * // Cache with custom ConcurrentMap
+ * ConcurrentMap
@@ -77,11 +90,9 @@
@NoArgsConstructor( access = AccessLevel.PRIVATE )
public final class ConnectionPoolManagerProviders
{
- // Constant keys for cache entries
- private static final String GLOBAL_KEY = "__GLOBAL__";
- private static final String NULL_KEY = "__NULL__";
+ 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
@@ -97,115 +108,175 @@ public static ConnectionPoolManagerProvider noCache()
}
/**
- * 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.
- *
+ * Creates a builder for cached connection pool manager providers using a default {@link ConcurrentHashMap} as the
+ * cache.
*
- * If no tenant is available in the current context, a shared "no-tenant" connection manager is used.
+ * The returned builder allows selecting a caching strategy (by tenant, by destination name, global, or custom).
*
*
- * @return A provider that caches connection managers by tenant.
- * @see TenantAccessor#tryGetCurrentTenant()
- */
- @Nonnull
- public static ConnectionPoolManagerProvider byTenant()
- {
- return withCacheKey(dest -> TenantAccessor.tryGetCurrentTenant().map(Tenant::getTenantId).getOrNull());
- }
-
- /**
- * Creates a provider that caches connection managers by destination name.
- *
- * Connection managers are shared among all 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 shared "unnamed" connection manager is used.
- *
+ *
Example Usage
+ *
+ *
+ * {@code
+ * // Cache by tenant
+ * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byTenant();
+ *
+ * // Cache by destination name
+ * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
*
- * @return A provider that caches connection managers by destination name.
+ * // Single global cache
+ * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().global();
+ *
+ * // Custom cache key
+ * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached()
+ * .withCacheKey(dest -> dest.getUri().getHost());
+ * }
+ *
+ *
+ * @return A builder for configuring the caching strategy.
*/
@Nonnull
- public static ConnectionPoolManagerProvider byDestinationName()
+ public static CachedProviderBuilder cached()
{
- return withCacheKey(dest -> dest != null ? dest.get(DestinationProperty.NAME).getOrNull() : null);
+ final Cache cache = Caffeine.newBuilder().expireAfterAccess(DEFAULT_CACHE_DURATION).build();
+ return new CachedProviderBuilder(cache::get);
}
/**
- * Creates a provider that uses a single global connection manager for all destinations.
+ * Creates a builder for cached connection pool manager providers using a custom cache function.
*
- * This provides the lowest memory consumption but no isolation between tenants or destinations. Use this only when
- * all destinations have compatible TLS configurations and isolation is not required.
- *
- *
- * Warning: This strategy does not support destination-specific TLS configurations. All
- * destinations will use the default TLS settings.
+ * The cache function must have the signature {@code (key, loader) -> value}, which is compatible with:
*
+ *
+ * @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 ConnectionPoolManagerProvider global()
+ public static CachedProviderBuilder cached(
+ @Nonnull final BiFunction, HttpClientConnectionManager> cacheFunction )
{
- return withCacheKey(dest -> GLOBAL_KEY);
+ Objects.requireNonNull(cacheFunction, "Cache function must not be null");
+ return new CachedProviderBuilder(cacheFunction);
}
/**
- * Creates a provider that caches connection managers using a custom cache key extractor.
+ * Builder class for creating cached {@link ConnectionPoolManagerProvider} instances with various caching
+ * strategies.
*
- * The cache key extractor function is called for each request to determine which cached connection manager to use.
- * Requests 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.
+ * Use {@link ConnectionPoolManagerProviders#cached()} or
+ * {@link ConnectionPoolManagerProviders#cached(BiFunction)} to obtain an instance of this builder.
*
*
- * @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 shared "null-key" bucket.
- * @return A provider that caches connection managers using the custom key extractor.
- */
- @Nonnull
- public static ConnectionPoolManagerProvider withCacheKey(
- @Nonnull final Function cacheKeyExtractor )
- {
- Objects.requireNonNull(cacheKeyExtractor, "Cache key extractor must not be null");
- return new CachingProvider(cacheKeyExtractor);
- }
-
- /**
- * Provider that caches connection managers using a custom key extractor.
+ * @since 5.XX.0
*/
- private static final class CachingProvider implements ConnectionPoolManagerProvider
+ @Beta
+ @RequiredArgsConstructor( access = AccessLevel.PRIVATE )
+ public static final class CachedProviderBuilder
{
- private final Function cacheKeyExtractor;
- private final ConcurrentMap cache = new ConcurrentHashMap<>();
+ @Nonnull
+ private final BiFunction, HttpClientConnectionManager> cacheFunction;
- CachingProvider( final Function cacheKeyExtractor )
+ /**
+ * 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()
{
- this.cacheKeyExtractor = cacheKeyExtractor;
+ 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 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
- @Override
- public HttpClientConnectionManager getConnectionManager(
- @Nonnull final ConnectionPoolSettings settings,
- @Nullable final HttpDestinationProperties destination )
- throws HttpClientInstantiationException
+ public ConnectionPoolManagerProvider byDestinationName()
{
- final Object rawKey = cacheKeyExtractor.apply(destination);
- final Object cacheKey = rawKey != null ? rawKey : NULL_KEY;
+ return by(dest -> dest != null ? dest.get(DestinationProperty.NAME).getOrNull() : null);
+ }
- return cache.computeIfAbsent(cacheKey, key -> {
- log.debug("Creating new connection manager for cache key: {}", rawKey);
- return createConnectionManager(settings, destination);
- });
+ /**
+ * Creates a provider that caches connection managers using a custom cache key extractor.
+ *
+ * The cache key extractor function is called for each request to determine which cached connection manager to
+ * use. Requests 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 connection manager due to missing cache key for destination: {}", destination);
+ return createConnectionManager(settings, destination);
+ }
+ return cacheFunction.apply(rawKey, key -> {
+ log.debug("Creating new connection manager for cache key: {}", rawKey);
+ return createConnectionManager(settings, destination);
+ });
+ };
}
}
@@ -213,7 +284,7 @@ public HttpClientConnectionManager getConnectionManager(
* Creates a new connection manager with the given settings and destination-specific TLS configuration.
*/
@Nonnull
- private static HttpClientConnectionManager createConnectionManager(
+ static HttpClientConnectionManager createConnectionManager(
@Nonnull final ConnectionPoolSettings settings,
@Nullable final HttpDestinationProperties destination )
throws HttpClientInstantiationException
@@ -266,4 +337,4 @@ private static boolean supportsTls( @Nullable final HttpDestinationProperties de
final String scheme = destination.getUri().getScheme();
return "https".equalsIgnoreCase(scheme) || StringUtils.isEmpty(scheme);
}
-}
+}
\ No newline at end of file
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
index f2b1c2582..56c2db8ce 100644
--- 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
@@ -4,6 +4,11 @@
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
import java.net.URI;
+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 org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.junit.jupiter.api.Test;
@@ -29,9 +34,9 @@ void testNoCacheCreatesNewManagerEachTime()
}
@Test
- void testGlobalReturnsSameManagerForAllCalls()
+ void testCachedGlobalReturnsSameManagerForAllCalls()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.global();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().by(destination -> true);
final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
@@ -46,9 +51,9 @@ void testGlobalReturnsSameManagerForAllCalls()
}
@Test
- void testByDestinationNameCachesByName()
+ void testCachedByDestinationNameCachesByName()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byDestinationName();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
final HttpDestinationProperties dest1 =
DefaultHttpDestination.builder(URI.create("http://example1.com")).name("dest-a").build();
@@ -67,21 +72,21 @@ void testByDestinationNameCachesByName()
}
@Test
- void testByDestinationNameHandlesNullDestination()
+ void testCachedByDestinationNameHandlesNullDestination()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byDestinationName();
+ 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).isSameAs(manager2);
+ assertThat(manager1).isNotSameAs(manager2);
}
@Test
- void testByDestinationNameHandlesUnnamedDestination()
+ void testCachedByDestinationNameHandlesUnnamedDestination()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byDestinationName();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
final HttpDestinationProperties unnamedDest =
DefaultHttpDestination.builder(URI.create("http://example.com")).build();
@@ -91,13 +96,13 @@ void testByDestinationNameHandlesUnnamedDestination()
// Both should use the same "null key" bucket
assertThat(manager1).isNotNull();
- assertThat(manager1).isSameAs(manager2);
+ assertThat(manager1).isNotSameAs(manager2);
}
@Test
- void testByTenantCachesByTenant()
+ void testCachedByTenantCachesByTenant()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byTenant();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byCurrentTenant();
final HttpClientConnectionManager managerTenant1 =
TenantAccessor
@@ -123,28 +128,29 @@ void testByTenantCachesByTenant()
}
@Test
- void testByTenantHandlesNoTenant()
+ void testCachedByTenantHandlesNoTenant()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.byTenant();
+ 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).isSameAs(manager2);
+ assertThat(manager1).isNotSameAs(manager2);
}
@Test
- void testWithCacheKeyCustomExtractor()
+ void testCachedWithCacheKeyCustomExtractor()
{
// Custom extractor that uses the URI host as cache key
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.withCacheKey(dest -> {
- if( dest == null ) {
- return "no-destination";
- }
- return dest.getUri().getHost();
- });
+ final ConnectionPoolManagerProvider provider =
+ ConnectionPoolManagerProviders.cached().by(dest -> {
+ if( dest == null ) {
+ return "no-destination";
+ }
+ return dest.getUri().getHost();
+ });
final HttpDestinationProperties dest1 =
DefaultHttpDestination.builder(URI.create("http://host-a.com/path1")).build();
@@ -162,14 +168,75 @@ void testWithCacheKeyCustomExtractor()
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(URI.create("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.withCacheKey(null))
+ .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()
{
@@ -180,4 +247,4 @@ void testFunctionalInterfaceCanBeUsedWithLambda()
final HttpClientConnectionManager manager = lambdaProvider.getConnectionManager(DEFAULT_SETTINGS, null);
assertThat(manager).isNotNull();
}
-}
+}
\ No newline at end of file
From e5753555417b3c1b00a62ef73d9058eb782ac9da Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 19:07:24 +0100
Subject: [PATCH 3/8] Finalize solution for behalf indicated caching
---
.../connectivity/IsOnBehalfOf.java | 22 +++
.../connectivity/AbstractHttpClientCache.java | 7 +-
.../ApacheHttpClient5FactoryBuilder.java | 4 +-
.../ConnectionPoolManagerProvider.java | 2 +-
.../ConnectionPoolManagerProviders.java | 80 +++++---
.../connectivity/ConnectionPoolSettings.java | 4 +-
.../DefaultConnectionPoolSettings.java | 16 +-
.../ConnectionPoolManagerProvidersTest.java | 186 +++++++++++++++---
.../connectivity/OAuth2HeaderProvider.java | 11 +-
.../connectivity/OAuth2Service.java | 3 +-
10 files changed, 266 insertions(+), 69 deletions(-)
create mode 100644 cloudplatform/cloudplatform-connectivity/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/IsOnBehalfOf.java
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 616c356f0..583dbcc17 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
@@ -44,7 +44,7 @@ public class ApacheHttpClient5FactoryBuilder
*
* @see DefaultConnectionPoolSettings#ofDefaults()
* @see DefaultConnectionPoolSettings#builder()
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Setter( onMethod_ = @Beta )
@Nonnull
@@ -82,7 +82,7 @@ public class ApacheHttpClient5FactoryBuilder
*
* @see ConnectionPoolManagerProvider
* @see ConnectionPoolManagerProviders
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Setter( onMethod_ = @Beta )
@Nonnull
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
index 4f39189e5..451b9c1ee 100644
--- 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
@@ -36,7 +36,7 @@
*
* @see ConnectionPoolManagerProviders
* @see ApacheHttpClient5FactoryBuilder#connectionPoolManagerProvider(ConnectionPoolManagerProvider)
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Beta
@FunctionalInterface
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
index 95f3358d8..09a93bd92 100644
--- 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
@@ -3,6 +3,7 @@
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;
@@ -36,6 +37,9 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.NAMED_USER_CURRENT_TENANT;
+import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT;
+
/**
* Factory class providing pre-built {@link ConnectionPoolManagerProvider} implementations with various caching
* strategies.
@@ -69,10 +73,8 @@
* .build();
*
* // Cache with Caffeine cache (supports expiration, size limits, etc.)
- * Cache caffeineCache = Caffeine.newBuilder()
- * .expireAfterAccess(Duration.ofMinutes(30))
- * .maximumSize(100)
- * .build();
+ * Cache caffeineCache =
+ * Caffeine.newBuilder().expireAfterAccess(Duration.ofMinutes(30)).maximumSize(100).build();
* ApacheHttpClient5Factory factory =
* new ApacheHttpClient5FactoryBuilder()
* .connectionPoolManagerProvider(ConnectionPoolManagerProviders.cached(caffeineCache::get).byDestinationName())
@@ -83,7 +85,7 @@
* @see ConnectionPoolManagerProvider
* @see ConnectionPoolSettings
* @see ApacheHttpClient5FactoryBuilder#connectionPoolManagerProvider(ConnectionPoolManagerProvider)
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Beta
@Slf4j
@@ -92,7 +94,7 @@ 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
@@ -128,8 +130,8 @@ public static ConnectionPoolManagerProvider noCache()
* ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().global();
*
* // Custom cache key
- * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached()
- * .withCacheKey(dest -> dest.getUri().getHost());
+ * ConnectionPoolManagerProvider provider =
+ * ConnectionPoolManagerProviders.cached().withCacheKey(dest -> dest.getUri().getHost());
* }
*
*
@@ -138,8 +140,9 @@ public static ConnectionPoolManagerProvider noCache()
@Nonnull
public static CachedProviderBuilder cached()
{
- final Cache cache = Caffeine.newBuilder().expireAfterAccess(DEFAULT_CACHE_DURATION).build();
- return new CachedProviderBuilder(cache::get);
+ final Cache cache =
+ Caffeine.newBuilder().expireAfterAccess(DEFAULT_CACHE_DURATION).build();
+ return new CachedProviderBuilder(cache::get);
}
/**
@@ -160,18 +163,13 @@ public static CachedProviderBuilder cached()
* {@code
* // Using a custom ConcurrentMap
* ConcurrentMap myCache = new ConcurrentHashMap<>();
- * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders
- * .cached(myCache::computeIfAbsent)
- * .byTenant();
+ * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached(myCache::computeIfAbsent).byTenant();
*
* // Using Caffeine cache with expiration
- * Cache caffeineCache = Caffeine.newBuilder()
- * .expireAfterAccess(Duration.ofMinutes(30))
- * .maximumSize(100)
- * .build();
- * ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders
- * .cached(caffeineCache::get)
- * .byDestinationName();
+ * Cache caffeineCache =
+ * Caffeine.newBuilder().expireAfterAccess(Duration.ofMinutes(30)).maximumSize(100).build();
+ * ConnectionPoolManagerProvider provider =
+ * ConnectionPoolManagerProviders.cached(caffeineCache::get).byDestinationName();
* }
*
*
@@ -192,11 +190,11 @@ public static CachedProviderBuilder cached(
* 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.
+ * Use {@link ConnectionPoolManagerProviders#cached()} or {@link ConnectionPoolManagerProviders#cached(BiFunction)}
+ * to obtain an instance of this builder.
*
*
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Beta
@RequiredArgsConstructor( access = AccessLevel.PRIVATE )
@@ -242,6 +240,31 @@ public ConnectionPoolManagerProvider byDestinationName()
return by(dest -> dest != null ? dest.get(DestinationProperty.NAME).getOrNull() : null);
}
+ @Nonnull
+ public ConnectionPoolManagerProvider byIndicatedBehalfOf()
+ {
+ return by(destination -> {
+ if( !(destination instanceof final DefaultHttpDestination dest) ) {
+ return null;
+ }
+ final boolean indicatesCurrentTenant =
+ dest
+ .getCustomHeaderProviders()
+ .stream()
+ .filter(IsOnBehalfOf.class::isInstance)
+ .map(d -> ((IsOnBehalfOf) d).getOnBehalfOf())
+ .anyMatch(b -> b == NAMED_USER_CURRENT_TENANT || b == TECHNICAL_USER_CURRENT_TENANT);
+
+ // If the destination indicates that it is on behalf of the current tenant, include the tenant ID in the cache key to ensure proper isolation.
+ if( indicatesCurrentTenant ) {
+ return List.of(TenantAccessor.tryGetCurrentTenant().map(Tenant::getTenantId).getOrNull(), dest);
+ }
+
+ // Otherwise, return a cache key that does not include tenant information, allowing sharing across tenants if other properties match.
+ return dest;
+ });
+ }
+
/**
* Creates a provider that caches connection managers using a custom cache key extractor.
*
@@ -268,9 +291,12 @@ public ConnectionPoolManagerProvider by(
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 connection manager due to missing cache key for destination: {}", destination);
- return createConnectionManager(settings, destination);
+ if( rawKey == null ) {
+ log
+ .debug(
+ "Creating new connection manager due to missing cache key for destination: {}",
+ destination);
+ return createConnectionManager(settings, destination);
}
return cacheFunction.apply(rawKey, key -> {
log.debug("Creating new connection manager for cache key: {}", rawKey);
@@ -337,4 +363,4 @@ private static boolean supportsTls( @Nullable final HttpDestinationProperties de
final String scheme = destination.getUri().getScheme();
return "https".equalsIgnoreCase(scheme) || StringUtils.isEmpty(scheme);
}
-}
\ No newline at end of file
+}
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
index 57031eda7..473a5e66b 100644
--- 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
@@ -19,7 +19,7 @@
*
* @see ConnectionPoolManagerProviders
* @see DefaultConnectionPoolSettings
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Beta
public interface ConnectionPoolSettings
@@ -76,4 +76,4 @@ public interface ConnectionPoolSettings
* @return The maximum connections per route.
*/
int getMaxConnectionsPerRoute();
-}
\ No newline at end of file
+}
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
index a4711cb50..22879eca3 100644
--- 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
@@ -25,11 +25,13 @@
* ConnectionPoolSettings settings = DefaultConnectionPoolSettings.ofDefaults();
*
* // Using builder
- * ConnectionPoolSettings settings = DefaultConnectionPoolSettings.builder()
- * .connectTimeout(Duration.ofSeconds(30))
- * .socketTimeout(Duration.ofMinutes(1))
- * .maxConnectionsTotal(500)
- * .build();
+ * ConnectionPoolSettings settings =
+ * DefaultConnectionPoolSettings
+ * .builder()
+ * .connectTimeout(Duration.ofSeconds(30))
+ * .socketTimeout(Duration.ofMinutes(1))
+ * .maxConnectionsTotal(500)
+ * .build();
*
* // Using with() for copy-with-modification
* DefaultConnectionPoolSettings modified = settings.withMaxConnectionsTotal(1000);
@@ -37,7 +39,7 @@
*
*
* @see ConnectionPoolSettings
- * @since 5.XX.0
+ * @since 5.27.0
*/
@Beta
@Value
@@ -98,4 +100,4 @@ public static DefaultConnectionPoolSettings ofDefaults()
{
return builder().build();
}
-}
\ No newline at end of file
+}
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
index 56c2db8ce..71f07a91d 100644
--- 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
@@ -3,19 +3,24 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
-import java.net.URI;
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 lombok.Getter;
+import lombok.RequiredArgsConstructor;
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 javax.annotation.Nonnull;
+import java.util.Collections;
+import java.util.List;
+
class ConnectionPoolManagerProvidersTest
{
private static final ConnectionPoolSettings DEFAULT_SETTINGS = DefaultConnectionPoolSettings.ofDefaults();
@@ -41,8 +46,7 @@ void testCachedGlobalReturnsSameManagerForAllCalls()
final HttpClientConnectionManager manager1 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
final HttpClientConnectionManager manager2 = provider.getConnectionManager(DEFAULT_SETTINGS, null);
- final HttpDestinationProperties destination =
- DefaultHttpDestination.builder(URI.create("http://example.com")).build();
+ final HttpDestinationProperties destination = DefaultHttpDestination.builder("http://example.com").build();
final HttpClientConnectionManager manager3 = provider.getConnectionManager(DEFAULT_SETTINGS, destination);
assertThat(manager1).isNotNull();
@@ -56,11 +60,11 @@ void testCachedByDestinationNameCachesByName()
final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
final HttpDestinationProperties dest1 =
- DefaultHttpDestination.builder(URI.create("http://example1.com")).name("dest-a").build();
+ DefaultHttpDestination.builder("http://example1.com").name("dest-a").build();
final HttpDestinationProperties dest2 =
- DefaultHttpDestination.builder(URI.create("http://example2.com")).name("dest-a").build();
+ DefaultHttpDestination.builder("http://example2.com").name("dest-a").build();
final HttpDestinationProperties dest3 =
- DefaultHttpDestination.builder(URI.create("http://example3.com")).name("dest-b").build();
+ 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);
@@ -88,8 +92,7 @@ void testCachedByDestinationNameHandlesUnnamedDestination()
{
final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byDestinationName();
- final HttpDestinationProperties unnamedDest =
- DefaultHttpDestination.builder(URI.create("http://example.com")).build();
+ 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);
@@ -144,20 +147,16 @@ void testCachedByTenantHandlesNoTenant()
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 ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().by(dest -> {
+ if( dest == null ) {
+ return "no-destination";
+ }
+ return dest.getUri().getHost();
+ });
- final HttpDestinationProperties dest1 =
- DefaultHttpDestination.builder(URI.create("http://host-a.com/path1")).build();
- final HttpDestinationProperties dest2 =
- DefaultHttpDestination.builder(URI.create("http://host-a.com/path2")).build();
- final HttpDestinationProperties dest3 =
- DefaultHttpDestination.builder(URI.create("http://host-b.com/path1")).build();
+ 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);
@@ -178,7 +177,7 @@ void testCachedWithCustomConcurrentMap()
ConnectionPoolManagerProviders.cached(customCache::computeIfAbsent).byDestinationName();
final HttpDestinationProperties dest =
- DefaultHttpDestination.builder(URI.create("http://example.com")).name("my-dest").build();
+ 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);
@@ -247,4 +246,145 @@ void testFunctionalInterfaceCanBeUsedWithLambda()
final HttpClientConnectionManager manager = lambdaProvider.getConnectionManager(DEFAULT_SETTINGS, null);
assertThat(manager).isNotNull();
}
-}
\ No newline at end of file
+
+ @Test
+ void testCachedByIndicatedBehalfOfWithCurrentTenantHeaderProvider()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+
+ // 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 testCachedByIndicatedBehalfOfWithTechnicalUserCurrentTenant()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+
+ 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 testCachedByIndicatedBehalfOfWithProviderUserSharesAcrossTenants()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+
+ // 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 testCachedByIndicatedBehalfOfWithNoHeaderProvider()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+
+ // 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 testCachedByIndicatedBehalfOfWithNonDefaultHttpDestination()
+ {
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+
+ // 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-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..9f973064e 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
@@ -11,15 +11,24 @@
import io.vavr.control.Option;
import lombok.RequiredArgsConstructor;
+import org.jetbrains.annotations.Nullable;
@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;
From 50ef2bfe13b385f3e7a056021bf08a20e1876ce3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 19:50:53 +0100
Subject: [PATCH 4/8] Add release note; Minor comment fix
---
.../ConnectionPoolManagerProviders.java | 47 ++++++++++---------
release_notes.md | 10 ++++
2 files changed, 35 insertions(+), 22 deletions(-)
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
index 09a93bd92..4adaf1d2c 100644
--- 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
@@ -37,9 +37,6 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.NAMED_USER_CURRENT_TENANT;
-import static com.sap.cloud.sdk.cloudplatform.connectivity.OnBehalfOf.TECHNICAL_USER_CURRENT_TENANT;
-
/**
* Factory class providing pre-built {@link ConnectionPoolManagerProvider} implementations with various caching
* strategies.
@@ -101,7 +98,7 @@ public final class ConnectionPoolManagerProviders
* maximum isolation but highest memory consumption.
*
*
- * @return A provider that creates a new connection manager for each request.
+ * @return A provider that creates a new connection manager for each http-client.
*/
@Nonnull
public static ConnectionPoolManagerProvider noCache()
@@ -225,8 +222,8 @@ public ConnectionPoolManagerProvider byCurrentTenant()
/**
* Creates a provider that caches connection managers by destination name.
*
- * Connection managers are shared among all requests to destinations with the same name. This is useful when
- * different destinations may have different TLS or proxy configurations.
+ * 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.
@@ -244,32 +241,41 @@ public ConnectionPoolManagerProvider byDestinationName()
public ConnectionPoolManagerProvider byIndicatedBehalfOf()
{
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 boolean indicatesCurrentTenant =
- dest
- .getCustomHeaderProviders()
+ final List headerProviders = dest.getCustomHeaderProviders();
+ final List behalfOfIndicators =
+ headerProviders
.stream()
.filter(IsOnBehalfOf.class::isInstance)
.map(d -> ((IsOnBehalfOf) d).getOnBehalfOf())
- .anyMatch(b -> b == NAMED_USER_CURRENT_TENANT || b == TECHNICAL_USER_CURRENT_TENANT);
+ .filter(Objects::nonNull)
+ .toList();
- // If the destination indicates that it is on behalf of the current tenant, include the tenant ID in the cache key to ensure proper isolation.
- if( indicatesCurrentTenant ) {
- return List.of(TenantAccessor.tryGetCurrentTenant().map(Tenant::getTenantId).getOrNull(), dest);
+ // If no OnBehalfOf indicators are present, return null to avoid caching
+ if( behalfOfIndicators.isEmpty() && !headerProviders.isEmpty() ) {
+ return null;
}
- // Otherwise, return a cache key that does not include tenant information, allowing sharing across tenants if other properties match.
- return dest;
+ 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 request to determine which cached connection manager to
- * use. Requests that produce equal cache keys (via {@link Object#equals(Object)}) will share the same
+ * 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.
*
*
@@ -292,14 +298,11 @@ public ConnectionPoolManagerProvider by(
return ( settings, destination ) -> {
final Object rawKey = cacheKeyExtractor.apply(destination);
if( rawKey == null ) {
- log
- .debug(
- "Creating new connection manager due to missing cache key for destination: {}",
- destination);
+ log.debug("Creating new uncached connection manager for destination: {}", destination);
return createConnectionManager(settings, destination);
}
return cacheFunction.apply(rawKey, key -> {
- log.debug("Creating new connection manager for cache key: {}", rawKey);
+ log.debug("Creating new cached connection manager for key: {}", rawKey);
return createConnectionManager(settings, destination);
});
};
diff --git a/release_notes.md b/release_notes.md
index bc140eba2..f048abbc9 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().byIndicatedBehalfOf()) // new API
+ .build();
+ ```
+ Available caching strategies include `byCurrentTenant()`, `byDestinationName()`, `byIndicatedBehalfOf()`, and custom key extractors via `by(Function)`.
+ The `byIndicatedBehalfOf()` strategy intelligently determines tenant isolation requirements based on the destination's `OnBehalfOf` indication.
### 📈 Improvements
From c1157c965b47edaa1e59515ce152697b777cc910 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 19:55:45 +0100
Subject: [PATCH 5/8] Fix imports
---
.../connectivity/ApacheHttpClient5FactoryBuilder.java | 6 +++---
.../connectivity/ConnectionPoolManagerProviders.java | 4 ++--
.../ConnectionPoolManagerProvidersTest.java | 11 ++++++-----
.../connectivity/OAuth2HeaderProvider.java | 2 +-
4 files changed, 12 insertions(+), 11 deletions(-)
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 583dbcc17..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
@@ -3,14 +3,14 @@
import java.time.Duration;
import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import lombok.Setter;
-import lombok.experimental.Accessors;
import org.apache.hc.client5.http.classic.HttpClient;
import com.google.common.annotations.Beta;
+import lombok.Setter;
+import lombok.experimental.Accessors;
+
/**
* Builder class for a default implementation of the {@link ApacheHttpClient5Factory} interface.
*
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
index 4adaf1d2c..3741ba346 100644
--- 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
@@ -14,8 +14,6 @@
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
-import com.github.benmanes.caffeine.cache.Cache;
-import com.github.benmanes.caffeine.cache.Caffeine;
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;
@@ -26,6 +24,8 @@
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;
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
index 71f07a91d..b7bb506d4 100644
--- 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
@@ -3,23 +3,24 @@
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 lombok.Getter;
-import lombok.RequiredArgsConstructor;
+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 javax.annotation.Nonnull;
-import java.util.Collections;
-import java.util.List;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
class ConnectionPoolManagerProvidersTest
{
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 9f973064e..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,13 +5,13 @@
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;
import io.vavr.control.Option;
import lombok.RequiredArgsConstructor;
-import org.jetbrains.annotations.Nullable;
@RequiredArgsConstructor
class OAuth2HeaderProvider implements DestinationHeaderProvider, IsOnBehalfOf
From 710eefe9fb27cab32365b9eb4d3fa57400411594 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 19:59:02 +0100
Subject: [PATCH 6/8] Improve Naming
---
.../ConnectionPoolManagerProviders.java | 2 +-
.../ConnectionPoolManagerProvidersTest.java | 20 +++++++++----------
release_notes.md | 6 +++---
3 files changed, 14 insertions(+), 14 deletions(-)
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
index 3741ba346..debe43aa0 100644
--- 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
@@ -238,7 +238,7 @@ public ConnectionPoolManagerProvider byDestinationName()
}
@Nonnull
- public ConnectionPoolManagerProvider byIndicatedBehalfOf()
+ public ConnectionPoolManagerProvider byOnBehalfOf()
{
return by(destination -> {
// Check if the destination has any OnBehalfOf indicators in its custom header providers
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
index b7bb506d4..2e60f6414 100644
--- 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
@@ -249,9 +249,9 @@ void testFunctionalInterfaceCanBeUsedWithLambda()
}
@Test
- void testCachedByIndicatedBehalfOfWithCurrentTenantHeaderProvider()
+ void testCachedByOnBehalfOfWithCurrentTenantHeaderProvider()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
// Create a header provider that indicates NAMED_USER_CURRENT_TENANT
final DestinationHeaderProvider namedUserProvider =
@@ -286,9 +286,9 @@ void testCachedByIndicatedBehalfOfWithCurrentTenantHeaderProvider()
}
@Test
- void testCachedByIndicatedBehalfOfWithTechnicalUserCurrentTenant()
+ void testCachedByOnBehalfOfWithTechnicalUserCurrentTenant()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
final DefaultHttpDestination dest =
DefaultHttpDestination
@@ -311,9 +311,9 @@ void testCachedByIndicatedBehalfOfWithTechnicalUserCurrentTenant()
}
@Test
- void testCachedByIndicatedBehalfOfWithProviderUserSharesAcrossTenants()
+ void testCachedByOnBehalfOfWithProviderUserSharesAcrossTenants()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
// Create a header provider that indicates TECHNICAL_USER_PROVIDER (not current tenant)
final DefaultHttpDestination dest =
@@ -336,9 +336,9 @@ void testCachedByIndicatedBehalfOfWithProviderUserSharesAcrossTenants()
}
@Test
- void testCachedByIndicatedBehalfOfWithNoHeaderProvider()
+ void testCachedByOnBehalfOfWithNoHeaderProvider()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+ final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byOnBehalfOf();
// Destination without any header provider
final DefaultHttpDestination dest = DefaultHttpDestination.builder("http://example.com").build();
@@ -357,9 +357,9 @@ void testCachedByIndicatedBehalfOfWithNoHeaderProvider()
}
@Test
- void testCachedByIndicatedBehalfOfWithNonDefaultHttpDestination()
+ void testCachedByOnBehalfOfWithNonDefaultHttpDestination()
{
- final ConnectionPoolManagerProvider provider = ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf();
+ 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();
diff --git a/release_notes.md b/release_notes.md
index f048abbc9..3e7be64eb 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -19,11 +19,11 @@
```java
ApacheHttpClient5Factory factory = new ApacheHttpClient5FactoryBuilder()
.connectionPoolManagerProvider(ConnectionPoolManagerProviders.noCache()) // new API (default behavior)
- .connectionPoolManagerProvider(ConnectionPoolManagerProviders.cached().byIndicatedBehalfOf()) // new API
+ .connectionPoolManagerProvider(ConnectionPoolManagerProviders.cached().byOnBehalfOf()) // new API
.build();
```
- Available caching strategies include `byCurrentTenant()`, `byDestinationName()`, `byIndicatedBehalfOf()`, and custom key extractors via `by(Function)`.
- The `byIndicatedBehalfOf()` strategy intelligently determines tenant isolation requirements based on the destination's `OnBehalfOf` indication.
+ 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
From 5c126f117439e240c173f378757a6d1dcc1521f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 20:03:00 +0100
Subject: [PATCH 7/8] Add missing javadoc
---
.../ConnectionPoolManagerProviders.java | 13 +++++++++++++
1 file changed, 13 insertions(+)
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
index debe43aa0..7961f378f 100644
--- 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
@@ -237,6 +237,19 @@ 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()
{
From 09f5822d00a9eef8c3e3c684cd6ef4f6b84c040a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Alexander=20D=C3=BCmont?=
Date: Tue, 10 Feb 2026 20:11:54 +0100
Subject: [PATCH 8/8] Fix Checkstyle
---
.../connectivity/DefaultApacheHttpClient5Factory.java | 1 +
.../connectivity/DefaultConnectionPoolSettings.java | 10 +++++-----
2 files changed, 6 insertions(+), 5 deletions(-)
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 bd7d56789..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
@@ -55,6 +55,7 @@ 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 )
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
index 22879eca3..353adc54d 100644
--- 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
@@ -52,33 +52,33 @@ public class DefaultConnectionPoolSettings implements ConnectionPoolSettings
*/
@Nonnull
@Builder.Default
- Duration connectTimeout = ConnectionPoolSettings.DEFAULT_TIMEOUT;
+ Duration connectTimeout = DEFAULT_TIMEOUT;
/**
* The default socket timeout value for I/O operations on connections.
*/
@Nonnull
@Builder.Default
- Duration socketTimeout = ConnectionPoolSettings.DEFAULT_TIMEOUT;
+ Duration socketTimeout = DEFAULT_TIMEOUT;
/**
* The timeout when requesting a connection lease from the connection pool.
*/
@Nonnull
@Builder.Default
- Duration connectionRequestTimeout = ConnectionPoolSettings.DEFAULT_TIMEOUT;
+ Duration connectionRequestTimeout = DEFAULT_TIMEOUT;
/**
* The maximum number of total connections in the pool.
*/
@Builder.Default
- int maxConnectionsTotal = ConnectionPoolSettings.DEFAULT_MAX_CONNECTIONS_TOTAL;
+ int maxConnectionsTotal = DEFAULT_MAX_CONNECTIONS_TOTAL;
/**
* The maximum number of connections per route (e.g., per remote host).
*/
@Builder.Default
- int maxConnectionsPerRoute = ConnectionPoolSettings.DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
+ int maxConnectionsPerRoute = DEFAULT_MAX_CONNECTIONS_PER_ROUTE;
/**
* Creates a new {@link DefaultConnectionPoolSettings} with default values.