Skip to content

Commit 2d10f2a

Browse files
[11.x] Proper rate limiter fix with phpredis serialization/compression enabled (#54372)
* Properly resolve RateLimiter not working while redis serialization or compression is enabled * Fix code style * Fix test * Skip outdated test * formatting --------- Co-authored-by: Taylor Otwell <[email protected]>
1 parent 96302bf commit 2d10f2a

File tree

7 files changed

+102
-7
lines changed

7 files changed

+102
-7
lines changed

src/Illuminate/Cache/RateLimiter.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Closure;
66
use Illuminate\Contracts\Cache\Repository as Cache;
7+
use Illuminate\Redis\Connections\PhpRedisConnection;
78
use Illuminate\Support\Collection;
89
use Illuminate\Support\InteractsWithTime;
910

@@ -165,12 +166,16 @@ public function increment($key, $decaySeconds = 60, $amount = 1)
165166
$key.':timer', $this->availableAt($decaySeconds), $decaySeconds
166167
);
167168

168-
$added = $this->cache->add($key, 0, $decaySeconds);
169+
$added = $this->withoutSerializationOrCompression(
170+
fn () => $this->cache->add($key, 0, $decaySeconds)
171+
);
169172

170173
$hits = (int) $this->cache->increment($key, $amount);
171174

172175
if (! $added && $hits == 1) {
173-
$this->cache->put($key, 1, $decaySeconds);
176+
$this->withoutSerializationOrCompression(
177+
fn () => $this->cache->put($key, 1, $decaySeconds)
178+
);
174179
}
175180

176181
return $hits;
@@ -199,7 +204,7 @@ public function attempts($key)
199204
{
200205
$key = $this->cleanRateLimiterKey($key);
201206

202-
return $this->cache->get($key, 0);
207+
return $this->withoutSerializationOrCompression(fn () => $this->cache->get($key, 0));
203208
}
204209

205210
/**
@@ -282,6 +287,29 @@ public function cleanRateLimiterKey($key)
282287
return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key));
283288
}
284289

290+
/**
291+
* Execute the given callback without serialization or compression when applicable.
292+
*
293+
* @param callable $callback
294+
* @return mixed
295+
*/
296+
protected function withoutSerializationOrCompression(callable $callback)
297+
{
298+
$store = $this->cache->getStore();
299+
300+
if (! $store instanceof RedisStore) {
301+
return $callback();
302+
}
303+
304+
$connection = $store->connection();
305+
306+
if (! $connection instanceof PhpRedisConnection) {
307+
return $callback();
308+
}
309+
310+
return $connection->withoutSerializationOrCompression($callback);
311+
}
312+
285313
/**
286314
* Resolve the rate limiter name.
287315
*

src/Illuminate/Cache/RedisStore.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -432,10 +432,6 @@ public function setPrefix($prefix)
432432
protected function pack($value, $connection)
433433
{
434434
if ($connection instanceof PhpRedisConnection) {
435-
if ($this->shouldBeStoredWithoutSerialization($value)) {
436-
return $value;
437-
}
438-
439435
if ($connection->serialized()) {
440436
return $connection->pack([$value])[0];
441437
}

src/Illuminate/Redis/Connections/PacksPhpRedisValues.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,43 @@ public function pack(array $values): array
8282
return array_map($processor, $values);
8383
}
8484

85+
/**
86+
* Execute the given callback without serialization or compression when applicable.
87+
*
88+
* @param callable $callback
89+
* @return mixed
90+
*/
91+
public function withoutSerializationOrCompression(callable $callback)
92+
{
93+
$client = $this->client;
94+
95+
$oldSerializer = null;
96+
97+
if ($this->serialized()) {
98+
$oldSerializer = $client->getOption($client::OPT_SERIALIZER);
99+
$client->setOption($client::OPT_SERIALIZER, $client::SERIALIZER_NONE);
100+
}
101+
102+
$oldCompressor = null;
103+
104+
if ($this->compressed()) {
105+
$oldCompressor = $client->getOption($client::OPT_COMPRESSION);
106+
$client->setOption($client::OPT_COMPRESSION, $client::COMPRESSION_NONE);
107+
}
108+
109+
try {
110+
return $callback();
111+
} finally {
112+
if ($oldSerializer !== null) {
113+
$client->setOption($client::OPT_SERIALIZER, $oldSerializer);
114+
}
115+
116+
if ($oldCompressor !== null) {
117+
$client->setOption($client::OPT_COMPRESSION, $oldCompressor);
118+
}
119+
}
120+
}
121+
85122
/**
86123
* Determine if serialization is enabled.
87124
*

tests/Cache/CacheRateLimiterTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Tests\Cache;
44

5+
use Illuminate\Cache\ArrayStore;
56
use Illuminate\Cache\RateLimiter;
67
use Illuminate\Contracts\Cache\Repository as Cache;
78
use Mockery as m;
@@ -20,6 +21,7 @@ public function testTooManyAttemptsReturnTrueIfAlreadyLockedOut()
2021
$cache->shouldReceive('get')->once()->with('key', 0)->andReturn(1);
2122
$cache->shouldReceive('has')->once()->with('key:timer')->andReturn(true);
2223
$cache->shouldReceive('add')->never();
24+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
2325
$rateLimiter = new RateLimiter($cache);
2426

2527
$this->assertTrue($rateLimiter->tooManyAttempts('key', 1));
@@ -31,6 +33,7 @@ public function testHitProperlyIncrementsAttemptCount()
3133
$cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true);
3234
$cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(true);
3335
$cache->shouldReceive('increment')->once()->with('key', 1)->andReturn(1);
36+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
3437
$rateLimiter = new RateLimiter($cache);
3538

3639
$rateLimiter->hit('key', 1);
@@ -42,6 +45,7 @@ public function testIncrementProperlyIncrementsAttemptCount()
4245
$cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true);
4346
$cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(true);
4447
$cache->shouldReceive('increment')->once()->with('key', 5)->andReturn(5);
48+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
4549
$rateLimiter = new RateLimiter($cache);
4650

4751
$rateLimiter->increment('key', 1, 5);
@@ -53,6 +57,7 @@ public function testDecrementProperlyDecrementsAttemptCount()
5357
$cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1)->andReturn(true);
5458
$cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(true);
5559
$cache->shouldReceive('increment')->once()->with('key', -5)->andReturn(-5);
60+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
5661
$rateLimiter = new RateLimiter($cache);
5762

5863
$rateLimiter->decrement('key', 1, 5);
@@ -65,6 +70,7 @@ public function testHitHasNoMemoryLeak()
6570
$cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturn(false);
6671
$cache->shouldReceive('increment')->once()->with('key', 1)->andReturn(1);
6772
$cache->shouldReceive('put')->once()->with('key', 1, 1);
73+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
6874
$rateLimiter = new RateLimiter($cache);
6975

7076
$rateLimiter->hit('key', 1);
@@ -74,6 +80,7 @@ public function testRetriesLeftReturnsCorrectCount()
7480
{
7581
$cache = m::mock(Cache::class);
7682
$cache->shouldReceive('get')->once()->with('key', 0)->andReturn(3);
83+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
7784
$rateLimiter = new RateLimiter($cache);
7885

7986
$this->assertEquals(2, $rateLimiter->retriesLeft('key', 5));
@@ -84,6 +91,7 @@ public function testClearClearsTheCacheKeys()
8491
$cache = m::mock(Cache::class);
8592
$cache->shouldReceive('forget')->once()->with('key');
8693
$cache->shouldReceive('forget')->once()->with('key:timer');
94+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
8795
$rateLimiter = new RateLimiter($cache);
8896

8997
$rateLimiter->clear('key');
@@ -93,6 +101,7 @@ public function testAvailableInReturnsPositiveValues()
93101
{
94102
$cache = m::mock(Cache::class);
95103
$cache->shouldReceive('get')->andReturn(now()->subSeconds(60)->getTimestamp(), null);
104+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
96105
$rateLimiter = new RateLimiter($cache);
97106

98107
$this->assertTrue($rateLimiter->availableIn('key:timer') >= 0);
@@ -106,6 +115,7 @@ public function testAttemptsCallbackReturnsTrue()
106115
$cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 1);
107116
$cache->shouldReceive('add')->once()->with('key', 0, 1)->andReturns(1);
108117
$cache->shouldReceive('increment')->once()->with('key', 1)->andReturn(1);
118+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
109119

110120
$executed = false;
111121

@@ -124,6 +134,7 @@ public function testAttemptsCallbackReturnsCallbackReturn()
124134
$cache->shouldReceive('add')->times(6)->with('key:timer', m::type('int'), 1);
125135
$cache->shouldReceive('add')->times(6)->with('key', 0, 1)->andReturns(1);
126136
$cache->shouldReceive('increment')->times(6)->with('key', 1)->andReturn(1);
137+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
127138

128139
$rateLimiter = new RateLimiter($cache);
129140

@@ -157,6 +168,7 @@ public function testAttemptsCallbackReturnsFalse()
157168
$cache = m::mock(Cache::class);
158169
$cache->shouldReceive('get')->once()->with('key', 0)->andReturn(2);
159170
$cache->shouldReceive('has')->once()->with('key:timer')->andReturn(true);
171+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
160172

161173
$executed = false;
162174

@@ -174,6 +186,7 @@ public function testKeysAreSanitizedFromUnicodeCharacters()
174186
$cache->shouldReceive('get')->once()->with('john', 0)->andReturn(1);
175187
$cache->shouldReceive('has')->once()->with('john:timer')->andReturn(true);
176188
$cache->shouldReceive('add')->never();
189+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
177190
$rateLimiter = new RateLimiter($cache);
178191

179192
$this->assertTrue($rateLimiter->tooManyAttempts('jôhn', 1));
@@ -190,6 +203,7 @@ public function testKeyIsSanitizedOnlyOnce()
190203
$cache->shouldReceive('get')->once()->with($cleanedKey, 0)->andReturn(1);
191204
$cache->shouldReceive('has')->once()->with("$cleanedKey:timer")->andReturn(true);
192205
$cache->shouldReceive('add')->never();
206+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
193207

194208
$this->assertTrue($rateLimiter->tooManyAttempts($key, 1));
195209
}

tests/Cache/RedisCacheIntegrationTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Tests\Cache;
44

5+
use Illuminate\Cache\RateLimiter;
56
use Illuminate\Cache\RedisStore;
67
use Illuminate\Cache\Repository;
78
use Illuminate\Foundation\Testing\Concerns\InteractsWithRedis;
@@ -37,6 +38,22 @@ public function testRedisCacheAddTwice($driver)
3738
$this->assertGreaterThan(3500, $this->redis[$driver]->connection()->ttl('k'));
3839
}
3940

41+
/**
42+
* @param string $driver
43+
*/
44+
#[DataProvider('redisDriverProvider')]
45+
public function testRedisCacheRateLimiter($driver)
46+
{
47+
$store = new RedisStore($this->redis[$driver]);
48+
$repository = new Repository($store);
49+
$rateLimiter = new RateLimiter($repository);
50+
51+
$this->assertFalse($rateLimiter->tooManyAttempts('key', 1));
52+
$this->assertEquals(1, $rateLimiter->hit('key', 60));
53+
$this->assertTrue($rateLimiter->tooManyAttempts('key', 1));
54+
$this->assertFalse($rateLimiter->tooManyAttempts('key', 2));
55+
}
56+
4057
/**
4158
* Breaking change.
4259
*

tests/Integration/Cache/RedisStoreTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,8 @@ public function testPutManyCallsPutWhenClustered()
252252

253253
public function testIncrementWithSerializationEnabled()
254254
{
255+
$this->markTestSkipped('Test makes no sense anymore. Application must explicitly wrap such code in runClean() when used with serialization/compression enabled.');
256+
255257
/** @var \Illuminate\Cache\RedisStore $store */
256258
$store = Cache::store('redis');
257259
/** @var \Redis $client */

tests/Integration/Queue/RateLimitedTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public function testRateLimitedJobsAreNotExecutedOnLimitReached2()
6363
$cache->shouldReceive('add')->andReturn(true, true);
6464
$cache->shouldReceive('increment')->andReturn(1);
6565
$cache->shouldReceive('has')->andReturn(true);
66+
$cache->shouldReceive('getStore')->andReturn(new ArrayStore);
6667

6768
$rateLimiter = new RateLimiter($cache);
6869
$this->app->instance(RateLimiter::class, $rateLimiter);

0 commit comments

Comments
 (0)