Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d275ce3
feat(sliding-window): add storage-agnostic SlidingWindow adapter cont…
ArnabChatterjee20k Aug 12, 2026
0dbc1f0
feat(sliding-window): add RedisBase with atomic Lua check-and-increment
ArnabChatterjee20k Aug 12, 2026
f281091
feat(sliding-window): add Redis adapter
ArnabChatterjee20k Aug 12, 2026
d68199f
feat(sliding-window): add RedisCluster adapter with hash-tagged keys
ArnabChatterjee20k Aug 12, 2026
2d15f05
feat(sliding-window): add RedisPool adapter
ArnabChatterjee20k Aug 12, 2026
5daae51
test(sliding-window): add shared adapter test base
ArnabChatterjee20k Aug 12, 2026
4d8c86b
test(sliding-window): add Redis, RedisCluster and RedisPool test cases
ArnabChatterjee20k Aug 12, 2026
47a5459
Merge branch 'main' into strategy/sliding-window
ArnabChatterjee20k Aug 12, 2026
5c016a8
refactor(sliding-window): reduce adapter seams to eval/get/delete pri…
ArnabChatterjee20k Aug 12, 2026
bf8336d
fix(sliding-window): validate window/ttl bounds and cache weighted es…
ArnabChatterjee20k Aug 12, 2026
25016c0
refactor(sliding-window): improve window computation and state manage…
ArnabChatterjee20k Aug 12, 2026
252a10d
feat(token-bucket): add agnostic adapter and Redis-family base
ArnabChatterjee20k Aug 13, 2026
1830b61
feat(token-bucket): add Redis, RedisCluster and RedisPool adapters
ArnabChatterjee20k Aug 13, 2026
84f4424
test(token-bucket): add Redis, RedisCluster and RedisPool test cases
ArnabChatterjee20k Aug 13, 2026
70e1e4c
fix(token-bucket): floor available balance before deriving consumed c…
ArnabChatterjee20k Aug 13, 2026
6c152d3
fix(token-bucket): always read a fresh refill estimate in count()
ArnabChatterjee20k Aug 13, 2026
6a011fe
fix(token-bucket): scan every cluster master fully before paginating …
ArnabChatterjee20k Aug 13, 2026
e5000d3
feat(sliding-window): add None adapter
ArnabChatterjee20k Aug 13, 2026
4afb6a2
feat(token-bucket): add None adapter
ArnabChatterjee20k Aug 13, 2026
1eecb5e
Merge branch 'strategy/sliding-window' into strategy/token-bucket
ArnabChatterjee20k Aug 13, 2026
fba428f
fix(sliding-window): guard None against zero-sized window
ArnabChatterjee20k Aug 13, 2026
49ac83a
fix(sliding-window): drop stale count cache so estimate keeps decaying
ArnabChatterjee20k Aug 13, 2026
d75bb51
Merge pull request #122 from utopia-php/strategy/token-bucket
ArnabChatterjee20k Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/Abuse/Adapters/SlidingWindow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

namespace Utopia\Abuse\Adapters;

use Throwable;
use Utopia\Abuse\Adapter;

// counter based sliding window
abstract class SlidingWindow extends Adapter
{
/**
* @var int
*/
protected int $limit = 0;

/**
* @var int
*/
protected int $timestamp;

/**
* Count
*
* Read-only weighted estimate of hits in the current sliding window.
*
* @param string $key
* @param int $timestamp
* @return int
*
* @throws \Exception
*/
abstract protected function count(string $key, int $timestamp): int;

/**
* Check
*
* Atomically evaluates the sliding-window estimate and records the request
* if it is under the limit. Storage backends MUST implement this as a single
* atomic operation so the read-decide-increment sequence cannot race between
* concurrent requests. Returns true when the request is abuse (limit reached).
* limit 0 is equal to unlimited.
*
* @return bool
*
* @throws \Exception|Throwable
*/
abstract public function check(): bool;

/**
* Remaining
*
* Returns the number of current remaining counts
*
* @return int
*
* @throws \Exception
*/
public function remaining(): int
{
$left = $this->limit - ($this->count($this->parseKey(), $this->timestamp) + 1);

return (0 > $left) ? 0 : $left;
}

/**
* Limit
*
* Return the limit integer
*
* @return int
*/
public function limit(): int
{
return $this->limit;
}

/**
* Time
*
* Return the timestamp
*
* @return int
*/
public function time(): int
{
return $this->timestamp;
}

/**
* Reset
*
* Clear the counters for the current key so the limit starts fresh.
* Implementations must clear both the current and previous window buckets.
*
* @return void
*
* @throws \Exception
*/
abstract public function reset(): void;
}
62 changes: 62 additions & 0 deletions src/Abuse/Adapters/SlidingWindow/None.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

namespace Utopia\Abuse\Adapters\SlidingWindow;

use Utopia\Abuse\Adapters\SlidingWindow;

class None extends SlidingWindow
{
/**
* @param int $ttl Accepted for parity with the storage adapters; unused here
*/
public function __construct(string $key, int $limit, int $windowSize, int $ttl) // @phpstan-ignore constructor.unusedParameter
{
if ($windowSize <= 0) {
throw new \InvalidArgumentException('windowSize must be greater than 0');
}

$this->key = $key;
$this->limit = $limit;
$now = \time();
$this->timestamp = (int) ($now - ($now % $windowSize));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

protected function count(string $key, int $timestamp): int
{
return 0;
}

public function check(): bool
{
return false;
}

public function reset(): void
{
}

/**
* Get abuse logs
*
* Return logs with an offset and limit
*
* @param int|null $offset
* @param int|null $limit
* @return array<string, mixed>
*/
public function getLogs(?int $offset = null, ?int $limit = 25): array
{
return [];
}

/**
* Delete all logs older than $timestamp
*
* @param int $timestamp
* @return bool
*/
public function cleanup(int $timestamp): bool
{
return true;
}
}
94 changes: 94 additions & 0 deletions src/Abuse/Adapters/SlidingWindow/Redis.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

namespace Utopia\Abuse\Adapters\SlidingWindow;

class Redis extends RedisBase
{
/**
* @param string $key Abuse key pattern, e.g. "ip:{ip}"; params are substituted via setParam()
* @param int $limit Max allowed hits per window; 0 means unlimited
* @param int $windowSize Length of the rate-limit window in seconds
* @param int $ttl Lifetime of a bucket key in seconds; must be >= $windowSize so the
* previous window's bucket survives long enough to be weighted
* @param \Redis $redis Redis connection used for storage
*/
public function __construct(protected string $key, protected int $limit, int $windowSize, int $ttl, protected \Redis $redis)
{
$this->initWindow($windowSize, $ttl);
}

/**
* @param string $script
* @param list<string> $keys
* @param list<int|float> $argv
* @return mixed
*
* @throws \RedisException
*/
protected function eval(string $script, array $keys, array $argv): mixed
{
return $this->redis->eval($script, [...$keys, ...$argv], \count($keys));
}

/**
* @param string $key
* @return mixed
*
* @throws \RedisException
*/
protected function get(string $key): mixed
{
return $this->redis->get($key);
}

/**
* @param string ...$keys
* @return void
*
* @throws \RedisException
*/
protected function delete(string ...$keys): void
{
$this->redis->del(...$keys);
}

/**
* Get abuse logs
*
* Return logs with an offset and limit
*
* @param int|null $offset
* @param int|null $limit
* @return array<string, mixed>
*/
public function getLogs(?int $offset = null, ?int $limit = 25): array
{
$offset = $offset ?? 0;
$limit = $limit ?? 25;

$cursor = null;
$matches = [];
$pattern = self::NAMESPACE . '__*';

do {
$keys = $this->redis->scan($cursor, $pattern, 100);
if ($keys !== false) {
\array_push($matches, ...$keys);
}
} while ($cursor > 0);

\sort($matches);
$matches = \array_slice($matches, $offset, $limit);

if (empty($matches)) {
return [];
}

$logs = [];
foreach ($matches as $key) {
$logs[$key] = $this->redis->get($key);
}

return $logs;
}
}
Loading
Loading