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
Binary file not shown.
+88 -1
View File
@@ -13,6 +13,10 @@ use shelly\shelly_c;
class shelly implements shelly_i 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 * Configuration of the shelly module
* @var shelly_c * @var shelly_c
@@ -146,6 +150,7 @@ class shelly implements shelly_i
self::requireModuleEnabled(); self::requireModuleEnabled();
self::requireValidSecretKey(); self::requireValidSecretKey();
self::requireValidServerURL(); self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
// Send the request // Send the request
$ch = curl_init(); $ch = curl_init();
curl_setopt($ch, CURLOPT_URL, self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint)); 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(); 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);
}
}
}
@@ -50,14 +50,14 @@ trait selfserve_lane_command_t
} }
/** /**
* Resolve whether the program selector relay currently reports ON. * Resolve whether the program selector relay is currently online.
* Fail-closed to false when relay status cannot be read. * Fail-closed to false when relay status cannot be read.
*/ */
protected function isProgramSelectorRelayOnForStop(): bool protected function isProgramSelectorRelayOnlineForStop(): bool
{ {
try { try {
$status = $this->getMachineProgramPickerRelayStatus(); $status = $this->getMachineProgramPickerRelayStatus();
return (bool)($status['on'] ?? false); return (bool)($status['online'] ?? false);
} catch (\Throwable) { } catch (\Throwable) {
return false; return false;
} }
@@ -128,14 +128,12 @@ trait selfserve_lane_command_t
/** /**
* Disable relays after STOP in deterministic order: * Disable relays after STOP in deterministic order:
* 1. Cleaner relay * 1. Cleaner relay
* 2. Program selector relay * 2. Machine relay
* 3. Machine relay
*/ */
protected function turnOffRelaysAfterStop(): void protected function turnOffRelaysAfterStop(): void
{ {
$relays = [ $relays = [
selfserve_lane_relay::MACHINE_CLEANER, selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE, selfserve_lane_relay::MACHINE,
]; ];
@@ -322,20 +320,20 @@ trait selfserve_lane_command_t
if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) { if (($this->getCustomerNumber() !== $arguments->customer_number) && !$this->isBypassCustomerNumberValidation()) {
throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number); throw new \InvalidArgumentException("Customer number mismatch: Lane customer number " . $this->getCustomerNumber() . " does not match argument customer number " . $arguments->customer_number);
} }
// Snapshot program selector state before relay shutdown. // Snapshot selector relay online state before relay shutdown.
$program_selector_on = $this->isProgramSelectorRelayOnForStop(); $program_selector_online = $this->isProgramSelectorRelayOnlineForStop();
// Open the exit port // Open the exit port
$this->open(selfserve_lane_port::EXIT); $this->open(selfserve_lane_port::EXIT);
// Turn off configured relays in deterministic order. // Turn off relays in deterministic order after STOP
$this->turnOffRelaysAfterStop(); $this->turnOffRelaysAfterStop();
// Log the lane stop event // Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH); $this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Finalize any active self-serve wash session before resetting lane state.
$this->completeLatestSessionForStop();
// Invoice the customer // Invoice the customer
$this->invoice(); $this->invoice();
// If program selector was ON, add vehicle-type product to the self-serve order. // If program selector relay is online at stop time, bill the primary product.
$this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_on); $this->addVehicleTypeProductToInvoiceIfNeeded($program_selector_online);
// Finalize any active self-serve wash session for this lane
$this->completeLatestSessionForStop();
// Reset the lane // Reset the lane
self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); self::execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
break; break;
@@ -59,21 +59,17 @@ trait selfserve_lane_port_controller_t
*/ */
public function shellyOpenPort(selfserve_lane_port $port): bool public function shellyOpenPort(selfserve_lane_port $port): bool
{ {
// Get the relay ID based on the port // Queue the relay switch command to be executed asynchronously
$relay_id = match ($port) { $relay_id = match ($port) {
selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(), selfserve_lane_port::ENTRANCE => $this->department_lane->relay_out_id->value(),
selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(), selfserve_lane_port::EXIT => $this->department_lane->relay_in_id->value(),
default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"), default => throw new \Exception("Invalid port specified, must be ENTRANCE or EXIT"),
}; };
// Make sure relay ID is valid // If the relay ID indicates a demo port, skip the Shelly switch call but keep the logging and state changes
if (empty($relay_id)) { if ($this->isDemoPortRelayId($relay_id)) {
throw new \Exception("Invalid relay ID for port {$port->name}"); return true;
} }
//if ($this->isDemoPortRelayId((string)$relay_id)) {
// return true;
//}
$device = $this->createShellySwitchDevice(); $device = $this->createShellySwitchDevice();
$device->id = (string)$relay_id; $device->id = (string)$relay_id;
$device->switch(true); $device->switch(true);
@@ -14,8 +14,6 @@ trait selfserve_lane_relay_controller_t
{ {
private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20; private const SHELLY_STATUS_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000; private const SHELLY_RETRY_SLEEP_MICROSECONDS = 250000;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 1000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'selfserve_shelly_cloud_rate_limit_gate';
private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1; private const SHELLY_STATUS_SNAPSHOT_TTL_SECONDS = 1;
private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_'; private const SHELLY_STATUS_SNAPSHOT_KEY_PREFIX = 'selfserve_lane_shelly_status_snapshot_';
private const SHELLY_DEFAULT_CHANNEL = 0; private const SHELLY_DEFAULT_CHANNEL = 0;
@@ -94,6 +92,16 @@ trait selfserve_lane_relay_controller_t
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on); return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
} }
/**
* Set PROGRAM SELECTOR relay status directly, bypassing lane status guards.
* Intended for department-level operational toggles.
* @throws \Exception
*/
public function setProgramSelectorRelayStatusHard(bool $on): bool
{
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
}
/** /**
* Set MACHINE_PROGRAM_PICKER relay status directly. * Set MACHINE_PROGRAM_PICKER relay status directly.
* @param bool $on true to turn on, false to turn off * @param bool $on true to turn on, false to turn off
@@ -349,7 +357,7 @@ trait selfserve_lane_relay_controller_t
$last_reason = 'Shelly relay status is not ready yet'; $last_reason = 'Shelly relay status is not ready yet';
do { do {
$result = $this->sendShellyPostRateLimited('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids)); $result = $this->sendShellyPost('/v2/devices/api/get', $this->buildStatusGetPayload($cloud_relay_ids));
$snapshot = $this->appendDemoRelaySnapshots( $snapshot = $this->appendDemoRelaySnapshots(
$this->mapResponseToRelaySnapshot($result), $this->mapResponseToRelaySnapshot($result),
$demo_relay_ids $demo_relay_ids
@@ -667,80 +675,6 @@ trait selfserve_lane_relay_controller_t
); );
} }
/**
* @throws \Exception
*/
private function sendShellyPostRateLimited(string $endpoint, array $payload): array|object|null
{
$this->waitForShellyRateLimitWindow();
return $this->sendShellyPost($endpoint, $payload);
}
/**
* @throws \Exception
*/
private function waitForShellyRateLimitWindow(): void
{
$deadline = $this->nowTimestamp() + self::SHELLY_STATUS_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 relay control 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;
}
}
/** /**
* @throws \Exception * @throws \Exception
*/ */
@@ -890,7 +824,7 @@ trait selfserve_lane_relay_controller_t
$response = $this->isDemoRelayId($relay_id) $response = $this->isDemoRelayId($relay_id)
? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)] ? [$this->buildDemoRelaySnapshotEntry($relay_id, $on)]
: $this->sendShellyPostRateLimited('/v2/devices/api/set/switch', $payload); : $this->sendShellyPost('/v2/devices/api/set/switch', $payload);
$this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response); $this->seedLaneShellySnapshotFromSwitch($relay_id, $on, $response);
return true; return true;
} }
@@ -47,6 +47,14 @@ class shelly_device_switch extends shelly_device_state
'on' => $on, 'on' => $on,
...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]), ...($skip_toggle_after ? [] : ['toggle_after' => (int)$this->toggle_after]),
]; ];
return $this->sendShellySwitchRequest($parameters);
}
/**
* @throws Exception
*/
protected function sendShellySwitchRequest(array $parameters): array|object|null
{
return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters); return (new shelly())->sendPostRequest('/v2/devices/api/set/switch', $parameters);
} }
} }
@@ -49,6 +49,18 @@ class SelfserveLanePortSwitchFake extends shelly_device_switch
} }
} }
class SelfservePortSwitchOutputFake extends shelly_device_switch
{
/** @var array<int,array> */
public array $switchCalls = [];
protected function sendShellySwitchRequest(array $parameters): array|object|null
{
$this->switchCalls[] = $parameters;
return ['ok' => true];
}
}
class SelfserveLanePortControllerHarness class SelfserveLanePortControllerHarness
{ {
use selfserve_lane_port_controller_t; use selfserve_lane_port_controller_t;
@@ -131,3 +143,15 @@ it('keeps demo relay gate-open queued but skips Shelly switch calls', function (
expect($lane->laneState)->toBe(selfserve_lane_state::EXIT_PORT_OPEN_QUEUED); expect($lane->laneState)->toBe(selfserve_lane_state::EXIT_PORT_OPEN_QUEUED);
expect($lane->switchFake->switchCalls)->toBe([]); expect($lane->switchFake->switchCalls)->toBe([]);
}); });
it('does not print Shelly switch responses to output', function (): void {
$switch = new SelfservePortSwitchOutputFake();
$switch->id = 'relay-port-123';
ob_start();
$switch->switch(true);
$output = (string)ob_get_clean();
expect($output)->toBe('');
expect($switch->switchCalls)->toHaveCount(1);
});
@@ -306,7 +306,7 @@ it('batches sequential machine relay status requests into a single Shelly get ca
expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']); expect($harness->shellyCalls[0]['payload']['select'])->toBe(['status']);
}); });
it('respects the 1 request per second Shelly gate for back-to-back requests', function (): void { it('keeps back-to-back get/switch requests ordered through the relay controller', function (): void {
$harness = selfserve_lane_shelly_test_harness(); $harness = selfserve_lane_shelly_test_harness();
$harness->queueShellyResponse('/v2/devices/api/get', [ $harness->queueShellyResponse('/v2/devices/api/get', [
[ [
@@ -330,7 +330,30 @@ it('respects the 1 request per second Shelly gate for back-to-back requests', fu
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1); expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get'); expect($harness->shellyCalls[0]['endpoint'])->toBe('/v2/devices/api/get');
expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch'); expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch');
expect(array_sum($harness->sleepCalls))->toBeGreaterThanOrEqual(1000000); });
it('executes sequential switch requests used by wash start and stop flows', function (): void {
$harness = selfserve_lane_shelly_test_harness();
for ($i = 0; $i < 5; $i++) {
$harness->queueShellyResponse('/v2/devices/api/set/switch', [
[
'id' => 'relay-machine',
'online' => true,
'status' => ['switch:0' => ['output' => true]],
],
]);
}
// START-like sequence.
$harness->setMachineCleanerRelayStatusHard(true);
$harness->setMachineRelayStatusHard(true);
// STOP-like sequence.
$harness->setMachineCleanerRelayStatusHard(false);
$harness->setMachineProgramPickerRelayStatusHard(false);
$harness->setMachineRelayStatusHard(false);
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(5);
}); });
it('retries missing Shelly status payloads until relay status becomes ready', function (): void { it('retries missing Shelly status payloads until relay status becomes ready', function (): void {
@@ -68,14 +68,17 @@ class SelfserveLaneStopFlowHarness
private int $washStartTime = 120; private int $washStartTime = 120;
private ?int $reservationStartTime = null; private ?int $reservationStartTime = null;
private bool $bypassCustomerValidation = false; private bool $bypassCustomerValidation = false;
private bool $programSelectorOnline;
private bool $programSelectorOn; private bool $programSelectorOn;
public function __construct( public function __construct(
bool $programSelectorOn, bool $programSelectorOnline,
bool $programSelectorOn = true,
string $machineRelayId = 'relay-machine', string $machineRelayId = 'relay-machine',
string $programRelayId = 'relay-program', string $programRelayId = 'relay-program',
string $cleanerRelayId = 'relay-cleaner' string $cleanerRelayId = 'relay-cleaner'
) { ) {
$this->programSelectorOnline = $programSelectorOnline;
$this->programSelectorOn = $programSelectorOn; $this->programSelectorOn = $programSelectorOn;
$this->department_lane = new SelfserveLaneCommandDepartmentLaneFake( $this->department_lane = new SelfserveLaneCommandDepartmentLaneFake(
$machineRelayId, $machineRelayId,
@@ -162,7 +165,10 @@ class SelfserveLaneStopFlowHarness
public function getMachineProgramPickerRelayStatus(): array public function getMachineProgramPickerRelayStatus(): array
{ {
return ['on' => $this->programSelectorOn]; return [
'online' => $this->programSelectorOnline,
'on' => $this->programSelectorOn,
];
} }
public function open(selfserve_lane_port $port): bool public function open(selfserve_lane_port $port): bool
@@ -193,8 +199,8 @@ class SelfserveLaneStopFlowHarness
} }
} }
it('adds vehicle type product on STOP when program selector is on, then turns off cleaner machine and selector relays', function (): void { it('adds vehicle type product on STOP when program selector relay is online, then turns off cleaner and machine relays', function (): void {
$lane = new SelfserveLaneStopFlowHarness(programSelectorOn: true); $lane = new SelfserveLaneStopFlowHarness(programSelectorOnline: true);
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234); $args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
$lane->execute(selfserve_lane_command::STOP, $args); $lane->execute(selfserve_lane_command::STOP, $args);
@@ -205,15 +211,14 @@ it('adds vehicle type product on STOP when program selector is on, then turns of
expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]); expect($lane->openedPorts)->toBe([selfserve_lane_port::EXIT]);
expect($lane->turnedOffRelays)->toBe([ expect($lane->turnedOffRelays)->toBe([
selfserve_lane_relay::MACHINE_CLEANER, selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE, selfserve_lane_relay::MACHINE,
]); ]);
expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE); expect($lane->getLaneStatus())->toBe(selfserve_lane_status::AVAILABLE);
}); });
it('skips vehicle type product add when program selector is off and only disables configured relays', function (): void { it('skips vehicle type product add when program selector relay is offline and only disables configured relays', function (): void {
$lane = new SelfserveLaneStopFlowHarness( $lane = new SelfserveLaneStopFlowHarness(
programSelectorOn: false, programSelectorOnline: false,
machineRelayId: 'relay-machine', machineRelayId: 'relay-machine',
programRelayId: 'relay-program', programRelayId: 'relay-program',
cleanerRelayId: '' cleanerRelayId: ''
@@ -226,7 +231,20 @@ it('skips vehicle type product add when program selector is off and only disable
expect($lane->ensureInvoiceOrderContextCalls)->toBe(0); expect($lane->ensureInvoiceOrderContextCalls)->toBe(0);
expect($lane->vehicleTypeProductAddCalls)->toBe(0); expect($lane->vehicleTypeProductAddCalls)->toBe(0);
expect($lane->turnedOffRelays)->toBe([ expect($lane->turnedOffRelays)->toBe([
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE, selfserve_lane_relay::MACHINE,
]); ]);
}); });
it('bills primary product when selector relay is online even if relay output is off', function (): void {
$lane = new SelfserveLaneStopFlowHarness(
programSelectorOnline: true,
programSelectorOn: false
);
$args = (new selfserve_lane_command_arguments())->setCustomerNumber(1234);
$lane->execute(selfserve_lane_command::STOP, $args);
expect($lane->invoiceCalls)->toBe(1);
expect($lane->ensureInvoiceOrderContextCalls)->toBe(1);
expect($lane->vehicleTypeProductAddCalls)->toBe(1);
});
@@ -0,0 +1,173 @@
<?php
app_require('classes/shelly.php');
use classes\shelly;
class GlobalShellyRateLimitClock
{
public float $now = 0.0;
}
class GlobalShellyRateLimitRedisClientFake
{
public function __construct(private readonly GlobalShellyRateLimitRedisFake $owner) {}
public function set(string $key, string $value, mixed ...$args): bool|string
{
return $this->owner->clientSet($key, $value, $args);
}
public function pttl(string $key): int
{
return $this->owner->clientPttl($key);
}
}
class GlobalShellyRateLimitRedisFake
{
/** @var array<string,string> */
private array $store = [];
/** @var array<string,float> */
private array $expiresAt = [];
private GlobalShellyRateLimitRedisClientFake $client;
public function __construct(private readonly GlobalShellyRateLimitClock $clock)
{
$this->client = new GlobalShellyRateLimitRedisClientFake($this);
}
public function get_client(): GlobalShellyRateLimitRedisClientFake
{
return $this->client;
}
public function clientSet(string $key, string $value, array $args): bool|string
{
$this->purgeExpired($key);
$useNx = false;
$ttlMs = null;
$count = count($args);
for ($i = 0; $i < $count; $i++) {
$token = strtoupper((string)$args[$i]);
if ($token === 'NX') {
$useNx = true;
continue;
}
if ($token === 'PX' && isset($args[$i + 1])) {
$ttlMs = max(1, (int)$args[$i + 1]);
$i++;
}
}
if ($useNx && array_key_exists($key, $this->store)) {
return false;
}
$this->store[$key] = $value;
if ($ttlMs === null) {
unset($this->expiresAt[$key]);
} else {
$this->expiresAt[$key] = $this->clock->now + ($ttlMs / 1000);
}
return 'OK';
}
public function clientPttl(string $key): int
{
$this->purgeExpired($key);
if (!array_key_exists($key, $this->store)) {
return -2;
}
if (!isset($this->expiresAt[$key])) {
return -1;
}
$remainingMs = (int)ceil(($this->expiresAt[$key] - $this->clock->now) * 1000);
return $remainingMs > 0 ? $remainingMs : 0;
}
private function purgeExpired(string $key): void
{
if (!isset($this->expiresAt[$key])) {
return;
}
if ($this->clock->now < $this->expiresAt[$key]) {
return;
}
unset($this->expiresAt[$key], $this->store[$key]);
}
}
class GlobalShellyRateLimitHarness extends shelly
{
/** @var array<int,int> */
public array $sleepCalls = [];
public function __construct(
private readonly GlobalShellyRateLimitClock $clock,
private readonly ?GlobalShellyRateLimitRedisFake $redis = null
) {
}
public function waitForShellyRateLimitWindowForTest(): void
{
$invoke = \Closure::bind(function (): void {
$this->waitForShellyRateLimitWindow();
}, $this, shelly::class);
$invoke();
}
protected function redisFacade(): mixed
{
return $this->redis;
}
protected function nowTimestamp(): float
{
return $this->clock->now;
}
protected function sleepMicroseconds(int $microseconds): void
{
if ($microseconds <= 0) {
return;
}
$this->sleepCalls[] = $microseconds;
$this->clock->now += ($microseconds / 1000000);
}
}
it('enforces the 2 second Shelly gate across separate request contexts', function (): void {
$clock = new GlobalShellyRateLimitClock();
$redis = new GlobalShellyRateLimitRedisFake($clock);
$requestA = new GlobalShellyRateLimitHarness($clock, $redis);
$requestB = new GlobalShellyRateLimitHarness($clock, $redis);
$requestA->waitForShellyRateLimitWindowForTest();
$timeBeforeRequestB = $clock->now;
$requestB->waitForShellyRateLimitWindowForTest();
expect(array_sum($requestA->sleepCalls))->toBe(0);
expect(array_sum($requestB->sleepCalls))->toBeGreaterThanOrEqual(2000000);
expect($clock->now - $timeBeforeRequestB)->toBeGreaterThanOrEqual(2.0);
});
it('does not delay when the Shelly gate is already expired', function (): void {
$clock = new GlobalShellyRateLimitClock();
$redis = new GlobalShellyRateLimitRedisFake($clock);
$firstRequest = new GlobalShellyRateLimitHarness($clock, $redis);
$firstRequest->waitForShellyRateLimitWindowForTest();
$clock->now += 2.1;
$secondRequest = new GlobalShellyRateLimitHarness($clock, $redis);
$secondRequest->waitForShellyRateLimitWindowForTest();
expect(array_sum($secondRequest->sleepCalls))->toBe(0);
});
@@ -0,0 +1,25 @@
<?php
it('enforces a global 2 second Shelly gate in sendPostRequest', function (): void {
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
expect($shellyClass)->not->toBeFalse();
expect($shellyClass)->toContain('private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;');
expect($shellyClass)->toContain("private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';");
$sendPostRequestOffset = strpos($shellyClass, 'function sendPostRequest(string $endpoint, array $data): array|object|null');
expect($sendPostRequestOffset)->not->toBeFalse();
$sendPostRequestBody = substr($shellyClass, (int)$sendPostRequestOffset, 2200);
expect($sendPostRequestBody)->toContain('$this->waitForShellyRateLimitWindow();');
});
it('uses Redis NX PX semantics for cross-request Shelly rate limiting', function (): void {
$shellyClass = file_get_contents(app_path('classes/shelly.php'));
expect($shellyClass)->not->toBeFalse();
expect($shellyClass)->toContain("self::SHELLY_RATE_LIMIT_GATE_KEY");
expect($shellyClass)->toContain("'PX'");
expect($shellyClass)->toContain("'NX'");
expect($shellyClass)->toContain('pttl(self::SHELLY_RATE_LIMIT_GATE_KEY)');
});
+18
View File
@@ -145,6 +145,13 @@ Accept: application/json
Content-Type: application/json Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8 Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### GET request to weather (specific ID)
GET https://api.truckwash.io/departments/weather?id=1
Accept: application/json
Content-Type: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
### PUT request to order bookings (update specific ID) ### PUT request to order bookings (update specific ID)
PUT https://api.truckwash.dk:4433/order-bookings PUT https://api.truckwash.dk:4433/order-bookings
Accept: application/json Accept: application/json
@@ -178,6 +185,17 @@ Content-Type: application/json
"customer_number": 12345679 "customer_number": 12345679
} }
### Get department distribution e-conomic
GET http://localhost/api/superuser/invoicing/period/distribution/v2/booked-department-75
Accept: application/json
Authorization: Bearer e9d4359673de64f6a9bc4231bf50ac9f5726ad5b3ee6fcad5f8ebbb20647c3b8
Content-Type: application/json
{
"dateFrom": "2026-01-01",
"dateTo": "2026-01-31"
}
### ###
### POST request to order bookings ### POST request to order bookings
POST https://api.truckwash.dk:4433/order-bookings POST https://api.truckwash.dk:4433/order-bookings