521 lines
20 KiB
PHP
521 lines
20 KiB
PHP
<?php
|
||
/**
|
||
* API Rate Limiter — content only
|
||
* app/Views/docs/api-rate-limiter.php
|
||
*
|
||
* Based on app/Libraries/RateLimiterService.php,
|
||
* app/Filters/AuthApiRateLimitFilter.php, app/Filters/JwtApiFilter.php (class JwtApiRateLimitFilter).
|
||
*/
|
||
?>
|
||
|
||
<p>
|
||
The API rate limiter combines a shared <code>RateLimiterService</code> with two route filters:
|
||
one for unauthenticated auth-style endpoints (email / mobile in the request body or query), and
|
||
one for JWT-protected APIs. Both enforce IP-level throttling and progressive blocks, and tie
|
||
additional counters to a resolved user identity when one is available.
|
||
</p>
|
||
|
||
<h2 id="overview">Overview</h2>
|
||
|
||
<p>
|
||
A client fingerprint is built with <code>generateFingerprint(exclude_ua: true)</code>, so the
|
||
IP-level bucket is keyed primarily by client IP (not the full user-agent string). The service
|
||
stores counters and block metadata in the application cache (<code>Config\Services::cache()</code>).
|
||
</p>
|
||
|
||
<div class="mermaid-wrapper">
|
||
<div class="mermaid">
|
||
flowchart TD
|
||
A[Incoming request] --> B{Which filter}
|
||
B -->|Auth API routes| C[AuthApiRateLimitFilter]
|
||
B -->|JWT API routes| D[JwtApiRateLimitFilter]
|
||
C --> E[RateLimiterService]
|
||
D --> E
|
||
E --> F{IP allowed}
|
||
F -->|No| G[JSON error response]
|
||
F -->|Yes| H{User checks}
|
||
H -->|Blocked / throttled| G
|
||
H -->|Pass| I[Controller runs]
|
||
I --> J{Response status}
|
||
J -->|4xx except 429/403/451| K[Record IP + user failures]
|
||
J -->|Other| L[No extra recording]
|
||
</div>
|
||
</div>
|
||
|
||
<h2 id="core-service">Core service</h2>
|
||
|
||
<p>
|
||
<code>App\Libraries\RateLimiterService</code> reads limits from <code>app/Config/RateLimiter.php</code>.
|
||
It separates <strong>IP behaviour</strong> (shared <code>$config->ipBlock</code>) from
|
||
<strong>user behaviour</strong> (shared <code>$config->userBlock</code> for block durations and
|
||
escalation), while per-route-type windows and limits use either <code>$jwtApi</code> or
|
||
<code>$authApi</code> depending on the string passed from the filter (<code>jwtApi</code> vs
|
||
<code>authApi</code>).
|
||
</p>
|
||
|
||
<h3>IP level</h3>
|
||
<ul>
|
||
<li><code>checkIp($fingerprint, $routeType)</code> — if the IP is already blocked, returns a block payload and may escalate soft → medium → hard when additional requests hit while blocked.</li>
|
||
<li>Otherwise increments a sliding-window request counter; exceeding the limit increments an IP
|
||
violation counter. Enough violations apply a soft IP block; a single-window overrun without
|
||
reaching the block threshold returns a throttle message (HTTP 429) without yet blocking.</li>
|
||
<li><code>recordIpFailure($fingerprint)</code> — increments the same violation path (used from
|
||
filter <code>after()</code> on failed controller responses).</li>
|
||
</ul>
|
||
|
||
<h3>User level</h3>
|
||
<ul>
|
||
<li>Identities are normalized for storage keys with <code>hash('sha256', strtolower(trim($identity)))</code>.</li>
|
||
<li><code>checkUser($identity)</code> — returns a block response if that identity is already blocked.</li>
|
||
<li><code>checkUserThrottle($identity, 'jwtApi')</code> — used on JWT routes: block check first, then
|
||
a per-user request counter in a time window (same violation → soft-block pattern as IP).</li>
|
||
<li><code>recordUserFailure($identity, $routeType)</code> — increments user violations when the
|
||
controller returns a failure; thresholds use the route-type config (<code>authApi</code> or
|
||
<code>jwtApi</code>).</li>
|
||
<li>Progressive blocks (soft, medium, hard) can escalate when the client keeps hitting endpoints
|
||
while already blocked; durations come from <code>userBlock</code> / <code>ipBlock</code> (zero
|
||
duration is treated as long-lived until manual unblock).</li>
|
||
</ul>
|
||
|
||
<p>
|
||
Manual operations exposed on the service include <code>blockIp</code>, <code>unblockIp</code>,
|
||
<code>blockUser</code>, and <code>unblockUser</code>, which clear the relevant cache keys for
|
||
counters, violations, and block records.
|
||
</p>
|
||
|
||
<h2 id="auth-api-filter">Auth API filter</h2>
|
||
|
||
<p>
|
||
<code>App\Filters\AuthApiRateLimitFilter</code> targets routes that do <strong>not</strong> rely on
|
||
JWT (for example mobile verification or OTP flows). Identity is resolved from POST fields first,
|
||
then GET: <code>email</code> (lower-cased) or <code>mobile_number</code> (trimmed).
|
||
</p>
|
||
|
||
<ul>
|
||
<li><strong>before:</strong> <code>checkIp($fingerprint, 'authApi')</code>, then if identity exists,
|
||
<code>checkUser($identity)</code> only (no per-user request throttle before the controller).</li>
|
||
<li><strong>after:</strong> On HTTP status ≥ 400, excluding 429, 403, and 451, records
|
||
<code>recordIpFailure</code> and <code>recordUserFailure($identity, 'authApi')</code> when identity
|
||
was stashed or can still be resolved — so failed logins or bad OTP attempts feed the violation
|
||
counters.</li>
|
||
<li>Throttle/block JSON responses run the global <code>Cors</code> filter’s <code>after()</code>
|
||
handler so CORS headers stay consistent on early exits.</li>
|
||
</ul>
|
||
|
||
<h2 id="jwt-api-filter">JWT API filter</h2>
|
||
|
||
<p>
|
||
The class <code>JwtApiRateLimitFilter</code> lives in <code>app/Filters/JwtApiFilter.php</code>.
|
||
It resolves identity from <code>getEmailFromJWT()</code> or <code>getMobileFromJWT()</code> when those
|
||
helpers exist; invalid JWTs are caught and the request falls back to IP-only limiting.
|
||
</p>
|
||
|
||
<ul>
|
||
<li><strong>before:</strong> <code>checkIp($fingerprint, 'jwtApi')</code>, then
|
||
<code>checkUserThrottle($identity, 'jwtApi')</code> when identity is known.</li>
|
||
<li><strong>after:</strong> On controller failures (4xx except 429, 403, 451), records IP failure and
|
||
<code>recordUserFailure($identity, 'jwtApi')</code> for the JWT identity when available.</li>
|
||
</ul>
|
||
|
||
<h2 id="configuration">Configuration</h2>
|
||
|
||
<p>
|
||
Defaults in <code>app/Config/RateLimiter.php</code> (adjust per environment as needed):
|
||
</p>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Key</th><th>Meaning (current defaults)</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td><code>jwtApi</code></td>
|
||
<td>60 requests per 60 seconds per user identity; 3 violations before a soft user block.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>authApi</code></td>
|
||
<td>10 requests per 180 seconds at the IP bucket for auth routes; 3 user violations (from failed
|
||
responses) before a soft user block.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>ipBlock</code></td>
|
||
<td>120 requests per 60 seconds per fingerprint; 5 violations before soft IP block; medium/hard
|
||
durations and triggers for escalation while blocked.</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>userBlock</code> / <code>ipBlock</code> durations</td>
|
||
<td>Soft can be stored as long TTL when duration is 0; medium 2 hours; hard 24 hours (see config).</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>statusCodes</code></td>
|
||
<td>Throttle and soft blocks use 429; medium 403; hard 451.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
<h3 id="change-block-count-duration">How to change block count and duration</h3>
|
||
|
||
<p>
|
||
All tuning happens in <code>app/Config/RateLimiter.php</code>. No filter code changes are needed
|
||
for normal policy updates. Edit values, deploy, and clear cache if your backend keeps old keys.
|
||
</p>
|
||
|
||
<h4>What controls what</h4>
|
||
<ul>
|
||
<li><code>jwtApi.limit</code> and <code>jwtApi.window</code>: per-user request throttle for JWT APIs.</li>
|
||
<li><code>authApi.limit</code> and <code>authApi.window</code>: auth-route request throttle window used by
|
||
IP checks in the auth filter flow.</li>
|
||
<li><code>jwtApi.violation_soft</code> / <code>authApi.violation_soft</code>: number of recorded violations
|
||
before applying a soft user block.</li>
|
||
<li><code>ipBlock.limit</code> and <code>ipBlock.window</code>: global per-fingerprint request throttle.</li>
|
||
<li><code>ipBlock.violation_soft</code>: over-limit events before soft IP block.</li>
|
||
<li><code>userBlock.soft_duration</code>, <code>medium_duration</code>, <code>hard_duration</code>:
|
||
user-block durations in seconds.</li>
|
||
<li><code>ipBlock.soft_duration</code>, <code>medium_duration</code>, <code>hard_duration</code>:
|
||
IP-block durations in seconds.</li>
|
||
<li><code>userBlock.medium_trigger</code> / <code>hard_trigger</code> and equivalent in
|
||
<code>ipBlock</code>: attempts while already blocked that escalate level.</li>
|
||
</ul>
|
||
|
||
<h4>Duration conversion quick reference</h4>
|
||
<pre><code class="language-text">300 = 5 minutes
|
||
900 = 15 minutes
|
||
1800 = 30 minutes
|
||
3600 = 1 hour
|
||
7200 = 2 hours
|
||
86400 = 24 hours
|
||
0 = permanent-style block (manual unblock expected)</code></pre>
|
||
|
||
<h4>Sample 1: Strict production policy</h4>
|
||
<pre><code class="language-php">public array $jwtApi = [
|
||
'limit' => 45,
|
||
'window' => 60,
|
||
'violation_soft' => 2,
|
||
];
|
||
|
||
public array $authApi = [
|
||
'limit' => 8,
|
||
'window' => 180,
|
||
'violation_soft' => 2,
|
||
];
|
||
|
||
public array $userBlock = [
|
||
'soft_duration' => 1800, // 30 min
|
||
'medium_duration' => 7200, // 2 hours
|
||
'hard_duration' => 86400, // 24 hours
|
||
'medium_trigger' => 1,
|
||
'hard_trigger' => 1,
|
||
];
|
||
|
||
public array $ipBlock = [
|
||
'limit' => 100,
|
||
'window' => 60,
|
||
'violation_soft' => 4,
|
||
'soft_duration' => 1800,
|
||
'medium_duration' => 7200,
|
||
'hard_duration' => 86400,
|
||
'medium_trigger' => 1,
|
||
'hard_trigger' => 1,
|
||
];</code></pre>
|
||
|
||
<h4>Sample 2: Balanced default-like policy</h4>
|
||
<pre><code class="language-php">public array $jwtApi = [
|
||
'limit' => 60,
|
||
'window' => 60,
|
||
'violation_soft' => 3,
|
||
];
|
||
|
||
public array $authApi = [
|
||
'limit' => 10,
|
||
'window' => 180,
|
||
'violation_soft' => 3,
|
||
];
|
||
|
||
public array $userBlock = [
|
||
'soft_duration' => 900, // 15 min
|
||
'medium_duration' => 3600, // 1 hour
|
||
'hard_duration' => 86400, // 24 hours
|
||
'medium_trigger' => 2,
|
||
'hard_trigger' => 2,
|
||
];</code></pre>
|
||
|
||
<h4>Sample 3: Dev / QA friendly policy</h4>
|
||
<pre><code class="language-php">public array $jwtApi = [
|
||
'limit' => 200,
|
||
'window' => 60,
|
||
'violation_soft' => 20,
|
||
];
|
||
|
||
public array $authApi = [
|
||
'limit' => 40,
|
||
'window' => 180,
|
||
'violation_soft' => 10,
|
||
];
|
||
|
||
public array $userBlock = [
|
||
'soft_duration' => 60, // 1 min
|
||
'medium_duration' => 300, // 5 min
|
||
'hard_duration' => 900, // 15 min
|
||
'medium_trigger' => 5,
|
||
'hard_trigger' => 5,
|
||
];
|
||
|
||
public array $ipBlock = [
|
||
'limit' => 300,
|
||
'window' => 60,
|
||
'violation_soft' => 30,
|
||
'soft_duration' => 60,
|
||
'medium_duration' => 300,
|
||
'hard_duration' => 900,
|
||
'medium_trigger' => 5,
|
||
'hard_trigger' => 5,
|
||
];</code></pre>
|
||
|
||
<h4>Change workflow (safe rollout)</h4>
|
||
<ol>
|
||
<li>Copy current values from <code>RateLimiter.php</code> to your release notes for rollback.</li>
|
||
<li>Change one policy group at a time (for example JWT first, then auth).</li>
|
||
<li>Deploy and clear cache keys if required by your cache backend strategy.</li>
|
||
<li>Monitor 429/403/451 counts and support tickets for 24-48 hours.</li>
|
||
<li>Adjust <code>violation_soft</code> and durations gradually, not in large jumps.</li>
|
||
</ol>
|
||
|
||
<div class="callout warning">
|
||
<span>!</span>
|
||
<div>
|
||
<strong>Important:</strong>
|
||
In this implementation, a duration of <code>0</code> is treated as long-lived and practically
|
||
permanent until manual unblock via <code>unblockIp()</code> or <code>unblockUser()</code>.
|
||
</div>
|
||
</div>
|
||
|
||
<h3 id="manual-unblock-samples">Manual unblock samples</h3>
|
||
|
||
<p>
|
||
Use <code>unblockUser()</code> and <code>unblockIp()</code> when support confirms a genuine user was
|
||
blocked by policy. Keep unblock actions auditable (who unblocked, why, and when).
|
||
</p>
|
||
|
||
<h4>Sample: controller/admin action</h4>
|
||
<pre><code class="language-php"><?php
|
||
|
||
namespace App\Controllers\Admin;
|
||
|
||
use App\Controllers\BaseController;
|
||
use App\Libraries\RateLimiterService;
|
||
|
||
class SecurityController extends BaseController
|
||
{
|
||
public function unblockRateLimitedUser(): \CodeIgniter\HTTP\ResponseInterface
|
||
{
|
||
$identity = trim((string) $this->request->getPost('identity'));
|
||
if ($identity === '') {
|
||
return $this->response->setStatusCode(422)->setJSON([
|
||
'success' => false,
|
||
'message' => 'identity is required',
|
||
]);
|
||
}
|
||
|
||
$limiter = new RateLimiterService();
|
||
$limiter->unblockUser($identity);
|
||
|
||
return $this->response->setJSON([
|
||
'success' => true,
|
||
'identity' => $identity,
|
||
'message' => 'User rate-limit state cleared',
|
||
]);
|
||
}
|
||
}</code></pre>
|
||
|
||
<h4>Sample: CLI / one-off script logic</h4>
|
||
<pre><code class="language-php">$limiter = new \App\Libraries\RateLimiterService();
|
||
$identity = 'user@example.com';
|
||
$fingerprint = 'known-fingerprint-key';
|
||
|
||
$limiter->unblockUser($identity);
|
||
$limiter->unblockIp($fingerprint);</code></pre>
|
||
|
||
<p>
|
||
If your support team only has an email/mobile, unblock user first. IP unblock should be done more
|
||
carefully because multiple users may share an IP (office NAT, VPN, mobile carrier).
|
||
</p>
|
||
|
||
<h4>Unblock SOP (short checklist)</h4>
|
||
<ol>
|
||
<li><strong>Verify requester:</strong> confirm account identity (email/mobile/user ID) from ticket context.</li>
|
||
<li><strong>Check scope:</strong> determine whether block is user-level, IP-level, or both.</li>
|
||
<li><strong>Apply least-risk fix:</strong> run <code>unblockUser()</code> first; use <code>unblockIp()</code> only if still blocked and justified.</li>
|
||
<li><strong>Audit it:</strong> record ticket ID, operator, timestamp, action taken, and reason.</li>
|
||
<li><strong>Watch rebound:</strong> monitor logs/metrics for quick re-block; escalate if abuse pattern continues.</li>
|
||
</ol>
|
||
|
||
<h2 id="cache-ttl-auto-release">Cache TTL and auto-release</h2>
|
||
|
||
<p>
|
||
Runtime enforcement of a block is whether the block payload exists in the application cache
|
||
(<code>RateLimiterService::blockIp()</code> / <code>blockUser()</code> call
|
||
<code>$this->cache->save(..., $ttl)</code>). When <code>$ttl</code> is a positive number of seconds
|
||
(medium and hard levels in <code>app/Config/RateLimiter.php</code>), the entry expires after that
|
||
period. The next <code>cache->get()</code> no longer returns block data, so the client is no longer
|
||
blocked for API checks (throttle and violation keys use their own TTLs).
|
||
</p>
|
||
|
||
<p>
|
||
With the default <strong>file</strong> cache handler (<code>app/Config/Cache.php</code>),
|
||
CodeIgniter’s <code>FileHandler</code> treats an item as expired when
|
||
<code>now > stored_time + ttl</code>; on read it removes the file and returns empty, so behaviour
|
||
matches a timed release without a separate unlock job.
|
||
</p>
|
||
|
||
<p>
|
||
When a level’s configured duration is <code>0</code> (for example soft blocks in the stock config),
|
||
the service stores a very long TTL (effectively manual unblock). Those rows are not “timed” blocks
|
||
in the operational sense.
|
||
</p>
|
||
|
||
<h2 id="db-reconciliation-cron">DB reconciliation (cron)</h2>
|
||
|
||
<p>
|
||
Active blocks are also upserted into the <code>rate_limit_blocks</code> table for admin visibility
|
||
(<code>/security/rate-limits</code>). Cache entries for medium/hard can disappear on TTL while the
|
||
database row stays <code>status = active</code> until something cleans it up. A scheduled job keeps
|
||
the index aligned with real enforcement and clears any leftover cache keys.
|
||
</p>
|
||
|
||
<p>
|
||
Spark command (implementation: <code>app/Commands/RateLimitBlocksReconcile.php</code>):
|
||
</p>
|
||
|
||
<pre><code class="language-bash">php spark rate-limit:reconcile-blocks --dry-run
|
||
php spark rate-limit:reconcile-blocks</code></pre>
|
||
|
||
<p>
|
||
Behaviour summary:
|
||
</p>
|
||
|
||
<ul>
|
||
<li>Selects rows where <code>status = 'active'</code>.</li>
|
||
<li>Computes expiry as <code>blocked_at + duration(block_level)</code> using the same duration fields
|
||
as <code>RateLimiter</code> (<code>userBlock</code> vs <code>ipBlock</code> depending on
|
||
<code>block_type</code>). Rows whose duration is <code><= 0</code> are skipped so permanent-style
|
||
soft blocks are not removed by the job.</li>
|
||
<li>If the row is past that time: calls <code>RateLimiterService::purgeIpBlockCaches()</code> or
|
||
<code>purgeUserBlockCaches()</code> (same cache keys as manual unblock, without updating the DB
|
||
row first), then <strong>deletes</strong> the row from <code>rate_limit_blocks</code>.</li>
|
||
<li><code>--dry-run</code> prints what would be reconciled without changing cache or the database.</li>
|
||
</ul>
|
||
|
||
<p>
|
||
Example cron (every 10 minutes on Linux):
|
||
</p>
|
||
|
||
<pre><code class="language-bash">*/10 * * * * cd /path/to/nhance && php spark rate-limit:reconcile-blocks >> /path/to/logs/rate-limit-reconcile.log 2>&1</code></pre>
|
||
|
||
<p>
|
||
On Windows, use Task Scheduler with the same command, set “Start in” to the project directory, and a
|
||
10-minute trigger. Deleting reconciled rows removes them from the admin list; if you need a full audit
|
||
trail instead, consider changing the job to mark <code>unblocked</code> rather than delete (not the
|
||
current implementation).
|
||
</p>
|
||
|
||
<div class="callout warning">
|
||
<span>!</span>
|
||
<div>
|
||
<strong>Config changes:</strong> expiry for reconciliation uses <strong>current</strong>
|
||
<code>RateLimiter</code> values, not the numbers that were in effect when the block was created.
|
||
If you shorten durations in config, old rows can become eligible sooner than the original policy.
|
||
</div>
|
||
</div>
|
||
|
||
<h2 id="http-responses">HTTP responses</h2>
|
||
|
||
<p>
|
||
When the service returns a structured result, filters respond with JSON of the form:
|
||
</p>
|
||
|
||
<pre><code class="language-json">{
|
||
"success": false,
|
||
"error": {
|
||
"code": "RATE_LIMIT_THROTTLE",
|
||
"message": "Too many requests. Please slow down.",
|
||
"type": "ip"
|
||
}
|
||
}</code></pre>
|
||
|
||
<p>
|
||
For progressive blocks, <code>code</code> uses <code>RATE_LIMIT_</code> plus the level
|
||
(<code>SOFT</code>, <code>MEDIUM</code>, <code>HARD</code>), and <code>type</code> is
|
||
<code>ip</code> or <code>user</code>. Messages for blocks are defined in
|
||
<code>RateLimiterService::blockedResponse()</code>.
|
||
</p>
|
||
|
||
<h2 id="wiring-routes">Wiring routes</h2>
|
||
|
||
<p>
|
||
Aliases in <code>app/Config/Filters.php</code>:
|
||
</p>
|
||
|
||
<pre><code class="language-php">'AuthApiRateLimitFilter' => AuthApiRateLimitFilter::class,
|
||
'JwtApiRateLimitFilter' => JwtApiRateLimitFilter::class,</code></pre>
|
||
|
||
<p>
|
||
Attach them per route (or route group) with the <code>filter</code> option, for example:
|
||
</p>
|
||
|
||
<pre><code class="language-php">$routes->post('api/auth/verify-otp', 'AuthController::verifyOtp', ['filter' => 'AuthApiRateLimitFilter']);
|
||
$routes->get('api/profile', 'ProfileController::index', ['filter' => 'JwtApiRateLimitFilter']);</code></pre>
|
||
|
||
<p>
|
||
Ensure JWT helpers used by <code>JwtApiRateLimitFilter</code> match your authentication stack; the
|
||
filter comments note replacing helper names if your project uses different entry points.
|
||
</p>
|
||
|
||
<h2 id="blocked-list-url">Blocked list URL</h2>
|
||
|
||
<p>
|
||
Admin can view active blocked IP and blocked user entries at:
|
||
</p>
|
||
|
||
<pre><code class="language-text">/security/rate-limits</code></pre>
|
||
|
||
<p>
|
||
This page has two tabs (IP and User), shows block level, and provides unblock action per row.
|
||
It is protected by ACL and restricted to <code>ADMIN_ROLE_ID</code> only.
|
||
</p>
|
||
|
||
<p>
|
||
Direct actions on the same feature:
|
||
</p>
|
||
|
||
<pre><code class="language-text">POST /security/rate-limits/unblock-ip
|
||
POST /security/rate-limits/unblock-user</code></pre>
|
||
|
||
<h2 id="smoke-test-command">Smoke test command</h2>
|
||
|
||
<p>
|
||
The project includes a targeted smoke test for this service at
|
||
<code>tests/unit/RateLimiterServiceSmokeTest.php</code>. Run it with:
|
||
</p>
|
||
|
||
<pre><code class="language-bash">php vendor/bin/phpunit --filter RateLimiterServiceSmokeTest</code></pre>
|
||
|
||
<p>
|
||
Expected result on success: <code>OK (3 tests, 77 assertions)</code> (assertion count can change as
|
||
tests evolve).
|
||
</p>
|
||
|
||
<h2 id="operational-notes">Operational notes</h2>
|
||
|
||
<ul>
|
||
<li>Cache backend choice (Redis, file, etc.) affects how limits behave across multiple PHP workers;
|
||
use a shared store in production so limits are cluster-wide.</li>
|
||
<li>Timed medium/hard blocks clear from cache automatically when TTL elapses; see
|
||
<a href="#cache-ttl-auto-release">Cache TTL and auto-release</a>. Permanent-style blocks
|
||
(<code>0</code> second duration, stored as a long TTL) still require
|
||
<code>unblockIp</code> / <code>unblockUser</code> or admin unblock.</li>
|
||
<li>Run <a href="#db-reconciliation-cron">DB reconciliation (cron)</a> on a schedule if you want
|
||
<code>rate_limit_blocks</code> rows removed after timed blocks end, and stray cache files cleared.</li>
|
||
<li>Filters skip recording failures on 429, 403, and 451 so rate-limit and block responses are not
|
||
double-counted as application failures.</li>
|
||
</ul>
|