Counter::calculateResetAfter()   A
last analyzed

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 1
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\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 DEFAULT_TTL = 86400;
21
    private const ID_PREFIX = 'rate-limiter-';
22
    private const MILLISECONDS_PER_SECOND = 1000;
23
24
    /**
25
     * @var int Period to apply limit to.
26
     */
27
    private int $periodInMilliseconds;
28
29
    /**
30
     * @var float Maximum interval before next increment.
31
     * In GCRA it is known as emission interval.
32
     */
33
    private float $incrementIntervalInMilliseconds;
34
    private TimerInterface $timer;
35
36
    /**
37
     * @param StorageInterface $storage Storage to use for counter values.
38
     * @param int $limit Maximum number of increments that could be performed before increments are limited.
39
     * @param int $periodInSeconds Period to apply limit to.
40
     * @param int $storageTtlInSeconds Storage TTL. Should be higher than `$periodInSeconds`.
41
     * @param string $storagePrefix Storage prefix.
42
     * @param TimerInterface|null $timer Timer instance to get current time from.
43
     */
44 10
    public function __construct(
45
        private StorageInterface $storage,
46
        private int $limit,
47
        private int $periodInSeconds,
48
        private int $storageTtlInSeconds = self::DEFAULT_TTL,
49
        private string $storagePrefix = self::ID_PREFIX,
50
        TimerInterface|null $timer = null
51
    ) {
52 10
        if ($limit < 1) {
53 1
            throw new InvalidArgumentException('The limit must be a positive value.');
54
        }
55
56 9
        if ($periodInSeconds < 1) {
57 1
            throw new InvalidArgumentException('The period must be a positive value.');
58
        }
59
60 8
        $this->limit = $limit;
61 8
        $this->periodInMilliseconds = $periodInSeconds * self::MILLISECONDS_PER_SECOND;
62 8
        $this->timer = $timer ?: new MicrotimeTimer();
63 8
        $this->incrementIntervalInMilliseconds = $this->periodInMilliseconds / $this->limit;
64
    }
65
66
    /**
67
     * {@inheritdoc}
68
     */
69 7
    public function hit(string $id): CounterState
70
    {
71
        // Last increment time.
72
        // In GCRA it's known as arrival time.
73 7
        $lastIncrementTimeInMilliseconds = $this->timer->nowInMilliseconds();
74
75 7
        $theoreticalNextIncrementTime = $this->calculateTheoreticalNextIncrementTime(
76 7
            $lastIncrementTimeInMilliseconds,
77 7
            $this->getLastStoredTheoreticalNextIncrementTime($id, $lastIncrementTimeInMilliseconds)
78 7
        );
79
80 7
        $remaining = $this->calculateRemaining($lastIncrementTimeInMilliseconds, $theoreticalNextIncrementTime);
81 7
        $resetAfter = $this->calculateResetAfter($theoreticalNextIncrementTime);
82
83 7
        if ($remaining >= 1) {
84 6
            $this->storeTheoreticalNextIncrementTime($id, $theoreticalNextIncrementTime);
85
        }
86
87 7
        return new CounterState($this->limit, $remaining, $resetAfter);
88
    }
89
90
    /**
91
     * @return float Theoretical increment time that would be expected from equally spaced increments at exactly rate
92
     * limit. In GCRA it is known as TAT, theoretical arrival time.
93
     */
94 7
    private function calculateTheoreticalNextIncrementTime(
95
        int $lastIncrementTimeInMilliseconds,
96
        float $storedTheoreticalNextIncrementTime
97
    ): float {
98 7
        return max($lastIncrementTimeInMilliseconds, $storedTheoreticalNextIncrementTime) +
99 7
            $this->incrementIntervalInMilliseconds;
100
    }
101
102
    /**
103
     * @return int The number of remaining requests in the current time period.
104
     */
105 7
    private function calculateRemaining(int $lastIncrementTimeInMilliseconds, float $theoreticalNextIncrementTime): int
106
    {
107 7
        $incrementAllowedAt = $theoreticalNextIncrementTime - $this->periodInMilliseconds;
108
109 7
        return (int) (
110 7
            round($lastIncrementTimeInMilliseconds - $incrementAllowedAt) /
111 7
            $this->incrementIntervalInMilliseconds
112 7
        );
113
    }
114
115 7
    private function getLastStoredTheoreticalNextIncrementTime(string $id, int $lastIncrementTimeInMilliseconds): float
116
    {
117 7
        return (float) $this->storage->get($this->getStorageKey($id), $lastIncrementTimeInMilliseconds);
118
    }
119
120 6
    private function storeTheoreticalNextIncrementTime(string $id, float $theoreticalNextIncrementTime): void
121
    {
122 6
        $this->storage->save($this->getStorageKey($id), $theoreticalNextIncrementTime, $this->storageTtlInSeconds);
123
    }
124
125
    /**
126
     * @return int Timestamp to wait until the rate limit resets.
127
     */
128 7
    private function calculateResetAfter(float $theoreticalNextIncrementTime): int
129
    {
130 7
        return (int) ($theoreticalNextIncrementTime / self::MILLISECONDS_PER_SECOND);
131
    }
132
133
    /**
134
     * @return string Storage key used to store the next increment time.
135
     */
136 8
    private function getStorageKey(string $id): string
137
    {
138 8
        return $this->storagePrefix . $id;
139
    }
140
}
141