Passed
Pull Request — master (#204)
by
unknown
01:59
created

Counter::calculateTheoreticalNextIncrementTime()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 3
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 = $this->getCurrentTime();
83
        $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
84
            $this->getLastStoredTheoreticalNextIncrementTime()
85
        );
86
        $remaining = $this->calculateRemaining($theoreticalNextIncrementTime);
87
        $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);
88
89
        if ($remaining >= 1) {
90
            $this->storeTheoreticalNextIncrementTime($theoreticalNextIncrementTime);
91
        }
92
93
        return new CounterStatistics($this->limit, $remaining, $resetAfter);
94
    }
95
96
    /**
97
     * @param float $storedTheoreticalNextIncrementTime
98
     * @return float theoretical increment time that would be expected from equally spaced increments at exactly rate limit
99
     * In GCRA it is known as TAT, theoretical arrival time.
100
     */
101
    private function calculateTheoreticalNextIncrementTime(float $storedTheoreticalNextIncrementTime): float
102
    {
103
        return max($this->lastIncrementTime, $storedTheoreticalNextIncrementTime) + $this->incrementInterval;
104
    }
105
106
    /**
107
     * @param float $theoreticalNextIncrementTime
108
     * @return int the number of remaining requests in the current time period
109
     */
110
    private function calculateRemaining(float $theoreticalNextIncrementTime): int
111
    {
112
        $incrementAllowedAt = $theoreticalNextIncrementTime - $this->period;
113
114
        return (int)(round($this->lastIncrementTime - $incrementAllowedAt) / $this->incrementInterval);
115
    }
116
117
    private function getLastStoredTheoreticalNextIncrementTime(): float
118
    {
119
        return $this->storage->get($this->getCacheKey(), (float)$this->lastIncrementTime);
120
    }
121
122
    private function storeTheoreticalNextIncrementTime(float $theoreticalNextIncrementTime): void
123
    {
124
        $this->storage->set($this->getCacheKey(), $theoreticalNextIncrementTime);
125
    }
126
127
    /**
128
     * @param float $theoreticalNextIncrementTime
129
     * @return int timestamp to wait until the rate limit resets
130
     */
131
    private function calculateResetAfter(float $theoreticalNextIncrementTime): int
132
    {
133
        return (int)($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND);
134
    }
135
136
    private function getCurrentTime(): int
137
    {
138
        return (int)round(microtime(true) * self::MILLISECONDS_PER_SECOND);
139
    }
140
}
141