Test Failed
Pull Request — master (#43)
by
unknown
12:03
created

Counter::storeTheoreticalNextIncrementTime()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\RateLimiter;
6
7
use InvalidArgumentException;
8
use Yiisoft\Yii\RateLimiter\Exception\OutOfMaxAttemptsException;
9
use Yiisoft\Yii\RateLimiter\Storage\StorageInterface;
10
use Yiisoft\Yii\RateLimiter\Time\MicrotimeTimer;
11
use Yiisoft\Yii\RateLimiter\Time\TimerInterface;
12
13
/**
14
 * Counter implements generic cell rate limit algorithm (GCRA) that ensures that after reaching the limit further
15
 * increments are distributed equally.
16
 *
17
 * @link https://en.wikipedia.org/wiki/Generic_cell_rate_algorithm
18
 */
19
final class Counter implements CounterInterface
20
{
21
    private const DEFAULT_TTL = 86400;
22
    private const ID_PREFIX = 'rate-limiter-';
23
    private const MILLISECONDS_PER_SECOND = 1000;
24
    private const DEFAULT_MAX_CAS_ATTEMPTS = 10;
25
26
    /**
27
     * @var int Period to apply limit to.
28
     */
29
    private int $periodInMilliseconds;
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
    private TimerInterface $timer;
37
38
    /**
39
     * @param StorageInterface $storage Storage to use for counter values.
40
     * @param int $limit Maximum number of increments that could be performed before increments are limited.
41
     * @param int $periodInSeconds Period to apply limit to.
42
     * @param int $storageTtlInSeconds Storage TTL. Should be higher than `$periodInSeconds`.
43
     * @param string $storagePrefix Storage prefix.
44 10
     * @param TimerInterface|null $timer Timer instance to get current time from.
45
     * @param int $maxCASAttempts Maximum number of times to retry saveIfNotExists/saveCompareAndSwap operations before returning an error.
46
     */
47
    public function __construct(
48
        private StorageInterface $storage,
49
        private int $limit,
50
        private int $periodInSeconds,
51
        private int $storageTtlInSeconds = self::DEFAULT_TTL,
52 10
        private string $storagePrefix = self::ID_PREFIX,
53 1
        TimerInterface|null $timer = null,
54
        private int $maxCasAttempts = self::DEFAULT_MAX_CAS_ATTEMPTS
55
    ) {
56 9
        if ($limit < 1) {
57 1
            throw new InvalidArgumentException('The limit must be a positive value.');
58
        }
59
60 8
        if ($periodInSeconds < 1) {
61 8
            throw new InvalidArgumentException('The period must be a positive value.');
62 8
        }
63 8
64
        $this->limit = $limit;
65
        $this->periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND;
66
        $this->timer = $timer ?: new MicrotimeTimer();
67
        $this->incrementIntervalInMilliseconds = $this->periodInMilliseconds / $this->limit;
68
    }
69 7
70
    /**
71
     * {@inheritdoc}
72
     */
73 7
    public function hit(string $id): CounterState
74
    {
75 7
        $attempts = 0; 
76 7
        do {
77 7
            // Last increment time.
78 7
            // In GCRA it's known as arrival time.
79
            $lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds();
80 7
81 7
            $lastStoredTheoreticalNextIncrementTime = $this->getLastStoredTheoreticalNextIncrementTime($id);
82
83 7
            $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
84 6
                $lastIncrementTimeInMilliseconds,
85
                $lastStoredTheoreticalNextIncrementTime
86
            );
87 7
88
            $remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime);
89
            $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);
90
91
            if ($remaining >= 1) {
92
                $isStored = $this->storeTheoreticalNextIncrementTime($id, $theoreticalNextIncrementTime, $lastStoredTheoreticalNextIncrementTime);
93
                if ($isStored) {
94 7
                    break;
95
                }
96
97
                $attempts++;
98 7
                if ($attempts >= $this->maxCASAttempts) {
99 7
                    throw new OutOfMaxAttemptsException(
100
                        sprintf(
101
                            "Failed to store updated rate limit data for key "%s" after %d attempts",
0 ignored issues
show
Bug introduced by
A parse error occurred: Syntax error, unexpected T_CONSTANT_ENCAPSED_STRING, expecting ',' or ')' on line 101 at column 80
Loading history...
102
                            $id, $this->maxCASAttempts
103
                        )
104
                    );
105 7
                }
106
            } else {
107 7
                break;
108
            }
109 7
        } while (true);
110 7
111 7
        return new CounterState($this->limit, $remaining, $resetAfter);
112 7
    }
113
114
    /**
115 7
     * @return float Theoretical increment time that would be expected from equally spaced increments at exactly rate
116
     * limit. In GCRA it is known as TAT, theoretical arrival time.
117 7
     */
118
    private function calculateTheoreticalNextIncrementTime(
119
        float $lastIncrementTimeInMilliseconds,
120 6
        float $storedTheoreticalNextIncrementTime
121
    ): float {
122 6
        return max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime) +
123
            $this->incrementIntervalInMilliseconds;
124
    }
125
126
    /**
127
     * @return int The number of remaining requests in the current time period.
128 7
     */
129
    private function calculateRemaining(float $lastIncrementTimeInMilliseconds, float $theoreticalNextIncrementTime): int
130 7
    {
131
        $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds;
132
133
        return (int) (
134
            round($lastIncrementTimeInMilliseconds - $incrementAllowedAt) /
135
            $this->incrementIntervalInMilliseconds
136 8
        );
137
    }
138 8
139
    private function getLastStoredTheoreticalNextIncrementTime(string $id): float
140
    {
141
        return (float) $this->storage->get($this->getStorageKey($id));
142
    }
143
144
    private function storeTheoreticalNextIncrementTime(string $id, float $theoreticalNextIncrementTime, float $lastStoredTheoreticalNextIncrementTime): bool
145
    {
146
        if ($lastStoredTheoreticalNextIncrementTime !== 0.0) {
147
            return $this->storage->saveCompareAndSwap(
148
                $this->getStorageKey($id), 
149
                $lastStoredTheoreticalNextIncrementTime, 
150
                $theoreticalNextIncrementTime, 
151
                $this->storageTtlInSeconds
152
            );
153
        }
154
        
155
        return $this->storage->saveIfNotExists(
156
            $this->getStorageKey($id), 
157
            $theoreticalNextIncrementTime, 
158
            $this->storageTtlInSeconds
159
        );
160
    }
161
162
    /**
163
     * @return int Timestamp to wait until the rate limit resets.
164
     */
165
    private function calculateResetAfter(float $theoreticalNextIncrementTime): int
166
    {
167
        return (int) ($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND);
168
    }
169
170
    /**
171
     * @return string Storage key used to store the next increment time.
172
     */
173
    private function getStorageKey(string $id): string
174
    {
175
        return $this->storagePrefix . $id;
176
    }
177
}
178