Raiju/tests/agent-rate-limit-smoke.php
2026-10-25 06:05:42 +03:00

135 lines
7.4 KiB
PHP

<?php
declare(strict_types=1);
// File-backed sliding-window rate limiter (includes/ai/agent/rate-limit.php).
// php tests/agent-rate-limit-smoke.php (full run)
// php tests/agent-rate-limit-smoke.php hits <dir> <n> <limit> <window> (internal worker)
$root = dirname(__DIR__);
function rateAssert(bool $condition, string $message): void {
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
if (($argv[1] ?? '') === 'hits') {
define('RAIJU_AGENT_RATE_DIR', $argv[2]);
require_once $root . '/includes/ai/agent/rate-limit.php';
$allowed = 0;
for ($i = 0; $i < (int)$argv[3]; $i++) {
$allowed += raijuAgentRateLimit('shared-user', 'burst', ['limit' => (int)$argv[4], 'window' => (int)$argv[5]], true, 5000)['allowed'] ? 1 : 0;
}
echo $allowed;
exit(0);
}
$dir = sys_get_temp_dir() . '/raiju-rate-smoke-' . bin2hex(random_bytes(4));
mkdir($dir, 0755, true);
register_shutdown_function(static fn() => exec('rm -rf ' . escapeshellarg($dir)));
define('RAIJU_AGENT_RATE_DIR', $dir . '/rate');
require_once $root . '/includes/ai/agent/rate-limit.php';
// Defaults from the plan
$defaults = raijuAgentRateLimitDefaults();
rateAssert($defaults['runs'] === ['limit' => 10, 'window' => 3600]
&& $defaults['steps'] === ['limit' => 60, 'window' => 60]
&& $defaults['uploads'] === ['limit' => 10, 'window' => 600], 'default buckets: 10 runs/h, 60 steps/min, 10 uploads/10 min');
// Sliding window
$t = 1_000_000;
for ($i = 1; $i <= 10; $i++) {
$r = raijuAgentRateLimit('u_alice', 'runs', null, true, $t + $i);
rateAssert($r['allowed'] && $r['remaining'] === 10 - $i && $r['limit'] === 10, "run {$i} is allowed");
}
$r = raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 11);
rateAssert(!$r['allowed'] && $r['remaining'] === 0 && $r['retryAfter'] === 3600 - 10, 'the 11th run is refused with the time until the oldest hit expires: ' . json_encode($r));
rateAssert(!raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 3599)['allowed'], 'still refused just before the first hit expires');
$r = raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 3601);
rateAssert($r['allowed'] && $r['remaining'] === 0, 'the window slides: the first hit expired, so one more is allowed (' . json_encode($r) . ')');
$state = json_decode((string)file_get_contents(raijuAgentRateLimitFile('u_alice')), true);
rateAssert(count($state['buckets']['runs']) === 10, 'expired hits are pruned from the file');
// Peek does not consume; refused calls do not consume either
rateAssert(!raijuAgentRateLimit('u_alice', 'runs', null, false, $t + 3601)['allowed'], 'peek reports a full bucket');
$peek = raijuAgentRateLimit('u_bob', 'steps', null, false, $t);
rateAssert($peek['allowed'] && $peek['remaining'] === 60 && !is_file(raijuAgentRateLimitFile('u_bob')), 'peek on a fresh bucket allows and writes nothing');
for ($i = 0; $i < 5; $i++) {
rateAssert(!raijuAgentRateLimit('u_alice', 'runs', null, true, $t + 3601)['allowed'], 'full bucket stays full');
}
$state = json_decode((string)file_get_contents(raijuAgentRateLimitFile('u_alice')), true);
rateAssert(count($state['buckets']['runs']) === 10, 'refused requests are not recorded');
// Users and buckets are independent
rateAssert(raijuAgentRateLimit('u_bob', 'runs', null, true, $t)['allowed'], 'another user has their own budget');
for ($i = 0; $i < 60; $i++) {
rateAssert(raijuAgentRateLimit('u_carol', 'steps', null, true, $t)['allowed'], 'step ' . $i);
}
rateAssert(!raijuAgentRateLimit('u_carol', 'steps', null, true, $t)['allowed'], '61st step in a minute is refused');
rateAssert(raijuAgentRateLimit('u_carol', 'steps', null, true, $t + 61)['allowed'], 'steps recover after the minute');
rateAssert(raijuAgentRateLimit('u_carol', 'uploads', null, true, $t)['allowed'], 'buckets do not share budget');
for ($i = 0; $i < 9; $i++) {
raijuAgentRateLimit('u_dave', 'uploads', null, true, $t);
}
rateAssert(raijuAgentRateLimit('u_dave', 'uploads', null, true, $t)['allowed'] && !raijuAgentRateLimit('u_dave', 'uploads', null, true, $t + 599)['allowed'] && raijuAgentRateLimit('u_dave', 'uploads', null, true, $t + 601)['allowed'], '10 uploads per 10 minutes');
// Custom limits
$custom = ['limit' => 2, 'window' => 30];
rateAssert(raijuAgentRateLimit('u_erin', 'custom', $custom, true, $t)['allowed'] && raijuAgentRateLimit('u_erin', 'custom', $custom, true, $t)['allowed'], 'custom bucket allows its limit');
rateAssert(raijuAgentRateLimit('u_erin', 'custom', $custom, true, $t + 10)['retryAfter'] === 20, 'custom retryAfter');
raijuAgentAssertRateLimit('u_zoe', 'custom', $custom);
raijuAgentAssertRateLimit('u_zoe', 'custom', $custom);
try {
raijuAgentAssertRateLimit('u_zoe', 'custom', $custom);
rateAssert(false, 'assert variant must throw when exhausted');
} catch (RuntimeException $e) {
rateAssert($e->getCode() === 429, 'assert variant throws with code 429');
}
// Invalid input and file names
foreach ([['u_x', 'unknown-bucket'], ['', 'runs'], ['u_x', '../runs'], ['u_x', 'Runs']] as [$user, $bucket]) {
try {
raijuAgentRateLimit($user, $bucket);
rateAssert(false, "bucket '{$bucket}' / user '{$user}' must be rejected");
} catch (InvalidArgumentException $e) {
}
}
$hostile = raijuAgentRateLimitFile('../../../etc/passwd');
rateAssert(dirname($hostile) === $dir . '/rate' && preg_match('#/h_[0-9a-f]{40}\.json$#', $hostile) === 1, 'hostile user keys are hashed into the rate directory');
rateAssert(raijuAgentRateLimitFile('u_alice') === $dir . '/rate/u_alice.json' && raijuAgentRateLimitFile('a@b.c') !== raijuAgentRateLimitFile('a_b_c'), 'safe keys stay readable, others cannot collide');
rateAssert(raijuAgentRateLimit('a@b.c', 'runs', null, true, $t)['allowed'], 'a hashed user key works');
// A damaged state file is set aside instead of locking the user out
file_put_contents(raijuAgentRateLimitFile('u_frank'), '{broken');
rateAssert(raijuAgentRateLimit('u_frank', 'runs', null, true, $t)['allowed'], 'damaged state is recovered');
rateAssert(is_file(raijuAgentRateLimitFile('u_frank') . '.corrupt') && is_array(json_decode((string)file_get_contents(raijuAgentRateLimitFile('u_frank')), true)), 'damaged state kept aside, fresh state written');
// Parallel processes share one budget
$shared = $dir . '/shared';
$workers = [];
for ($i = 0; $i < 10; $i++) {
$workers[] = proc_open([PHP_BINARY, __FILE__, 'hits', $shared, '6', '20', '60'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes[$i]);
}
$allowed = 0;
foreach ($workers as $i => $process) {
$allowed += (int)stream_get_contents($pipes[$i][1]);
proc_close($process);
}
rateAssert($allowed === 20, "60 parallel hits against a limit of 20 admitted exactly 20 (got {$allowed})");
// Unwritable storage fails closed
$blocked = proc_open([PHP_BINARY, '-r', '
define("RAIJU_AGENT_RATE_DIR", ' . var_export($dir . '/not-a-directory/rate', true) . ');
file_put_contents(' . var_export($dir . '/not-a-directory', true) . ', "file");
require ' . var_export($root . '/includes/ai/agent/rate-limit.php', true) . ';
$r = raijuAgentRateLimit("u_gina", "runs");
echo $r["allowed"] ? "open" : "closed";
'], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $blockedPipes);
$answer = stream_get_contents($blockedPipes[1]);
proc_close($blocked);
rateAssert($answer === 'closed', 'storage failure denies the request instead of running without a limit');
echo "PASS agent rate limit: sliding window, buckets, users, peek, hashed keys, corrupt-state recovery, parallel processes, fail closed\n";