From f990e9e9b81de040a1b439eae1f9b2940fc3054c Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 20 Feb 2026 16:10:26 -0800 Subject: [PATCH 1/6] dns cache --- .../launchdarkly/sdk/android/CachingDns.java | 91 ++++++++++++ .../sdk/android/ComponentsImpl.java | 36 ++++- .../sdk/android/StreamingDataSource.java | 13 +- .../sdk/android/CachingDnsTest.java | 134 ++++++++++++++++++ 4 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/CachingDns.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/CachingDnsTest.java 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..1f0a3517 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/CachingDns.java @@ -0,0 +1,91 @@ +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.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. + *

+ * On API 34+ Android provides native stale-DNS support via + * {@code DnsOptions.StaleDnsOptions}, so this class is only used on older + * platform versions. See {@code ComponentsImpl.StreamingDataSourceBuilderImpl} + * for the conditional wiring. + *

+ * 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 + + 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 = 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); + cache.put(hostname, new CacheEntry(addresses, now + ttlMs)); + return addresses; + } catch (UnknownHostException e) { + if (entry != null) { + logger.warn( + "DNS lookup failed for {}, falling back to cached address (age {}ms)", + hostname, now - (entry.expiresAtMs - ttlMs) + ); + return entry.addresses; + } + throw e; + } + } + + @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..51fae861 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 @@ -25,9 +25,15 @@ import com.launchdarkly.sdk.internal.events.Event; import com.launchdarkly.sdk.internal.events.EventsConfiguration; +import android.os.Build; + 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 +328,32 @@ 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) { + // API 34+ has native stale-DNS support in the platform resolver, + // so we only need our CachingDns fallback on older versions. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return Dns.SYSTEM; + } + 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 +374,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..6a2e4735 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/CachingDnsTest.java @@ -0,0 +1,134 @@ +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 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)); + } +} From 1cd1904f6fb2bde6dd3712907ec18dab03f8f269 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 20 Feb 2026 16:19:13 -0800 Subject: [PATCH 2/6] cap cache --- .../launchdarkly/sdk/android/CachingDns.java | 14 +++++++ .../sdk/android/CachingDnsTest.java | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+) 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 index 1f0a3517..027ed79f 100644 --- 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 @@ -29,6 +29,8 @@ 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; @@ -71,6 +73,9 @@ public List lookup(String hostname) throws UnknownHostException { try { List addresses = delegate.lookup(hostname); cache.put(hostname, new CacheEntry(addresses, now + ttlMs)); + if (cache.size() > MAX_ENTRIES) { + evictExpired(now); + } return addresses; } catch (UnknownHostException e) { if (entry != null) { @@ -84,6 +89,15 @@ public List lookup(String hostname) throws UnknownHostException { } } + private void evictExpired(long now) { + cache.entrySet().removeIf(e -> e.getValue().isExpired(now)); + } + + @VisibleForTesting + int cacheSize() { + return cache.size(); + } + @VisibleForTesting CacheEntry getCacheEntry(String hostname) { return cache.get(hostname); 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 index 6a2e4735..3fe26e02 100644 --- 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 @@ -119,6 +119,43 @@ public void cachesPerHostname() throws Exception { 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(); From ebd37ab678f64878bcf24077d8f4cf6e06846039 Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 20 Feb 2026 16:20:48 -0800 Subject: [PATCH 3/6] fix modifiable issue --- .../main/java/com/launchdarkly/sdk/android/CachingDns.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index 027ed79f..2b726a6b 100644 --- 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 @@ -6,6 +6,8 @@ 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; @@ -42,7 +44,7 @@ static final class CacheEntry { final long expiresAtMs; CacheEntry(List addresses, long expiresAtMs) { - this.addresses = addresses; + this.addresses = Collections.unmodifiableList(new ArrayList<>(addresses)); this.expiresAtMs = expiresAtMs; } From 4c6560132c71ccceb129841f5b84c0f54c8ced3d Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 20 Feb 2026 16:26:09 -0800 Subject: [PATCH 4/6] Make cache for all --- .../java/com/launchdarkly/sdk/android/CachingDns.java | 8 ++++---- .../java/com/launchdarkly/sdk/android/ComponentsImpl.java | 7 ------- 2 files changed, 4 insertions(+), 11 deletions(-) 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 index 2b726a6b..401dd49d 100644 --- 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 @@ -18,10 +18,10 @@ * entries when a fresh resolution fails. This is particularly useful on mobile * networks where DNS can be unreliable during network transitions. *

- * On API 34+ Android provides native stale-DNS support via - * {@code DnsOptions.StaleDnsOptions}, so this class is only used on older - * platform versions. See {@code ComponentsImpl.StreamingDataSourceBuilderImpl} - * for the conditional wiring. + * 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 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 51fae861..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 @@ -25,8 +25,6 @@ import com.launchdarkly.sdk.internal.events.Event; import com.launchdarkly.sdk.internal.events.EventsConfiguration; -import android.os.Build; - import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -336,11 +334,6 @@ static final class StreamingDataSourceBuilderImpl extends StreamingDataSourceBui new ConnectionPool(1, 5, TimeUnit.MINUTES); private Dns getOrCreateDns(LDLogger logger) { - // API 34+ has native stale-DNS support in the platform resolver, - // so we only need our CachingDns fallback on older versions. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - return Dns.SYSTEM; - } CachingDns dns = sharedDns; if (dns == null) { synchronized (this) { From 443c6b3c1a2281cea6693d70af64bf3e63dc06dc Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 20 Feb 2026 16:29:39 -0800 Subject: [PATCH 5/6] fix eviction logic --- .../java/com/launchdarkly/sdk/android/CachingDns.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 401dd49d..2e981b99 100644 --- 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 @@ -74,16 +74,17 @@ public List lookup(String hostname) throws UnknownHostException { try { List addresses = delegate.lookup(hostname); - cache.put(hostname, new CacheEntry(addresses, now + ttlMs)); - if (cache.size() > MAX_ENTRIES) { - evictExpired(now); + 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, now - (entry.expiresAtMs - ttlMs) + hostname, System.currentTimeMillis() - (entry.expiresAtMs - ttlMs) ); return entry.addresses; } From 0a6a23e13e6c627d45033ab8dd0f2086b6b1770a Mon Sep 17 00:00:00 2001 From: Andrey Belonogov Date: Fri, 20 Feb 2026 16:38:31 -0800 Subject: [PATCH 6/6] Refactor eviction logic in CachingDns to use an explicit iterator for improved clarity and performance. --- .../main/java/com/launchdarkly/sdk/android/CachingDns.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index 2e981b99..e7a0af0a 100644 --- 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 @@ -93,7 +93,12 @@ public List lookup(String hostname) throws UnknownHostException { } private void evictExpired(long now) { - cache.entrySet().removeIf(e -> e.getValue().isExpired(now)); + java.util.Iterator> it = cache.entrySet().iterator(); + while (it.hasNext()) { + if (it.next().getValue().isExpired(now)) { + it.remove(); + } + } } @VisibleForTesting