Passed
Push — master ( 2cc434...23a4c1 )
by Alexander
03:31
created

Counter::incrementAndGetState()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 1
b 0
f 0
nc 3
nop 0
dl 0
loc 18
rs 9.9332
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 DEFAULT_TTL = 86400;
18
19
    private const ID_PREFIX = 'rate-limiter-';
20
21
    private const MILLISECONDS_PER_SECOND = 1000;
22
23
    /**
24
     * @var int period to apply limit to, in milliseconds
25
     */
26
    private int $period;
27
28
    private int $limit;
29
30
    /**
31
     * @var float maximum interval before next increment, in milliseconds
32
     * In GCRA it is known as emission interval.
33
     */
34
    private float $incrementInterval;
35
36
    private ?string $id = null;
37
38
    private CacheInterface $storage;
39
40
    private int $ttl = self::DEFAULT_TTL;
41
42
    /**
43
     * @var int last increment time
44
     * In GCRA it's known as arrival time
45
     */
46
    private int $lastIncrementTime;
47
48
    /**
49
     * @param int $limit maximum number of increments that could be performed before increments are limited
50
     * @param int $period period to apply limit to, in seconds
51
     * @param CacheInterface $storage
52
     */
53
    public function __construct(int $limit, int $period, CacheInterface $storage)
54
    {
55
        if ($limit < 1) {
56
            throw new \InvalidArgumentException('The limit must be a positive value.');
57
        }
58
59
        if ($period < 1) {
60
            throw new \InvalidArgumentException('The period must be a positive value.');
61
        }
62
63
        $this->limit = $limit;
64
        $this->period = $period * self::MILLISECONDS_PER_SECOND;
65
        $this->storage = $storage;
66
67
        $this->incrementInterval = (float)($this->period / $this->limit);
68
    }
69
70
    public function setId(string $id): void
71
    {
72
        $this->id = $id;
73
    }
74
75
    /**
76
     * @param int $ttl cache TTL that is used to store counter values
77
     * Default is one day.
78
     * Note that period can not exceed TTL.
79
     */
80
    public function setTtl(int $ttl): void
81
    {
82
        $this->ttl = $ttl;
83
    }
84
85
    public function getCacheKey(): string
86
    {
87
        return self::ID_PREFIX . $this->id;
88
    }
89
90
    public function incrementAndGetState(): CounterState
91
    {
92
        if ($this->id === null) {
93
            throw new \LogicException('The counter ID should be set');
94
        }
95
96
        $this->lastIncrementTime = $this->getCurrentTime();
97
        $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
98
            $this->getLastStoredTheoreticalNextIncrementTime()
99
        );
100
        $remaining = $this->calculateRemaining($theoreticalNextIncrementTime);
101
        $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);
102
103
        if ($remaining >= 1) {
104
            $this->storeTheoreticalNextIncrementTime($theoreticalNextIncrementTime);
105
        }
106
107
        return new CounterState($this->limit, $remaining, $resetAfter);
108
    }
109
110
    /**
111
     * @param float $storedTheoreticalNextIncrementTime
112
     * @return float theoretical increment time that would be expected from equally spaced increments at exactly rate limit
113
     * In GCRA it is known as TAT, theoretical arrival time.
114
     */
115
    private function calculateTheoreticalNextIncrementTime(float $storedTheoreticalNextIncrementTime): float
116
    {
117
        return max($this->lastIncrementTime, $storedTheoreticalNextIncrementTime) + $this->incrementInterval;
118
    }
119
120
    /**
121
     * @param float $theoreticalNextIncrementTime
122
     * @return int the number of remaining requests in the current time period
123
     */
124
    private function calculateRemaining(float $theoreticalNextIncrementTime): int
125
    {
126
        $incrementAllowedAt = $theoreticalNextIncrementTime - $this->period;
127
128
        return (int)(round($this->lastIncrementTime - $incrementAllowedAt) / $this->incrementInterval);
129
    }
130
131
    private function getLastStoredTheoreticalNextIncrementTime(): float
132
    {
133
        return $this->storage->get($this->getCacheKey(), (float)$this->lastIncrementTime);
134
    }
135
136
    private function storeTheoreticalNextIncrementTime(float $theoreticalNextIncrementTime): void
137
    {
138
        $this->storage->set($this->getCacheKey(), $theoreticalNextIncrementTime, $this->ttl);
139
    }
140
141
    /**
142
     * @param float $theoreticalNextIncrementTime
143
     * @return int timestamp to wait until the rate limit resets
144
     */
145
    private function calculateResetAfter(float $theoreticalNextIncrementTime): int
146
    {
147
        return (int)($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND);
148
    }
149
150
    private function getCurrentTime(): int
151
    {
152
        return (int)round(microtime(true) * self::MILLISECONDS_PER_SECOND);
153
    }
154
}
155