@@ -46,6 +46,7 @@ public function tear_down() {
4646 remove_all_filters ( 'ep_max_failures_count ' );
4747 remove_all_filters ( 'ep_max_failures_timeframe ' );
4848 remove_all_filters ( 'ep_failures_transient_key ' );
49+ remove_all_filters ( 'ep_failures_min_time_between_writes ' );
4950
5051 // Unregister the feature.
5152 unset( \ElasticPress \Features::factory ()->registered_features [ $ this ->feature ->slug ] );
@@ -61,6 +62,7 @@ protected function cleanup_transients() {
6162 delete_transient ( $ transient_key );
6263 delete_option ( '_transient_ ' . $ transient_key );
6364 delete_option ( '_transient_timeout_ ' . $ transient_key );
65+ delete_transient ( $ transient_key . '_lock ' );
6466 }
6567
6668 /**
@@ -75,6 +77,16 @@ protected function get_failures_transient_key() {
7577 return $ method ->invoke ( $ this ->feature );
7678 }
7779
80+ /**
81+ * Failure timestamps array from a failures transient value.
82+ *
83+ * @param mixed $stored Transient value.
84+ * @return array
85+ */
86+ protected function get_stored_failure_timestamps ( $ stored ): array {
87+ return is_array ( $ stored ) ? $ stored : [];
88+ }
89+
7890 /**
7991 * Test setup_failures_count hooks admin_init
8092 *
@@ -447,6 +459,47 @@ function ( $key, $feature ) {
447459 $ this ->assertSame ( $ expected , $ method ->invoke ( $ this ->feature ) );
448460 }
449461
462+ /**
463+ * Test get_failures_min_time_between_writes returns the expected default value.
464+ *
465+ * The default is 1 second when an external object cache is present and 10 seconds otherwise.
466+ *
467+ * @group disable-after-failures
468+ */
469+ public function test_get_failures_min_time_between_writes_returns_default () {
470+ $ reflection = new \ReflectionClass ( $ this ->feature );
471+ $ method = $ reflection ->getMethod ( 'get_failures_min_time_between_writes ' );
472+ $ method ->setAccessible ( true );
473+
474+ $ expected = wp_using_ext_object_cache () ? 1 : 10 ;
475+ $ this ->assertSame ( $ expected , $ method ->invoke ( $ this ->feature ) );
476+ }
477+
478+ /**
479+ * Test get_failures_min_time_between_writes respects the ep_failures_min_time_between_writes filter.
480+ *
481+ * @group disable-after-failures
482+ */
483+ public function test_get_failures_min_time_between_writes_respects_filter () {
484+ add_filter (
485+ 'ep_failures_min_time_between_writes ' ,
486+ function ( $ min_time , $ feature ) {
487+ if ( $ feature ->slug === $ this ->feature ->slug ) {
488+ return 30 ;
489+ }
490+ return $ min_time ;
491+ },
492+ 10 ,
493+ 2
494+ );
495+
496+ $ reflection = new \ReflectionClass ( $ this ->feature );
497+ $ method = $ reflection ->getMethod ( 'get_failures_min_time_between_writes ' );
498+ $ method ->setAccessible ( true );
499+
500+ $ this ->assertSame ( 30 , $ method ->invoke ( $ this ->feature ) );
501+ }
502+
450503 /**
451504 * Test update_failures_count adds current timestamp
452505 *
@@ -465,7 +518,8 @@ public function test_update_failures_count_adds_current_timestamp() {
465518 $ method ->invoke ( $ this ->feature );
466519 $ after_time = time ();
467520
468- $ updated_failures = get_transient ( $ transient_key );
521+ $ updated_stored = get_transient ( $ transient_key );
522+ $ updated_failures = $ this ->get_stored_failure_timestamps ( $ updated_stored );
469523 $ this ->assertIsArray ( $ updated_failures );
470524 $ this ->assertCount ( 2 , $ updated_failures );
471525
@@ -513,7 +567,8 @@ public function test_update_failures_count_cleans_up_old_failures() {
513567
514568 $ method ->invoke ( $ this ->feature );
515569
516- $ updated_failures = get_transient ( $ transient_key );
570+ $ updated_stored = get_transient ( $ transient_key );
571+ $ updated_failures = $ this ->get_stored_failure_timestamps ( $ updated_stored );
517572 $ this ->assertIsArray ( $ updated_failures );
518573 // Should only have recent failures (old one cleaned up, plus new one).
519574 foreach ( $ updated_failures as $ failure_time ) {
@@ -524,9 +579,15 @@ public function test_update_failures_count_cleans_up_old_failures() {
524579 /**
525580 * Test update_failures_count limits stored failures
526581 *
582+ * The rate limit on writes after the limit is hit is disabled here so we can
583+ * validate that, when a write does happen, cleanup_failures keeps the array
584+ * trimmed to max + 1 entries.
585+ *
527586 * @group disable-after-failures
528587 */
529588 public function test_update_failures_count_limits_stored_failures () {
589+ add_filter ( 'ep_failures_min_time_between_writes ' , '__return_zero ' );
590+
530591 $ transient_key = $ this ->get_failures_transient_key ();
531592
532593 // Create more failures than max + 1.
@@ -539,11 +600,115 @@ public function test_update_failures_count_limits_stored_failures() {
539600
540601 $ method ->invoke ( $ this ->feature );
541602
542- $ updated_failures = get_transient ( $ transient_key );
603+ $ updated_stored = get_transient ( $ transient_key );
604+ $ updated_failures = $ this ->get_stored_failure_timestamps ( $ updated_stored );
543605 // Should only keep max_failures_count + 1 (3 + 1 = 4).
544606 $ this ->assertLessThanOrEqual ( 4 , count ( $ updated_failures ) );
545607 }
546608
609+ /**
610+ * Test update_failures_count skips the write when the failure count is already over
611+ * the max and the most recently stored failure happened within the rate-limit window.
612+ *
613+ * @group disable-after-failures
614+ */
615+ public function test_update_failures_count_skips_write_when_over_limit_and_recent () {
616+ $ transient_key = $ this ->get_failures_transient_key ();
617+
618+ // Already over the max (3) with the most recent failure happening "now",
619+ // well within the default 10s rate-limit window.
620+ $ now = time ();
621+ $ initial_failures = [ $ now - 30 , $ now - 20 , $ now - 10 , $ now ];
622+ set_transient ( $ transient_key , $ initial_failures , HOUR_IN_SECONDS );
623+
624+ $ reflection = new \ReflectionClass ( $ this ->feature );
625+ $ method = $ reflection ->getMethod ( 'update_failures_count ' );
626+ $ method ->setAccessible ( true );
627+
628+ $ method ->invoke ( $ this ->feature );
629+
630+ // The transient should be untouched: no new entry, same values.
631+ $ updated_failures = get_transient ( $ transient_key );
632+ $ this ->assertSame ( $ initial_failures , $ updated_failures );
633+ }
634+
635+ /**
636+ * Test update_failures_count writes when over the limit but the last stored failure
637+ * is older than the configured min_time_between_writes window.
638+ *
639+ * @group disable-after-failures
640+ */
641+ public function test_update_failures_count_writes_when_over_limit_and_last_failure_is_old () {
642+ // Set a small window so the test is not flaky on slow runners.
643+ add_filter (
644+ 'ep_failures_min_time_between_writes ' ,
645+ function ( $ min_time , $ feature ) {
646+ if ( $ feature ->slug === $ this ->feature ->slug ) {
647+ return 1 ;
648+ }
649+ return $ min_time ;
650+ },
651+ 10 ,
652+ 2
653+ );
654+
655+ $ transient_key = $ this ->get_failures_transient_key ();
656+
657+ // Already over the max (3), but the most recent stored failure is 30s old.
658+ $ now = time ();
659+ $ initial_failures = [ $ now - 60 , $ now - 50 , $ now - 40 , $ now - 30 ];
660+ set_transient ( $ transient_key , $ initial_failures , HOUR_IN_SECONDS );
661+
662+ $ reflection = new \ReflectionClass ( $ this ->feature );
663+ $ method = $ reflection ->getMethod ( 'update_failures_count ' );
664+ $ method ->setAccessible ( true );
665+
666+ $ before_time = time ();
667+ $ method ->invoke ( $ this ->feature );
668+ $ after_time = time ();
669+
670+ $ updated_stored = get_transient ( $ transient_key );
671+ $ updated_failures = $ this ->get_stored_failure_timestamps ( $ updated_stored );
672+ $ this ->assertIsArray ( $ updated_failures );
673+
674+ // The newly added timestamp should be the last entry in the stored array.
675+ $ last_failure = end ( $ updated_failures );
676+ $ this ->assertGreaterThanOrEqual ( $ before_time , $ last_failure );
677+ $ this ->assertLessThanOrEqual ( $ after_time , $ last_failure );
678+
679+ // cleanup_failures keeps at most max + 1 entries.
680+ $ this ->assertLessThanOrEqual ( 4 , count ( $ updated_failures ) );
681+ }
682+
683+ /**
684+ * Test update_failures_count writes normally while the failure count is still at or below max.
685+ *
686+ * The rate-limit logic must not affect calls happening before the feature gets
687+ * temporarily disabled, otherwise the disable threshold could never be reached.
688+ *
689+ * @group disable-after-failures
690+ */
691+ public function test_update_failures_count_writes_when_at_or_below_max () {
692+ $ transient_key = $ this ->get_failures_transient_key ();
693+
694+ // Exactly at max, all timestamps are "now" which is the worst case for the
695+ // rate limit if it were applied below the max.
696+ $ now = time ();
697+ $ initial_failures = [ $ now , $ now , $ now ];
698+ set_transient ( $ transient_key , $ initial_failures , HOUR_IN_SECONDS );
699+
700+ $ reflection = new \ReflectionClass ( $ this ->feature );
701+ $ method = $ reflection ->getMethod ( 'update_failures_count ' );
702+ $ method ->setAccessible ( true );
703+
704+ $ method ->invoke ( $ this ->feature );
705+
706+ $ updated_stored = get_transient ( $ transient_key );
707+ $ updated_failures = $ this ->get_stored_failure_timestamps ( $ updated_stored );
708+ $ this ->assertIsArray ( $ updated_failures );
709+ $ this ->assertCount ( count ( $ initial_failures ) + 1 , $ updated_failures );
710+ }
711+
547712 /**
548713 * Test cleanup_failures removes old failures
549714 *
@@ -625,6 +790,43 @@ public function test_integration_full_flow_failures_to_disable() {
625790 $ this ->assertSame ( FeatureRequirementsStatus::TEMPORARILY_DISABLED , $ status ->code );
626791 }
627792
793+ /**
794+ * Test integration: once the feature is temporarily disabled, additional failures
795+ * happening in rapid succession do not write to the transient (rate limit), but
796+ * the feature remains disabled.
797+ *
798+ * @group disable-after-failures
799+ */
800+ public function test_integration_rate_limits_writes_after_disabled () {
801+ $ transient_key = $ this ->get_failures_transient_key ();
802+
803+ $ reflection = new \ReflectionClass ( $ this ->feature );
804+ $ update_method = $ reflection ->getMethod ( 'update_failures_count ' );
805+ $ update_method ->setAccessible ( true );
806+
807+ // Trigger enough failures (max + 1 = 4) to disable the feature.
808+ for ( $ i = 0 ; $ i < 4 ; $ i ++ ) {
809+ $ update_method ->invoke ( $ this ->feature );
810+ }
811+
812+ $ failures_after_disable = get_transient ( $ transient_key );
813+ $ this ->assertIsArray ( $ failures_after_disable );
814+ $ this ->assertCount (
815+ 4 ,
816+ $ this ->get_stored_failure_timestamps ( $ failures_after_disable )
817+ );
818+ $ this ->assertTrue ( $ this ->feature ->should_disable_after_failures () );
819+
820+ // Subsequent rapid calls should be skipped: the transient must remain identical.
821+ for ( $ i = 0 ; $ i < 5 ; $ i ++ ) {
822+ $ update_method ->invoke ( $ this ->feature );
823+ }
824+
825+ $ failures_after_rapid_calls = get_transient ( $ transient_key );
826+ $ this ->assertSame ( $ failures_after_disable , $ failures_after_rapid_calls );
827+ $ this ->assertTrue ( $ this ->feature ->should_disable_after_failures () );
828+ }
829+
628830 /**
629831 * Test integration: failures expire after timeframe
630832 *
0 commit comments