Skip to content

Commit 4624466

Browse files
hyperpolymathclaude
andcommitted
feat: add VeriSimDbStore rate limit storage backend
Implement RateLimitStoreInterface backed by VeriSimDB REST API (collection: php-aegis:rate-limits). Suitable for multi-server deployments requiring shared token bucket state. Fail-open semantics ensure no traffic is blocked when VeriSimDB is unreachable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 431984e commit 4624466

1 file changed

Lines changed: 196 additions & 0 deletions

File tree

src/RateLimit/VeriSimDbStore.php

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: PMPL-1.0-or-later
5+
* SPDX-FileCopyrightText: 2024-2026 Hyperpolymath
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace PhpAegis\RateLimit;
11+
12+
/**
13+
* VeriSimDB-backed rate limit storage.
14+
*
15+
* Stores token bucket state in VeriSimDB via its REST API
16+
* (collection: php-aegis:rate-limits). Suitable for multi-server
17+
* deployments where all nodes must share rate limit state.
18+
*
19+
* ## Environment variables
20+
*
21+
* - `VERISIMDB_URL`: Base URL of the VeriSimDB instance
22+
* (default: `http://localhost:8080`).
23+
*
24+
* ## Fallback behaviour
25+
*
26+
* On VeriSimDB connectivity failure (curl error, non-2xx response),
27+
* operations fail open: get() returns null (no throttling), set() and
28+
* delete() become no-ops. This ensures PHP-Aegis never blocks legitimate
29+
* traffic due to a VeriSimDB outage. Use the FileStore fallback if you
30+
* prefer fail-closed semantics.
31+
*
32+
* ## Collection schema
33+
*
34+
* Documents are stored under the key `<prefix><key>` with shape:
35+
* ```json
36+
* { "tokens": 9.5, "lastRefill": 1740000000 }
37+
* ```
38+
*
39+
* TTL is enforced by a separate sweep job; VeriSimDB v1 does not natively
40+
* support document-level TTL. Use cron or a Hypatia rule to expire old docs.
41+
*/
42+
final class VeriSimDbStore implements RateLimitStoreInterface
43+
{
44+
private const COLLECTION = 'php-aegis:rate-limits';
45+
private const DEFAULT_URL = 'http://localhost:8080';
46+
private const CONNECT_TIMEOUT_S = 2;
47+
private const REQUEST_TIMEOUT_S = 5;
48+
49+
private string $baseUrl;
50+
private string $prefix;
51+
52+
/**
53+
* Create a new VeriSimDB-backed store.
54+
*
55+
* @param string|null $baseUrl VeriSimDB base URL
56+
* (overrides VERISIMDB_URL env var)
57+
* @param string $prefix Key prefix for namespacing buckets
58+
* (default: 'bucket_')
59+
*/
60+
public function __construct(?string $baseUrl = null, string $prefix = 'bucket_')
61+
{
62+
$this->baseUrl = $baseUrl
63+
?? (getenv('VERISIMDB_URL') ?: self::DEFAULT_URL);
64+
// Strip trailing slash for consistent URL construction
65+
$this->baseUrl = rtrim($this->baseUrl, '/');
66+
$this->prefix = $prefix;
67+
}
68+
69+
/**
70+
* {@inheritDoc}
71+
*
72+
* Returns null (allow-by-default) on VeriSimDB connectivity failure.
73+
*/
74+
public function get(string $key): ?array
75+
{
76+
$response = $this->request('GET', $this->docUrl($key), null);
77+
if ($response === null) {
78+
return null;
79+
}
80+
81+
$data = json_decode($response, true);
82+
if (!is_array($data)
83+
|| !isset($data['tokens'], $data['lastRefill'])
84+
|| !is_numeric($data['tokens'])
85+
|| !is_int($data['lastRefill'])) {
86+
return null;
87+
}
88+
89+
return ['tokens' => (float) $data['tokens'], 'lastRefill' => (int) $data['lastRefill']];
90+
}
91+
92+
/**
93+
* {@inheritDoc}
94+
*
95+
* Becomes a no-op on VeriSimDB connectivity failure.
96+
* The $ttl parameter is stored as metadata but not enforced by VeriSimDB v1.
97+
*/
98+
public function set(string $key, array $data, int $ttl): void
99+
{
100+
$payload = json_encode([
101+
'tokens' => $data['tokens'],
102+
'lastRefill' => $data['lastRefill'],
103+
'ttl' => $ttl,
104+
'expiresAt' => time() + $ttl,
105+
]);
106+
107+
$this->request('PUT', $this->docUrl($key), $payload);
108+
}
109+
110+
/**
111+
* {@inheritDoc}
112+
*
113+
* Becomes a no-op on VeriSimDB connectivity failure.
114+
*/
115+
public function delete(string $key): void
116+
{
117+
$this->request('DELETE', $this->docUrl($key), null);
118+
}
119+
120+
/**
121+
* {@inheritDoc}
122+
*
123+
* NOTE: VeriSimDB v1 does not support collection-level deletes.
124+
* This method is a no-op in the VeriSimDB store. Use a Hypatia
125+
* rule or scheduled job to bulk-delete the collection if needed.
126+
*/
127+
public function clear(): void
128+
{
129+
// Intentional no-op for VeriSimDB store.
130+
// Bulk collection delete is not supported in VeriSimDB v1 REST API.
131+
}
132+
133+
// -------------------------------------------------------------------------
134+
// Internal helpers
135+
// -------------------------------------------------------------------------
136+
137+
/**
138+
* Build the full document URL for a given key.
139+
*
140+
* @param string $key Bucket key (e.g., user ID or IP address)
141+
*/
142+
private function docUrl(string $key): string
143+
{
144+
$docId = rawurlencode($this->prefix . $key);
145+
return sprintf('%s/v1/%s/%s', $this->baseUrl, self::COLLECTION, $docId);
146+
}
147+
148+
/**
149+
* Execute a cURL request against VeriSimDB.
150+
*
151+
* Returns the response body as a string on success (2xx), or null on
152+
* network failure or non-2xx response.
153+
*
154+
* @param string $method HTTP method (GET, PUT, DELETE)
155+
* @param string $url Full request URL
156+
* @param string|null $body JSON request body (null for GET/DELETE)
157+
*/
158+
private function request(string $method, string $url, ?string $body): ?string
159+
{
160+
$ch = curl_init($url);
161+
if ($ch === false) {
162+
return null;
163+
}
164+
165+
curl_setopt_array($ch, [
166+
CURLOPT_RETURNTRANSFER => true,
167+
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT_S,
168+
CURLOPT_TIMEOUT => self::REQUEST_TIMEOUT_S,
169+
CURLOPT_CUSTOMREQUEST => $method,
170+
]);
171+
172+
if ($body !== null) {
173+
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
174+
curl_setopt($ch, CURLOPT_HTTPHEADER, [
175+
'Content-Type: application/json',
176+
'Content-Length: ' . strlen($body),
177+
]);
178+
}
179+
180+
$response = curl_exec($ch);
181+
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
182+
$curlError = curl_error($ch);
183+
curl_close($ch);
184+
185+
if ($response === false || $curlError !== '' || $httpCode < 200 || $httpCode >= 300) {
186+
// Fail open: log and return null rather than throwing.
187+
error_log(sprintf(
188+
'PhpAegis\RateLimit\VeriSimDbStore: %s %s failed (HTTP %d, curl: %s)',
189+
$method, $url, $httpCode, $curlError
190+
));
191+
return null;
192+
}
193+
194+
return (string) $response;
195+
}
196+
}

0 commit comments

Comments
 (0)