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