@@ -3,8 +3,9 @@ package zio.openfeature
33import zio ._
44import zio .stream ._
55import zio .openfeature .internal .FeatureFlagsState
6- import dev .openfeature .sdk .{FeatureProvider => OFFeatureProvider , OpenFeatureAPI , OpenFeatureAPIFactory }
6+ import dev .openfeature .sdk .{FeatureProvider => OFFeatureProvider , OpenFeatureAPI , OpenFeatureAPIFactory , ProviderState }
77import dev .openfeature .sdk .multiprovider .{MultiProvider , Strategy , FirstMatchStrategy , FirstSuccessfulStrategy }
8+ import java .util .concurrent .TimeoutException
89
910trait FeatureFlags {
1011
@@ -405,6 +406,24 @@ object FeatureFlags {
405406
406407 // Factory Methods
407408
409+ /** Default cap on how long initialization may take before the layer build fails or — for async layers — transitions
410+ * to `Fatal`. Mirrors the documented default referenced in every factory's ScalaDoc.
411+ */
412+ private [openfeature] val DefaultInitTimeout : Duration = 30 .seconds
413+
414+ /** Verify the provider reached a usable state after sync initialization. Anything outside `READY` / `STALE` causes
415+ * the layer build to fail so misconfiguration surfaces at startup, not at first evaluation. Returns the ZIO-level
416+ * `ProviderStatus` that should be reflected for a usable state.
417+ */
418+ @ scala.annotation.nowarn(" msg=deprecated" )
419+ private def verifyInitState (provider : OFFeatureProvider ): ZIO [Any , Throwable , ProviderStatus ] =
420+ ZIO .attempt(provider.getState).flatMap {
421+ case ProviderState .READY => ZIO .succeed(ProviderStatus .Ready )
422+ case ProviderState .STALE => ZIO .succeed(ProviderStatus .Stale )
423+ case bad @ (ProviderState .ERROR | ProviderState .FATAL | ProviderState .NOT_READY ) =>
424+ ZIO .fail(new IllegalStateException (s " Provider in $bad state after initialization " ))
425+ }
426+
408427 /** Shared initialization logic for all factory methods. */
409428 private [openfeature] def build (
410429 provider : OFFeatureProvider ,
@@ -414,14 +433,21 @@ object FeatureFlags {
414433 statusRef : Option [Ref [ProviderStatus ]],
415434 addShutdownFinalizer : Boolean ,
416435 apiOverride : Option [OpenFeatureAPI ] = None ,
417- evaluationTimeout : Option [Duration ] = None
436+ evaluationTimeout : Option [Duration ] = None ,
437+ initTimeout : Duration = DefaultInitTimeout
418438 ): ZIO [Scope , Throwable , FeatureFlagsLive ] =
419439 for {
420440 api <- ZIO .succeed(apiOverride.getOrElse(OpenFeatureAPI .getInstance()))
421- _ <- domain match {
441+ setAndWait = domain match {
422442 case Some (d) => ZIO .attemptBlocking(api.setProviderAndWait(d, provider))
423443 case None => ZIO .attemptBlocking(api.setProviderAndWait(provider))
424444 }
445+ // Bound the blocking init. `.disconnect` ensures the timeout returns promptly even though
446+ // `attemptBlocking` runs on the blocking pool; the underlying call may still run to completion
447+ // in the background and is handled by the Java SDK / addShutdownFinalizer path.
448+ _ <- setAndWait.disconnect
449+ .timeoutFail(new TimeoutException (s " Provider initialization exceeded $initTimeout" ))(initTimeout)
450+ verified <- verifyInitState(provider)
425451 client <- (domain, version) match {
426452 case (Some (d), Some (v)) => ZIO .attempt(api.getClient(d, v))
427453 case (Some (d), None ) => ZIO .attempt(api.getClient(d))
@@ -434,7 +460,8 @@ object FeatureFlags {
434460 baseState <- FeatureFlagsState .make
435461 state = statusRef.fold(baseState)(ref => baseState.copy(statusRef = ref))
436462 _ <- state.hooksRef.set(initialHooks)
437- _ <- statusRef.fold(state.statusRef.set(ProviderStatus .Ready ))(_ => ZIO .unit)
463+ // Only seed status when the caller didn't hand us a shared ref (testkit shares one).
464+ _ <- statusRef.fold(state.statusRef.set(verified))(_ => ZIO .unit)
438465 _ <- ZIO .when(addShutdownFinalizer)(ZIO .addFinalizer(ZIO .attemptBlocking(api.shutdown()).ignore))
439466 ff = new FeatureFlagsLive (
440467 client,
@@ -450,7 +477,13 @@ object FeatureFlags {
450477 _ <- ff.startEventBridge
451478 } yield ff
452479
453- /** Create a FeatureFlags layer from any OpenFeature provider. */
480+ /** Create a FeatureFlags layer from any OpenFeature provider.
481+ *
482+ * Initialization is bounded by the default 30s init timeout: if `setProviderAndWait` takes longer, or the provider
483+ * reports `ERROR`/`FATAL`/`NOT_READY` afterwards, the layer build fails with a `TimeoutException` or
484+ * `IllegalStateException` wrapped at the layer boundary. Use the overload that accepts an explicit `initTimeout` to
485+ * raise/lower this bound; pass a very large duration (e.g. `365.days`) to effectively disable it.
486+ */
454487 def fromProvider (provider : OFFeatureProvider ): ZLayer [Scope , Throwable , FeatureFlags ] =
455488 ZLayer .scoped(
456489 build(provider, domain = None , version = None , initialHooks = Nil , statusRef = None , addShutdownFinalizer = true )
@@ -460,7 +493,8 @@ object FeatureFlags {
460493 *
461494 * If a provider evaluation takes longer than `evaluationTimeout`, it fails with `ProviderError` containing a
462495 * `TimeoutException`. This prevents hung providers from blocking fibers indefinitely. Per-call timeouts set via
463- * `EvaluationOptions.timeout` override this global default.
496+ * `EvaluationOptions.timeout` override this global default. Initialization uses the default 30s init timeout — see
497+ * [[fromProvider(OFFeatureProvider, Duration, Duration) ]] to override.
464498 */
465499 def fromProvider (provider : OFFeatureProvider , evaluationTimeout : Duration ): ZLayer [Scope , Throwable , FeatureFlags ] =
466500 ZLayer .scoped(
@@ -475,6 +509,31 @@ object FeatureFlags {
475509 )
476510 )
477511
512+ /** Create a FeatureFlags layer with explicit evaluation and initialization timeouts.
513+ *
514+ * `initTimeout` bounds the sync init: if `setProviderAndWait` takes longer, the layer build fails with a
515+ * `TimeoutException`. After init, if the provider reports `ERROR`/`FATAL`/`NOT_READY`, the build also fails so
516+ * misconfiguration surfaces at startup rather than at first evaluation. Pass a very large duration to effectively
517+ * disable the init timeout.
518+ */
519+ def fromProvider (
520+ provider : OFFeatureProvider ,
521+ evaluationTimeout : Duration ,
522+ initTimeout : Duration
523+ ): ZLayer [Scope , Throwable , FeatureFlags ] =
524+ ZLayer .scoped(
525+ build(
526+ provider,
527+ domain = None ,
528+ version = None ,
529+ initialHooks = Nil ,
530+ statusRef = None ,
531+ addShutdownFinalizer = true ,
532+ evaluationTimeout = Some (evaluationTimeout),
533+ initTimeout = initTimeout
534+ )
535+ )
536+
478537 /** Create a FeatureFlags layer with a named domain/client. */
479538 def fromProviderWithDomain (provider : OFFeatureProvider , domain : String ): ZLayer [Scope , Throwable , FeatureFlags ] =
480539 ZLayer .scoped(
@@ -564,7 +623,7 @@ object FeatureFlags {
564623 * where the provider becomes ready before the event bridge is registered, we start the event bridge first and then
565624 * check the provider's actual state.
566625 */
567- private def buildAsync (
626+ private [openfeature] def buildAsync (
568627 provider : OFFeatureProvider ,
569628 domain : Option [String ],
570629 version : Option [String ],
@@ -573,7 +632,8 @@ object FeatureFlags {
573632 addShutdownFinalizer : Boolean ,
574633 apiOverride : Option [OpenFeatureAPI ] = None ,
575634 onReady : Option [java.util.concurrent.CountDownLatch ] = None ,
576- evaluationTimeout : Option [Duration ] = None
635+ evaluationTimeout : Option [Duration ] = None ,
636+ initTimeout : Duration = DefaultInitTimeout
577637 ): ZIO [Scope , Throwable , FeatureFlagsLive ] =
578638 for {
579639 api <- ZIO .succeed(apiOverride.getOrElse(OpenFeatureAPI .getInstance()))
@@ -609,12 +669,21 @@ object FeatureFlags {
609669 )
610670 // Start event bridge — if provider is already ready, replay fires immediately
611671 _ <- ff.startEventBridge
672+ // Init watchdog: after initTimeout, if the provider hasn't moved past NotReady/Error,
673+ // transition to Fatal so callers polling providerStatus stop waiting. The fiber is forked
674+ // into the layer's Scope so it's interrupted when the layer is released.
675+ _ <- (ZIO .sleep(initTimeout) *> state.statusRef.update {
676+ case ProviderStatus .NotReady | ProviderStatus .Error => ProviderStatus .Fatal
677+ case other => other
678+ }).forkScoped
612679 } yield ff
613680
614681 /** Create a FeatureFlags layer from any OpenFeature provider (non-blocking).
615682 *
616683 * The provider initializes in the background. Evaluations fail with `ProviderNotReady` until the provider is ready.
617- * Use `onProviderReady` or `providerStatus` to detect when the provider becomes available.
684+ * Use `onProviderReady` or `providerStatus` to detect when the provider becomes available. If the provider has not
685+ * become ready within the default 30s init timeout, status atomically transitions to `Fatal` so callers polling
686+ * `providerStatus` stop waiting. See [[fromProviderAsync(OFFeatureProvider, Duration, Duration) ]] to override.
618687 */
619688 def fromProviderAsync (provider : OFFeatureProvider ): ZLayer [Scope , Throwable , FeatureFlags ] =
620689 ZLayer .scoped(
@@ -632,7 +701,8 @@ object FeatureFlags {
632701 *
633702 * Combines async initialization with evaluation timeout protection. The provider initializes in the background;
634703 * evaluations fail with `ProviderNotReady` until ready. Once ready, evaluations that exceed `evaluationTimeout` fail
635- * with `ProviderError` containing a `TimeoutException`.
704+ * with `ProviderError` containing a `TimeoutException`. The init-side default 30s watchdog still applies — see
705+ * [[fromProviderAsync(OFFeatureProvider, Duration, Duration) ]] to override.
636706 */
637707 def fromProviderAsync (
638708 provider : OFFeatureProvider ,
@@ -650,6 +720,29 @@ object FeatureFlags {
650720 )
651721 )
652722
723+ /** Create a FeatureFlags layer with explicit evaluation and initialization timeouts (non-blocking).
724+ *
725+ * `initTimeout` bounds the async ready window: after that duration, if status is still `NotReady` or `Error`, it
726+ * atomically transitions to `Fatal`. Pass a very large duration to effectively disable the watchdog.
727+ */
728+ def fromProviderAsync (
729+ provider : OFFeatureProvider ,
730+ evaluationTimeout : Duration ,
731+ initTimeout : Duration
732+ ): ZLayer [Scope , Throwable , FeatureFlags ] =
733+ ZLayer .scoped(
734+ buildAsync(
735+ provider,
736+ domain = None ,
737+ version = None ,
738+ initialHooks = Nil ,
739+ statusRef = None ,
740+ addShutdownFinalizer = true ,
741+ evaluationTimeout = Some (evaluationTimeout),
742+ initTimeout = initTimeout
743+ )
744+ )
745+
653746 /** Create a FeatureFlags layer with a named domain (non-blocking). */
654747 def fromProviderWithDomainAsync (
655748 provider : OFFeatureProvider ,
0 commit comments