Passed
Pull Request — master (#204)
by Alexander
02:59 queued 10s
created

Counter::calculateRemaining()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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