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:
Jeppe Bundgaard
2026-03-26 20:15:36 +01:00
parent 0d825fb45a
commit b42a1a69a0
12 changed files with 415 additions and 111 deletions
+88 -1
View File
@@ -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);
}
}
}