Passed
Pull Request — master (#19)
by Alexander
02:25
created

Counter::incrementAndGetState()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 3

Importance

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