Refactor and centralize Shelly rate-limiting logic in the shelly class, add global enforcement and unit tests, and remove redundant implementations in self-serve lane controllers.
This commit is contained in:
@@ -13,6 +13,10 @@ use shelly\shelly_c;
|
||||
|
||||
class shelly implements shelly_i
|
||||
{
|
||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
|
||||
|
||||
/**
|
||||
* Configuration of the shelly module
|
||||
* @var shelly_c
|
||||
@@ -146,6 +150,7 @@ class shelly implements shelly_i
|
||||
self::requireModuleEnabled();
|
||||
self::requireValidSecretKey();
|
||||
self::requireValidServerURL();
|
||||
$this->waitForShellyRateLimitWindow();
|
||||
// Send the request
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint));
|
||||
@@ -185,4 +190,86 @@ class shelly implements shelly_i
|
||||
{
|
||||
return $url . '?auth_key=' . $this->config->secret_key->getVariableValue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function waitForShellyRateLimitWindow(): void
|
||||
{
|
||||
$deadline = $this->nowTimestamp() + self::SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS;
|
||||
|
||||
do {
|
||||
if ($this->tryAcquireShellyRateLimitSlot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->nowTimestamp() >= $deadline) {
|
||||
throw new Exception('Shelly rate limit gate wait timed out');
|
||||
}
|
||||
|
||||
$remaining_ms = $this->getShellyRateLimitSlotRemainingMs();
|
||||
if ($remaining_ms <= 0) {
|
||||
$remaining_ms = 50;
|
||||
}
|
||||
$this->sleepMicroseconds(min($remaining_ms, 250) * 1000);
|
||||
} while (true);
|
||||
}
|
||||
|
||||
private function tryAcquireShellyRateLimitSlot(): bool
|
||||
{
|
||||
$redis = $this->redisFacade();
|
||||
if ($redis === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $redis->get_client()->set(
|
||||
self::SHELLY_RATE_LIMIT_GATE_KEY,
|
||||
(string)$this->nowTimestamp(),
|
||||
'PX',
|
||||
self::SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS,
|
||||
'NX'
|
||||
);
|
||||
return $result === true || strtoupper((string)$result) === 'OK';
|
||||
} catch (\Throwable) {
|
||||
// If Redis gate can't be evaluated, fail open to avoid blocking API traffic completely.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function getShellyRateLimitSlotRemainingMs(): int
|
||||
{
|
||||
$redis = $this->redisFacade();
|
||||
if ($redis === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$ttl = $redis->get_client()->pttl(self::SHELLY_RATE_LIMIT_GATE_KEY);
|
||||
if (!is_numeric($ttl)) {
|
||||
return 0;
|
||||
}
|
||||
$ttl = (int)$ttl;
|
||||
return $ttl > 0 ? $ttl : 0;
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected function redisFacade(): mixed
|
||||
{
|
||||
return defined('redis') ? redis : null;
|
||||
}
|
||||
|
||||
protected function nowTimestamp(): float
|
||||
{
|
||||
return microtime(true);
|
||||
}
|
||||
|
||||
protected function sleepMicroseconds(int $microseconds): void
|
||||
{
|
||||
if ($microseconds > 0) {
|
||||
usleep($microseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user