diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/CachingDns.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/CachingDns.java
new file mode 100644
index 00000000..e7a0af0a
--- /dev/null
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/CachingDns.java
@@ -0,0 +1,113 @@
+package com.launchdarkly.sdk.android;
+
+import androidx.annotation.VisibleForTesting;
+
+import com.launchdarkly.logging.LDLogger;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+
+import okhttp3.Dns;
+
+/**
+ * A DNS resolver that caches successful lookups and falls back to stale cache
+ * entries when a fresh resolution fails. This is particularly useful on mobile
+ * networks where DNS can be unreliable during network transitions.
+ *
+ * Although Android API 34+ exposes {@code DnsOptions.StaleDnsOptions} in
+ * {@code DnsResolver}, OkHttp's {@code Dns.SYSTEM} uses
+ * {@code InetAddress.getAllByName} which does not opt into that mechanism.
+ * This class is therefore used on all API levels.
+ *
+ * Instances of this class are thread-safe and designed to be shared across
+ * multiple OkHttpClient instances so that the cache persists even when the
+ * HTTP client is recreated (e.g. on EventSource reconnections).
+ */
+final class CachingDns implements Dns {
+
+ @VisibleForTesting
+ static final long DEFAULT_TTL_MS = 10 * 60 * 1000; // 10 minutes
+ @VisibleForTesting
+ static final int MAX_ENTRIES = 30;
+
+ private final Dns delegate;
+ private final long ttlMs;
+ private final LDLogger logger;
+ private final ConcurrentHashMap cache = new ConcurrentHashMap<>();
+
+ static final class CacheEntry {
+ final List addresses;
+ final long expiresAtMs;
+
+ CacheEntry(List addresses, long expiresAtMs) {
+ this.addresses = Collections.unmodifiableList(new ArrayList<>(addresses));
+ this.expiresAtMs = expiresAtMs;
+ }
+
+ boolean isExpired(long nowMs) {
+ return nowMs >= expiresAtMs;
+ }
+ }
+
+ CachingDns(Dns delegate, long ttlMs, LDLogger logger) {
+ this.delegate = delegate;
+ this.ttlMs = ttlMs;
+ this.logger = logger;
+ }
+
+ CachingDns(LDLogger logger) {
+ this(Dns.SYSTEM, DEFAULT_TTL_MS, logger);
+ }
+
+ @Override
+ public List lookup(String hostname) throws UnknownHostException {
+ long now = System.currentTimeMillis();
+ CacheEntry entry = cache.get(hostname);
+
+ if (entry != null && !entry.isExpired(now)) {
+ return entry.addresses;
+ }
+
+ try {
+ List addresses = delegate.lookup(hostname);
+ long afterLookup = System.currentTimeMillis();
+ if (cache.size() >= MAX_ENTRIES) {
+ evictExpired(afterLookup);
+ }
+ cache.put(hostname, new CacheEntry(addresses, afterLookup + ttlMs));
+ return addresses;
+ } catch (UnknownHostException e) {
+ if (entry != null) {
+ logger.warn(
+ "DNS lookup failed for {}, falling back to cached address (age {}ms)",
+ hostname, System.currentTimeMillis() - (entry.expiresAtMs - ttlMs)
+ );
+ return entry.addresses;
+ }
+ throw e;
+ }
+ }
+
+ private void evictExpired(long now) {
+ java.util.Iterator> it = cache.entrySet().iterator();
+ while (it.hasNext()) {
+ if (it.next().getValue().isExpired(now)) {
+ it.remove();
+ }
+ }
+ }
+
+ @VisibleForTesting
+ int cacheSize() {
+ return cache.size();
+ }
+
+ @VisibleForTesting
+ CacheEntry getCacheEntry(String hostname) {
+ return cache.get(hostname);
+ }
+}
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java
index 98340280..e7c743d2 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java
@@ -28,6 +28,10 @@
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import okhttp3.ConnectionPool;
+import okhttp3.Dns;
/**
* This class contains the package-private implementations of component factories and builders whose
@@ -322,6 +326,27 @@ public PollingDataSourceBuilder pollIntervalMillisNoMinimum(int pollIntervalMill
static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBuilder
implements DiagnosticDescription, DataSourceRequiresFeatureFetcher {
+
+ // Shared across StreamingDataSource instances so that DNS cache and pooled
+ // connections survive data-source restarts (context switches, network transitions).
+ private volatile CachingDns sharedDns;
+ private final ConnectionPool sharedConnectionPool =
+ new ConnectionPool(1, 5, TimeUnit.MINUTES);
+
+ private Dns getOrCreateDns(LDLogger logger) {
+ CachingDns dns = sharedDns;
+ if (dns == null) {
+ synchronized (this) {
+ dns = sharedDns;
+ if (dns == null) {
+ dns = new CachingDns(logger);
+ sharedDns = dns;
+ }
+ }
+ }
+ return dns;
+ }
+
@Override
public DataSource build(ClientContext clientContext) {
// Even though this is called StreamingDataSourceBuilder, it doesn't always create a
@@ -342,7 +367,9 @@ public DataSource build(ClientContext clientContext) {
clientContext.getDataSourceUpdateSink(),
clientContextImpl.getFetcher(),
initialReconnectDelayMillis,
- streamEvenInBackground
+ streamEvenInBackground,
+ getOrCreateDns(clientContext.getBaseLogger()),
+ sharedConnectionPool
);
}
diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java
index 1f74b601..9489b478 100644
--- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java
+++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java
@@ -24,6 +24,8 @@
import java.net.URI;
import java.util.concurrent.TimeUnit;
+import okhttp3.ConnectionPool;
+import okhttp3.Dns;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
@@ -65,6 +67,8 @@ final class StreamingDataSource implements DataSource {
private final DiagnosticStore diagnosticStore;
private long eventSourceStarted;
private final LDLogger logger;
+ private final Dns dns;
+ private final ConnectionPool connectionPool;
StreamingDataSource(
@NonNull ClientContext clientContext,
@@ -72,7 +76,9 @@ final class StreamingDataSource implements DataSource {
@NonNull DataSourceUpdateSink dataSourceUpdateSink,
@NonNull FeatureFetcher fetcher,
int initialReconnectDelayMillis,
- boolean streamEvenInBackground
+ boolean streamEvenInBackground,
+ @NonNull Dns dns,
+ @NonNull ConnectionPool connectionPool
) {
this.context = context;
this.dataSourceUpdateSink = dataSourceUpdateSink;
@@ -85,6 +91,8 @@ final class StreamingDataSource implements DataSource {
this.streamEvenInBackground = streamEvenInBackground;
this.diagnosticStore = ClientContextImpl.get(clientContext).getDiagnosticStore();
this.logger = clientContext.getBaseLogger();
+ this.dns = dns;
+ this.connectionPool = connectionPool;
}
public void start(@NonNull Callback resultCallback) {
@@ -152,6 +160,9 @@ public void onError(Throwable t) {
public void configure(OkHttpClient.Builder clientBuilder) {
httpProperties.applyToHttpClientBuilder(clientBuilder);
clientBuilder.readTimeout(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ clientBuilder.dns(dns);
+ clientBuilder.connectionPool(connectionPool);
+ clientBuilder.retryOnConnectionFailure(true);
}
});
diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/CachingDnsTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/CachingDnsTest.java
new file mode 100644
index 00000000..3fe26e02
--- /dev/null
+++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/CachingDnsTest.java
@@ -0,0 +1,171 @@
+package com.launchdarkly.sdk.android;
+
+import static org.junit.Assert.assertEquals;
+
+import com.launchdarkly.logging.LDLogger;
+
+import org.junit.Test;
+
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import okhttp3.Dns;
+
+public class CachingDnsTest {
+
+ private static final LDLogger logger = LDLogger.none();
+
+ private static Dns counting(List result, AtomicInteger counter) {
+ return hostname -> {
+ counter.incrementAndGet();
+ return result;
+ };
+ }
+
+ private static Dns failing() {
+ return hostname -> {
+ throw new UnknownHostException("simulated DNS failure for " + hostname);
+ };
+ }
+
+ @Test
+ public void returnsFreshResultFromDelegate() throws Exception {
+ InetAddress addr = InetAddress.getByName("127.0.0.1");
+ List expected = Collections.singletonList(addr);
+ AtomicInteger lookups = new AtomicInteger();
+ CachingDns dns = new CachingDns(counting(expected, lookups), 60_000, logger);
+
+ List result = dns.lookup("example.com");
+ assertEquals(expected, result);
+ assertEquals(1, lookups.get());
+ }
+
+ @Test
+ public void returnsCachedResultWithinTtl() throws Exception {
+ InetAddress addr = InetAddress.getByName("127.0.0.1");
+ List expected = Collections.singletonList(addr);
+ AtomicInteger lookups = new AtomicInteger();
+ CachingDns dns = new CachingDns(counting(expected, lookups), 60_000, logger);
+
+ dns.lookup("example.com");
+ List result = dns.lookup("example.com");
+
+ assertEquals(expected, result);
+ assertEquals(1, lookups.get());
+ }
+
+ @Test
+ public void refreshesAfterTtlExpires() throws Exception {
+ InetAddress addr = InetAddress.getByName("127.0.0.1");
+ List expected = Collections.singletonList(addr);
+ AtomicInteger lookups = new AtomicInteger();
+ CachingDns dns = new CachingDns(counting(expected, lookups), 0, logger);
+
+ dns.lookup("example.com");
+ dns.lookup("example.com");
+
+ assertEquals(2, lookups.get());
+ }
+
+ @Test
+ public void fallsBackToStaleCacheOnFailure() throws Exception {
+ InetAddress addr = InetAddress.getByName("127.0.0.1");
+ List cached = Collections.singletonList(addr);
+
+ AtomicInteger lookups = new AtomicInteger();
+ Dns flaky = hostname -> {
+ if (lookups.incrementAndGet() == 1) {
+ return cached;
+ }
+ throw new UnknownHostException("simulated failure");
+ };
+
+ CachingDns dns = new CachingDns(flaky, 0, logger);
+ dns.lookup("example.com");
+
+ List result = dns.lookup("example.com");
+ assertEquals(cached, result);
+ }
+
+ @Test(expected = UnknownHostException.class)
+ public void throwsWhenDelegateFailsAndNoCacheExists() throws Exception {
+ CachingDns dns = new CachingDns(failing(), 60_000, logger);
+ dns.lookup("no-such-host.invalid");
+ }
+
+ @Test
+ public void cachesPerHostname() throws Exception {
+ InetAddress addr1 = InetAddress.getByName("10.0.0.1");
+ InetAddress addr2 = InetAddress.getByName("10.0.0.2");
+ List list1 = Collections.singletonList(addr1);
+ List list2 = Collections.singletonList(addr2);
+
+ AtomicInteger lookups = new AtomicInteger();
+ Dns delegate = hostname -> {
+ lookups.incrementAndGet();
+ return hostname.equals("a.example.com") ? list1 : list2;
+ };
+ CachingDns dns = new CachingDns(delegate, 60_000, logger);
+
+ assertEquals(list1, dns.lookup("a.example.com"));
+ assertEquals(list2, dns.lookup("b.example.com"));
+ assertEquals(2, lookups.get());
+
+ assertEquals(list1, dns.lookup("a.example.com"));
+ assertEquals(list2, dns.lookup("b.example.com"));
+ assertEquals(2, lookups.get());
+ }
+
+ @Test
+ public void evictsExpiredEntriesWhenCacheExceedsMax() throws Exception {
+ InetAddress addr = InetAddress.getByName("127.0.0.1");
+ List addrs = Collections.singletonList(addr);
+ AtomicInteger lookups = new AtomicInteger();
+
+ // TTL of 0 means every entry expires immediately
+ CachingDns dns = new CachingDns(counting(addrs, lookups), 0, logger);
+
+ // Fill beyond MAX_ENTRIES with distinct hostnames; each previous entry
+ // is already expired by the time the next lookup runs.
+ for (int i = 0; i <= CachingDns.MAX_ENTRIES; i++) {
+ dns.lookup("host-" + i + ".example.com");
+ }
+
+ // The last put should have triggered eviction of all expired entries,
+ // leaving only the most recent (non-expired at the instant it was stored).
+ assertEquals(1, dns.cacheSize());
+ }
+
+ @Test
+ public void retainsNonExpiredEntriesAcrossEviction() throws Exception {
+ InetAddress addr = InetAddress.getByName("127.0.0.1");
+ List addrs = Collections.singletonList(addr);
+ AtomicInteger lookups = new AtomicInteger();
+
+ // Long TTL so nothing expires during the test
+ CachingDns dns = new CachingDns(counting(addrs, lookups), 600_000, logger);
+
+ for (int i = 0; i <= CachingDns.MAX_ENTRIES; i++) {
+ dns.lookup("host-" + i + ".example.com");
+ }
+
+ // Nothing is expired, so eviction can't remove anything
+ assertEquals(CachingDns.MAX_ENTRIES + 1, dns.cacheSize());
+ }
+
+ @Test
+ public void cacheEntryRecordsExpiration() {
+ InetAddress loopback = InetAddress.getLoopbackAddress();
+ List addrs = Collections.singletonList(loopback);
+
+ long now = System.currentTimeMillis();
+ CachingDns.CacheEntry entry = new CachingDns.CacheEntry(addrs, now + 5000);
+
+ assertEquals(false, entry.isExpired(now));
+ assertEquals(true, entry.isExpired(now + 5000));
+ assertEquals(true, entry.isExpired(now + 6000));
+ }
+}