Issues (25)

src/Ganesha/Strategy/Rate.php (6 issues)

1
<?php
2
namespace Ackintosh\Ganesha\Strategy;
3
4
use Ackintosh\Ganesha;
5
use Ackintosh\Ganesha\Configuration;
6
use Ackintosh\Ganesha\Exception\StorageException;
7
use Ackintosh\Ganesha\Storage;
8
use Ackintosh\Ganesha\StrategyInterface;
9
use InvalidArgumentException;
10
use LogicException;
11
12
class Rate implements StrategyInterface
13
{
14
    /**
15
     * @var Configuration
16
     */
17
    private $configuration;
18
19
    /**
20
     * @var Storage
21
     */
22
    private $storage;
23
24
    /**
25
     * @var array
26
     */
27
    private static $requirements = [
28
        'adapter',
29
        'failureRateThreshold',
30
        'intervalToHalfOpen',
31
        'minimumRequests',
32
        'timeWindow',
33
    ];
34
35
    /**
36
     * @param Configuration $configuration
37
     */
38
    private function __construct(Configuration $configuration, Storage $storage)
39
    {
40
        $this->configuration = $configuration;
41
        $this->storage = $storage;
42
    }
43
44
    /**
45
     * @param array $params
46
     * @throws LogicException
47
     */
48
    public static function validate(array $params): void
49
    {
50
        foreach (self::$requirements as $r) {
51
            if (!isset($params[$r])) {
52
                throw new LogicException($r . ' is required');
53
            }
54
        }
55
56
        if (!call_user_func([$params['adapter'], 'supportRateStrategy'])) {
57
            throw new InvalidArgumentException(get_class($params['adapter']) . " doesn't support Rate Strategy.");
58
        }
59
    }
60
61
    /**
62
     * @param Configuration $configuration
63
     * @return Rate
64
     */
65
    public static function create(Configuration $configuration): StrategyInterface
66
    {
67
        $serviceNameDecorator = $configuration['adapter'] instanceof Storage\Adapter\TumblingTimeWindowInterface ? self::serviceNameDecorator($configuration['timeWindow']) : null;
0 ignored issues
show
It seems like $configuration['timeWindow'] can also be of type null; however, parameter $timeWindow of Ackintosh\Ganesha\Strate...:serviceNameDecorator() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

67
        $serviceNameDecorator = $configuration['adapter'] instanceof Storage\Adapter\TumblingTimeWindowInterface ? self::serviceNameDecorator(/** @scrutinizer ignore-type */ $configuration['timeWindow']) : null;
Loading history...
68
        $adapter = $configuration['adapter'];
69
        $adapter->setConfiguration($configuration);
0 ignored issues
show
The method setConfiguration() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

69
        $adapter->/** @scrutinizer ignore-call */ 
70
                  setConfiguration($configuration);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
70
71
        return new self(
72
            $configuration,
73
            new Storage(
74
                $adapter,
0 ignored issues
show
It seems like $adapter can also be of type null; however, parameter $adapter of Ackintosh\Ganesha\Storage::__construct() does only seem to accept Ackintosh\Ganesha\Storage\AdapterInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

74
                /** @scrutinizer ignore-type */ $adapter,
Loading history...
75
                $configuration['storageKeys'],
0 ignored issues
show
It seems like $configuration['storageKeys'] can also be of type null; however, parameter $storageKeys of Ackintosh\Ganesha\Storage::__construct() does only seem to accept Ackintosh\Ganesha\Storage\StorageKeysInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

75
                /** @scrutinizer ignore-type */ $configuration['storageKeys'],
Loading history...
76
                $serviceNameDecorator
77
            )
78
        );
79
    }
80
81
    /**
82
     * @param  string $service
83
     * @return int
84
     */
85
    public function recordFailure(string $service): int
86
    {
87
        $this->storage->setLastFailureTime($service, time());
88
        $this->storage->incrementFailureCount($service);
89
        if (
90
            $this->storage->getStatus($service) === Ganesha::STATUS_CALMED_DOWN
91
            && $this->isClosedInCurrentTimeWindow($service) === false
92
        ) {
93
            $this->storage->setStatus($service, Ganesha::STATUS_TRIPPED);
94
            return Ganesha::STATUS_TRIPPED;
95
        }
96
97
        return Ganesha::STATUS_CALMED_DOWN;
98
    }
99
100
    /**
101
     * @param  string $service
102
     * @return int
103
     */
104
    public function recordSuccess(string $service): ?int
105
    {
106
        $this->storage->incrementSuccessCount($service);
107
        $status = $this->storage->getStatus($service);
108
        if (
109
            $status === Ganesha::STATUS_TRIPPED
110
            && $this->isClosedInPreviousTimeWindow($service)
111
        ) {
112
            $this->storage->setStatus($service, Ganesha::STATUS_CALMED_DOWN);
113
            return Ganesha::STATUS_CALMED_DOWN;
114
        }
115
116
        return null;
117
    }
118
119
    /**
120
     * @return void
121
     */
122
    public function reset(): void
123
    {
124
        $this->storage->reset();
125
    }
126
127
    /**
128
     * @param  string $service
129
     * @return bool
130
     */
131
    public function isAvailable(string $service): bool
132
    {
133
        if ($this->isClosed($service) || $this->isHalfOpen($service)) {
134
            return true;
135
        }
136
137
        $this->storage->incrementRejectionCount($service);
138
        return false;
139
    }
140
141
    /**
142
     * @param  string $service
143
     * @return bool
144
     * @throws StorageException, \LogicException
145
     */
146
    private function isClosed(string $service): bool
147
    {
148
        switch (true) {
149
            case $this->storage->supportSlidingTimeWindow():
150
                return $this->isClosedInCurrentTimeWindow($service);
151
                break;
0 ignored issues
show
break is not strictly necessary here and could be removed.

The break statement is not necessary if it is preceded for example by a return statement:

switch ($x) {
    case 1:
        return 'foo';
        break; // This break is not necessary and can be left off.
}

If you would like to keep this construct to be consistent with other case statements, you can safely mark this issue as a false-positive.

Loading history...
152
            case $this->storage->supportTumblingTimeWindow():
153
                return $this->isClosedInCurrentTimeWindow($service) && $this->isClosedInPreviousTimeWindow($service);
154
                break;
155
            default:
156
                throw new LogicException(sprintf(
157
                    'storage adapter should implement %s and/or %s.',
158
                    Storage\Adapter\SlidingTimeWindowInterface::class,
159
                    Storage\Adapter\TumblingTimeWindowInterface::class
160
                ));
161
                break;
162
        }
163
    }
164
165
    /**
166
     * @param  string $service
167
     * @return bool
168
     */
169
    private function isClosedInCurrentTimeWindow(string $service): bool
170
    {
171
        $failure = $this->storage->getFailureCount($service);
172
        if (
173
            $failure === 0
174
            || ($failure / $this->configuration['minimumRequests']) * 100 < $this->configuration['failureRateThreshold']
175
        ) {
176
            return true;
177
        }
178
179
        $success = $this->storage->getSuccessCount($service);
180
        $rejection = $this->storage->getRejectionCount($service);
181
182
        return $this->isClosedInTimeWindow($failure, $success, $rejection);
183
    }
184
185
    /**
186
     * @param  string $service
187
     * @return bool
188
     */
189
    private function isClosedInPreviousTimeWindow(string $service): bool
190
    {
191
        $failure = $this->storage->getFailureCountByCustomKey(self::keyForPreviousTimeWindow($service, $this->configuration['timeWindow']));
0 ignored issues
show
It seems like $this->configuration['timeWindow'] can also be of type null; however, parameter $timeWindow of Ackintosh\Ganesha\Strate...ForPreviousTimeWindow() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

191
        $failure = $this->storage->getFailureCountByCustomKey(self::keyForPreviousTimeWindow($service, /** @scrutinizer ignore-type */ $this->configuration['timeWindow']));
Loading history...
192
        if (
193
            $failure === 0
194
            || ($failure / $this->configuration['minimumRequests']) * 100 < $this->configuration['failureRateThreshold']
195
        ) {
196
            return true;
197
        }
198
199
        $success = $this->storage->getSuccessCountByCustomKey(self::keyForPreviousTimeWindow($service, $this->configuration['timeWindow']));
200
        $rejection = $this->storage->getRejectionCountByCustomKey(self::keyForPreviousTimeWindow($service, $this->configuration['timeWindow']));
201
202
        return $this->isClosedInTimeWindow($failure, $success, $rejection);
203
    }
204
205
    /**
206
     * @param  int $failure
207
     * @param  int $success
208
     * @param  int $rejection
209
     * @return bool
210
     */
211
    private function isClosedInTimeWindow(int $failure, int $success, int $rejection): bool
212
    {
213
        if (($failure + $success + $rejection) < $this->configuration['minimumRequests']) {
214
            return true;
215
        }
216
217
        if (($failure / ($failure + $success)) * 100 < $this->configuration['failureRateThreshold']) {
218
            return true;
219
        }
220
221
        return false;
222
    }
223
224
    /**
225
     * @param  string $service
226
     * @return bool
227
     * @throws StorageException
228
     */
229
    private function isHalfOpen(string $service): bool
230
    {
231
        if (is_null($lastFailureTime = $this->storage->getLastFailureTime($service))) {
232
            return false;
233
        }
234
235
        if ((time() - $lastFailureTime) > $this->configuration['intervalToHalfOpen']) {
236
            $this->storage->setLastFailureTime($service, time());
237
            return true;
238
        }
239
240
        return false;
241
    }
242
243
    private static function serviceNameDecorator(int $timeWindow, $current = true)
244
    {
245
        return function ($service) use ($timeWindow, $current) {
246
            return sprintf(
247
                '%s.%d',
248
                $service,
249
                $current ? (int)floor(time() / $timeWindow) : (int)floor((time() - $timeWindow) / $timeWindow)
250
            );
251
        };
252
    }
253
254
    private static function keyForPreviousTimeWindow(string $service, int $timeWindow)
255
    {
256
        $f = self::serviceNameDecorator($timeWindow, false);
257
        return $f($service);
258
    }
259
}
260