diff --git a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java index 50357b0..e172f26 100644 --- a/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java +++ b/src/main/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProvider.java @@ -81,8 +81,19 @@ public void startPollingForDefinitions() { return t; }); + // Wrap in a Throwable-catching runnable: scheduleAtFixedRate + // permanently cancels future executions if a tick throws, and + // fetchDefinitions only catches Exception — so an Error + // (OOM, StackOverflowError, LinkageError, etc.) would silently + // kill polling for the lifetime of the JVM. pollingExecutor.scheduleAtFixedRate( - this::fetchDefinitions, + () -> { + try { + fetchDefinitions(); + } catch (Throwable t) { + logger.log(Level.SEVERE, "Uncaught throwable in flag-definitions poll; continuing", t); + } + }, config.getPollingIntervalSeconds(), config.getPollingIntervalSeconds(), TimeUnit.SECONDS diff --git a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java index 44bae7d..c9f693c 100644 --- a/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java +++ b/src/test/java/com/mixpanel/mixpanelapi/featureflags/provider/LocalFlagsProviderTest.java @@ -11,6 +11,8 @@ import java.io.IOException; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static com.mixpanel.mixpanelapi.featureflags.provider.TestUtils.*; import static org.junit.Assert.*; @@ -998,6 +1000,55 @@ public void testUseMostRecentPolledFlagDefinitions() throws Exception { provider.stopPollingForDefinitions(); } + @Test + public void testPollingSurvivesThrowableFromFetch() throws Exception { + config = LocalFlagsConfig.builder() + .projectToken(TEST_TOKEN) + .enablePolling(true) + .pollingIntervalSeconds(1) + .build(); + + AtomicInteger callCount = new AtomicInteger(0); + AtomicReference nextThrow = new AtomicReference<>(); + String validResponse = buildFlagsResponse( + flagKey, distinctIdContextKey, + Arrays.asList(new Variant("variant", "value", false, 1.0f)), + Arrays.asList(new Rollout(1.0f)), + null + ); + + LocalFlagsProvider throwingProvider = new LocalFlagsProvider(config, SDK_VERSION, eventSender) { + @Override + protected String httpGet(String urlString) { + callCount.incrementAndGet(); + Throwable t = nextThrow.getAndSet(null); + if (t instanceof Error) throw (Error) t; + if (t instanceof RuntimeException) throw (RuntimeException) t; + return validResponse; + } + }; + + try { + // Initial fetch succeeds (call 1). + throwingProvider.startPollingForDefinitions(); + assertEquals(1, callCount.get()); + + // Arm an Error to escape the next fetch. + nextThrow.set(new Error("simulated JVM-level failure")); + // 1s polling interval → wait long enough for two more ticks. + Thread.sleep(2500); + + // Before the fix, scheduleAtFixedRate would have permanently + // cancelled the task on the Error and callCount would stop at 2. + assertTrue( + "Polling should continue after a Throwable: callCount=" + callCount.get(), + callCount.get() >= 3 + ); + } finally { + throwingProvider.stopPollingForDefinitions(); + } + } + // #endregion // #region getAllVariantsByFlag Tests