From d81af9815967f7c86a56de87e48daf58fedd1988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniele=20Orr=C3=B9?= Date: Tue, 5 Nov 2024 12:26:33 +0100 Subject: [PATCH] [RateLimiter] Fix DateInterval normalization --- .../RateLimiter/RateLimiterFactory.php | 6 +++- .../Tests/RateLimiterFactoryTest.php | 34 +++++++++++++++++++ .../Component/RateLimiter/Util/TimeUtil.php | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php index b0c48855d4b10..510f2e644aa10 100644 --- a/src/Symfony/Component/RateLimiter/RateLimiterFactory.php +++ b/src/Symfony/Component/RateLimiter/RateLimiterFactory.php @@ -69,7 +69,11 @@ protected static function configureOptions(OptionsResolver $options): void { $intervalNormalizer = static function (Options $options, string $interval): \DateInterval { try { - return (new \DateTimeImmutable())->diff(new \DateTimeImmutable('+'.$interval)); + // Create DateTimeImmutable from unix timesatmp, so the default timezone is ignored and we don't need to + // deal with quirks happening when modifying dates using a timezone with DST. + $now = \DateTimeImmutable::createFromFormat('U', time()); + + return $now->diff($now->modify('+'.$interval)); } catch (\Exception $e) { if (!preg_match('/Failed to parse time string \(\+([^)]+)\)/', $e->getMessage(), $m)) { throw $e; diff --git a/src/Symfony/Component/RateLimiter/Tests/RateLimiterFactoryTest.php b/src/Symfony/Component/RateLimiter/Tests/RateLimiterFactoryTest.php index 5ac5963a2a1cb..3856a1189ffc9 100644 --- a/src/Symfony/Component/RateLimiter/Tests/RateLimiterFactoryTest.php +++ b/src/Symfony/Component/RateLimiter/Tests/RateLimiterFactoryTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\RateLimiter\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ClockMock; use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; use Symfony\Component\RateLimiter\Policy\FixedWindowLimiter; use Symfony\Component\RateLimiter\Policy\NoLimiter; @@ -76,4 +77,37 @@ public static function invalidConfigProvider() 'policy' => 'token_bucket', ]]; } + + /** + * @group time-sensitive + */ + public function testExpirationTimeCalculationWhenUsingDefaultTimezoneRomeWithIntervalAfterCETChange() + { + $originalTimezone = date_default_timezone_get(); + try { + // Timestamp for 'Sun 27 Oct 2024 12:59:40 AM UTC' that's just 20 seconds before switch CEST->CET + ClockMock::withClockMock(1729990780); + + // This is a prerequisite for the bug to happen + date_default_timezone_set('Europe/Rome'); + + $storage = new InMemoryStorage(); + $factory = new RateLimiterFactory( + [ + 'id' => 'id_1', + 'policy' => 'fixed_window', + 'limit' => 30, + 'interval' => '21 seconds', + ], + $storage + ); + $rateLimiter = $factory->create('key'); + $rateLimiter->consume(1); + $limiterState = $storage->fetch('id_1-key'); + // As expected the expiration is equal to the interval we defined + $this->assertSame(21, $limiterState->getExpirationTime()); + } finally { + date_default_timezone_set($originalTimezone); + } + } } diff --git a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php index 0f8948c57442b..30351d72c4c22 100644 --- a/src/Symfony/Component/RateLimiter/Util/TimeUtil.php +++ b/src/Symfony/Component/RateLimiter/Util/TimeUtil.php @@ -20,7 +20,7 @@ final class TimeUtil { public static function dateIntervalToSeconds(\DateInterval $interval): int { - $now = new \DateTimeImmutable(); + $now = \DateTimeImmutable::createFromFormat('U', time()); return $now->add($interval)->getTimestamp() - $now->getTimestamp(); }