Passed
Pull Request — master (#19)
by Vadim
01:52
created

Counter::hit()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 9
c 0
b 0
f 0
nc 2
nop 1
dl 0
loc 19
ccs 10
cts 10
cp 1
crap 2
rs 9.9666
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\RateLimiter;
6
7
use InvalidArgumentException;
8
use Psr\SimpleCache\CacheInterface;
9
10
/**
11
 * Counter implements generic cell rate limit algorithm (GCRA) that ensures that after reaching the limit further
12
 * increments are distributed equally.
13
 *
14
 * @link https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm
15
 */
16
final class Counter implements CounterInterface
17
{
18
    private const DEFAULT_TTL = 86400;
19
20
    private const ID_PREFIX = 'rate-limiter-';
21
22
    private const MILLISECONDS_PER_SECOND = 1000;
23
24
    /**
25
     * @var int Period to apply limit to.
26
     */
27
    private int $periodInMilliseconds;
28
29
    private int $limit;
30
31
    /**
32
     * @var float Maximum interval before next increment.
33
     * In GCRA it is known as emission interval.
34
     */
35
    private float $incrementIntervalInMilliseconds;
36
37
    private CacheInterface $storage;
38
39
    private int $ttlInSeconds;
40
    private string $cachePrefix;
41
42
    /**
43
     * @param int $limit Maximum number of increments that could be performed before increments are limited.
44
     * @param int $periodInSeconds Period to apply limit to.
45
     */
46 9
    public function __construct(
47
        CacheInterface $storage,
48
        int $limit,
49
        int $periodInSeconds,
50
        int $ttlInSeconds = self::DEFAULT_TTL,
51
        string $cachePrefix = self::ID_PREFIX
52
    ) {
53 9
        if ($limit < 1) {
54 1
            throw new InvalidArgumentException('The limit must be a positive value.');
55
        }
56
57 8
        if ($periodInSeconds < 1) {
58 1
            throw new InvalidArgumentException('The period must be a positive value.');
59
        }
60
61 7
        $this->limit = $limit;
62 7
        $this->periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND;
63 7
        $this->storage = $storage;
64 7
        $this->ttlInSeconds = $ttlInSeconds;
65 7
        $this->cachePrefix = $cachePrefix;
66
67 7
        $this->incrementIntervalInMilliseconds = (float)($this->periodInMilliseconds / $this->limit);
68 7
    }
69
70
    /**
71
     * {@inheritdoc}
72
     */
73 7
    public function hit(string $id): CounterState
74
    {
75
        // Last increment time.
76
        // In GCRA it's known as arrival time.
77 7
        $lastIncrementTimeInMilliseconds = $this->currentTimeInMilliseconds();
78
79 7
        $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
80 7
            $lastIncrementTimeInMilliseconds,
81 7
            $this->getLastStoredTheoreticalNextIncrementTime($id, $lastIncrementTimeInMilliseconds)
82
        );
83
84 7
        $remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime);
85 7
        $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);
86
87 7
        if ($remaining >= 1) {
88 6
            $this->storeTheoreticalNextIncrementTime($id, $theoreticalNextIncrementTime);
89
        }
90
91 7
        return new CounterState($this->limit, $remaining, $resetAfter);
92
    }
93
94
    /**
95
     * @return float Theoretical increment time that would be expected from equally spaced increments at exactly rate
96
     * limit. In GCRA it is known as TAT, theoretical arrival time.
97
     */
98 7
    private function calculateTheoreticalNextIncrementTime(
99
        int $lastIncrementTimeInMilliseconds,
100
        float $storedTheoreticalNextIncrementTime
101
    ): float {
102 7
        return max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime) +
103 7
            $this->incrementIntervalInMilliseconds;
104
    }
105
106
    /**
107
     * @return int The number of remaining requests in the current time period.
108
     */
109 7
    private function calculateRemaining(int $lastIncrementTimeInMilliseconds, float $theoreticalNextIncrementTime): int
110
    {
111 7
        $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds;
112
113
        return (int)(
114 7
            round($lastIncrementTimeInMilliseconds - $incrementAllowedAt) /
115 7
            $this->incrementIntervalInMilliseconds
116
        );
117
    }
118
119 7
    private function getLastStoredTheoreticalNextIncrementTime(string $id, int $lastIncrementTimeInMilliseconds): float
120
    {
121 7
        return (float)$this->storage->get($this->getCacheKey($id), $lastIncrementTimeInMilliseconds);
122
    }
123
124 6
    private function storeTheoreticalNextIncrementTime(string $id, float $theoreticalNextIncrementTime): void
125
    {
126 6
        $this->storage->set($this->getCacheKey($id), $theoreticalNextIncrementTime, $this->ttlInSeconds);
127 6
    }
128
129
    /**
130
     * @return int Timestamp to wait until the rate limit resets.
131
     */
132 7
    private function calculateResetAfter(float $theoreticalNextIncrementTime): int
133
    {
134 7
        return (int)($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND);
135
    }
136
137 7
    private function currentTimeInMilliseconds(): int
138
    {
139 7
        return (int)round(microtime(true) * self::MILLISECONDS_PER_SECOND);
140
    }
141
142
    /**
143
     * @param string $id
144
     *
145
     * @return string Cache key used to store the next increment time.
146
     */
147 7
    private function getCacheKey(string $id): string
148
    {
149 7
        return $this->cachePrefix . $id;
150
    }
151
}
152