Skip to content

Commit

Permalink
Adds InMemoryThrottler for mocking throttler functionality.
Browse files Browse the repository at this point in the history
Provides interface for throttler.
  • Loading branch information
mariusbalcytis committed Jun 17, 2017
1 parent db69219 commit 1feace8
Show file tree
Hide file tree
Showing 10 changed files with 199 additions and 11 deletions.
3 changes: 2 additions & 1 deletion README.md
Expand Up @@ -118,7 +118,7 @@ Some of reviewed alternatives:

## Semantic versioning

This bundle follows [semantic versioning](http://semver.org/spec/v2.0.0.html).
This library follows [semantic versioning](http://semver.org/spec/v2.0.0.html).

See [Symfony BC rules](http://symfony.com/doc/current/contributing/code/bc.html) for basic
information about what can be changed and what not in the API.
Expand All @@ -132,6 +132,7 @@ so that behaviour on high traffic could be tested. So, generally,
it's easier to run them in docker.

```
composer update
cd docker
docker-compose up -d
docker exec -it gentle_force_test_php vendor/bin/phpunit
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Expand Up @@ -9,7 +9,7 @@
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.2",
"phpunit/phpunit": "^4.8",
"phpunit/phpunit": "^4.8.35",
"misterion/ko-process": "^0.5.3",
"symfony/stopwatch": "^3.2"
},
Expand Down
113 changes: 113 additions & 0 deletions src/InMemory/InMemoryThrottler.php
@@ -0,0 +1,113 @@
<?php

namespace Maba\GentleForce\InMemory;

use Maba\GentleForce\Exception\RateLimitReachedException;
use Maba\GentleForce\IncreaseResult;
use Maba\GentleForce\RateLimitProvider;
use Maba\GentleForce\ThrottlerInterface;

/**
* Only for testing purposes (mocking) - mimics functionality without external dependencies.
* Current time is also mockable.
*
* This allows writing consistent and fast tests for functionality that uses throttler.
*
* @internal
*/
class InMemoryThrottler implements ThrottlerInterface
{
private $rateLimitProvider;
private $microtimeProvider;

/**
* @var array
*/
private $storage = [];

public function __construct(RateLimitProvider $rateLimitProvider, MicrotimeProvider $microtimeProvider)
{
$this->rateLimitProvider = $rateLimitProvider;
$this->microtimeProvider = $microtimeProvider;
}

/**
* @param string $useCaseKey configured key for this use case, like "credentials_error_ip"
* @param string $identifier rate-limiting group, like IP address or username
* @return IncreaseResult
* @throws RateLimitReachedException
*/
public function checkAndIncrease($useCaseKey, $identifier)
{
$now = $this->microtimeProvider->getMicrotime();
$rateLimits = $this->rateLimitProvider->getRateLimits($useCaseKey);
$key = $this->buildKey($useCaseKey, $identifier);

$totals = [];
$usagesAvailable = [];
$validAfter = 0;
foreach ($rateLimits as $rateLimit) {
$tokensPerUsage = $rateLimit->calculateTokensPerUsage();
$bucketSize = $rateLimit->calculateBucketSize();
$subKey = $tokensPerUsage . ':' . $bucketSize;
$total = 0;
$emptyAt = isset($this->storage[$key][$subKey]) ? $this->storage[$key][$subKey] : null;
if ($emptyAt !== null) {
$total = max(0, $emptyAt - $now);
}
$total += $tokensPerUsage;
if ($total > $bucketSize) {
$validAfter = max($validAfter, $total - $bucketSize);
}

$totals[$subKey] = $total;
$usagesAvailable[$subKey] = $total / $tokensPerUsage;
}

if ($validAfter > 0) {
throw new RateLimitReachedException($validAfter);
}

$maxUsagesAvailable = 0;
foreach ($totals as $subKey => $total) {
$this->storage[$key][$subKey] = $now + $total;
$maxUsagesAvailable = max($maxUsagesAvailable, $usagesAvailable[$subKey]);
}

return new IncreaseResult($this, floor($maxUsagesAvailable), $useCaseKey, $identifier);
}

/**
* @param string $useCaseKey configured key for this use case, like "credentials_error_ip"
* @param string $identifier rate-limiting group, like IP address or username
*/
public function decrease($useCaseKey, $identifier)
{
$rateLimits = $this->rateLimitProvider->getRateLimits($useCaseKey);
$key = $this->buildKey($useCaseKey, $identifier);

foreach ($rateLimits as $rateLimit) {
$tokensPerUsage = $rateLimit->calculateTokensPerUsage();
$bucketSize = $rateLimit->calculateBucketSize();
$subKey = $tokensPerUsage . ':' . $bucketSize;
if (isset($this->storage[$key][$subKey])) {
$this->storage[$key][$subKey] -= $tokensPerUsage;
}
}
}

/**
* @param string $useCaseKey configured key for this use case, like "credentials_error_ip"
* @param string $identifier rate-limiting group, like IP address or username
*/
public function reset($useCaseKey, $identifier)
{
$key = $this->buildKey($useCaseKey, $identifier);
unset($this->storage[$key]);
}

private function buildKey($useCaseKey, $identifier)
{
return $useCaseKey . ':' . $identifier;
}
}
24 changes: 24 additions & 0 deletions src/InMemory/MicrotimeProvider.php
@@ -0,0 +1,24 @@
<?php

namespace Maba\GentleForce\InMemory;

class MicrotimeProvider
{
/**
* @var float
*/
private $mockedMicrotime;

/**
* @param float $mockedMicrotime
*/
public function setMockedMicrotime($mockedMicrotime)
{
$this->mockedMicrotime = $mockedMicrotime;
}

public function getMicrotime()
{
return $this->mockedMicrotime !== null ? $this->mockedMicrotime : microtime(true);
}
}
2 changes: 1 addition & 1 deletion src/IncreaseResult.php
Expand Up @@ -9,7 +9,7 @@ class IncreaseResult
private $useCaseKey;
private $identifier;

public function __construct(Throttler $throttler, $usagesAvailable, $useCaseKey, $identifier)
public function __construct(ThrottlerInterface $throttler, $usagesAvailable, $useCaseKey, $identifier)
{
$this->throttler = $throttler;
$this->usagesAvailable = $usagesAvailable;
Expand Down
2 changes: 1 addition & 1 deletion src/Throttler.php
Expand Up @@ -8,7 +8,7 @@
use Maba\GentleForce\Redis\Result\CheckAndIncreaseResult;
use Predis\Client;

class Throttler
class Throttler implements ThrottlerInterface
{
private $client;
private $rateLimitProvider;
Expand Down
28 changes: 28 additions & 0 deletions src/ThrottlerInterface.php
@@ -0,0 +1,28 @@
<?php

namespace Maba\GentleForce;

use Maba\GentleForce\Exception\RateLimitReachedException;

interface ThrottlerInterface
{
/**
* @param string $useCaseKey configured key for this use case, like "credentials_error_ip"
* @param string $identifier rate-limiting group, like IP address or username
* @return IncreaseResult
* @throws RateLimitReachedException
*/
public function checkAndIncrease($useCaseKey, $identifier);

/**
* @param string $useCaseKey configured key for this use case, like "credentials_error_ip"
* @param string $identifier rate-limiting group, like IP address or username
*/
public function decrease($useCaseKey, $identifier);

/**
* @param string $useCaseKey configured key for this use case, like "credentials_error_ip"
* @param string $identifier rate-limiting group, like IP address or username
*/
public function reset($useCaseKey, $identifier);
}
16 changes: 16 additions & 0 deletions tests/Functional/FunctionalInMemoryThrottlerTest.php
@@ -0,0 +1,16 @@
<?php

namespace Maba\GentleForce\Tests\Functional;

use Maba\GentleForce\InMemory\InMemoryThrottler;
use Maba\GentleForce\InMemory\MicrotimeProvider;
use Maba\GentleForce\RateLimitProvider;

class FunctionalInMemoryThrottlerTest extends FunctionalTest
{

protected function createThrottler(RateLimitProvider $rateLimitProvider)
{
return new InMemoryThrottler($rateLimitProvider, new MicrotimeProvider());
}
}
17 changes: 11 additions & 6 deletions tests/Functional/FunctionalTest.php
Expand Up @@ -6,6 +6,7 @@
use Maba\GentleForce\RateLimit\UsageRateLimit;
use Maba\GentleForce\RateLimitProvider;
use Maba\GentleForce\Throttler;
use Maba\GentleForce\ThrottlerInterface;
use PHPUnit_Framework_TestCase as TestCase;
use Predis\Client;
use Symfony\Component\Stopwatch\Stopwatch;
Expand All @@ -19,7 +20,7 @@ class FunctionalTest extends TestCase
const ERROR_CORRECTION_PERIOD_MS = 60;

/**
* @var Throttler
* @var ThrottlerInterface
*/
private $throttler;

Expand Down Expand Up @@ -118,18 +119,22 @@ public function testWithReset()

private function setUpThrottler($rateLimits)
{
$prefix = 'functional_test_' . microtime();

$rateLimitProvider = new RateLimitProvider();
$rateLimitProvider->registerRateLimits(self::USE_CASE_KEY, $rateLimits);

$this->throttler = new Throttler(new Client([
'host' => isset($_ENV['REDIS_HOST']) ? $_ENV['REDIS_HOST'] : 'localhost',
]), $rateLimitProvider, $prefix);
$this->throttler = $this->createThrottler($rateLimitProvider);

$this->event = (new Stopwatch())->start('');
}

protected function createThrottler(RateLimitProvider $rateLimitProvider)
{
$prefix = 'functional_test_' . microtime();
return new Throttler(new Client([
'host' => isset($_ENV['REDIS_HOST']) ? $_ENV['REDIS_HOST'] : 'localhost',
]), $rateLimitProvider, $prefix);
}

private function assertUsagesValid($countOfUsages)
{
for ($i = 0; $i < $countOfUsages; $i++) {
Expand Down
3 changes: 2 additions & 1 deletion tests/Functional/RaceConditionsTest.php
Expand Up @@ -8,6 +8,7 @@
use Maba\GentleForce\RateLimit\UsageRateLimit;
use Maba\GentleForce\RateLimitProvider;
use Maba\GentleForce\Throttler;
use Maba\GentleForce\ThrottlerInterface;
use PHPUnit_Framework_TestCase as TestCase;
use Predis\Client;
use Symfony\Component\Stopwatch\Stopwatch;
Expand All @@ -19,7 +20,7 @@ class RaceConditionsTest extends TestCase
const ID = 'user1';

/**
* @var Throttler
* @var ThrottlerInterface
*/
private $throttler;

Expand Down

0 comments on commit 1feace8

Please sign in to comment.