The RateLimiter class automatically enforces a per-IP request quota on every request before any application code executes. When a visitor exceeds the configured limit it throws an HttpException(429 Too Many Requests) — optionally banning repeat offenders for a configurable duration.
- Class:
JiFramework\Core\Security\RateLimiter - Access:
$app->rateLimiter - Runs: automatically inside
App::__construct()— no manual call needed - Storage: SQLite file at
storage/RateLimit/rate_limit.db
Rate limiting is opt-in — it does nothing until you set rate_limit_enabled = true in your config. When disabled, the class is fully inert: no database is opened and every method is a safe no-op.
How it works:
- Each incoming request is checked against a sliding time window (default: 60 seconds).
- If the visitor's request count exceeds the limit and banning is enabled, their IP is written to the
banstable and all subsequent requests are rejected until the ban expires. - If the ProxyCheck API or SQLite backend becomes unavailable, the class disables itself and fails open — all requests are allowed rather than crashing the app.
- Garbage collection (cleaning up expired rows) runs probabilistically on roughly 1% of requests so there is no fixed overhead on every call.
// Rate limiting runs automatically — no code needed in index.php.
// The App constructor handles everything:
$app = new App(); // 429 thrown here if visitor is rate-limited or banned
// You can also interact with the limiter directly:
$remaining = $app->rateLimiter->getRemainingRequests();
config/jiconfig.php keys
All rate-limiter settings live in your config/jiconfig.php. None are required — every key has a safe default.
rate_limit_enabled— (bool) Master switch. Set totrueto activate. Default:falserate_limit_requests— (int) Maximum number of requests allowed per IP per window. Default:500rate_limit_time_window— (int) Sliding window length in seconds. Default:60rate_limit_ban_enabled— (bool) Ban IPs that exceed the limit instead of just rejecting the single request. Default:truerate_limit_ban_duration— (int) How long a ban lasts in seconds. Default:3600(1 hour)rate_limit_database_path— (string) Custom path for the SQLite database file. Default:storage/RateLimit/rate_limit.db
// config/jiconfig.php
return [
'rate_limit_enabled' => true,
'rate_limit_requests' => 100, // 100 requests ...
'rate_limit_time_window' => 60, // ... per 60 seconds
'rate_limit_ban_enabled' => true,
'rate_limit_ban_duration'=> 3600, // ban for 1 hour
];
Tip: Start with a generous limit (e.g. 500 requests per minute) and tighten it once you have real traffic data. A limit that is too low will frustrate legitimate users.
enforceRateLimit(): void
Checks the current visitor's IP against the request log and ban table, then either allows the request or throws HttpException(429). This method is called automatically by the framework — you do not need to call it yourself.
Behaviour:
- Does nothing when
rate_limit_enabled = falseor when the SQLite backend is unavailable (fail open). - If the IP is actively banned, throws
HttpException(429)with the remaining ban time in seconds. - If the IP has exceeded the request limit and banning is enabled, bans the IP then throws
HttpException(429). - If the IP has exceeded the request limit and banning is disabled, throws
HttpException(429)for this request only (no ban recorded). - If the request is allowed, records a timestamp in the
requeststable and returns.
Throws: HttpException(429)
// Called automatically — no code needed:
$app = new App(); // enforceRateLimit() runs here
// The ErrorHandler catches the exception and renders the 429 page.
// In production mode the generic "Too Many Requests" page is shown.
// In development mode the full message is displayed.
isBannedIp(string $ip): bool
Check whether a specific IP address is currently under an active ban. Returns false when the ban has expired, the IP was never banned, or the backend is unavailable.
$ip— (string) The IP address to check.
Returns: bool — true if an active (non-expired) ban exists, false otherwise.
if ($app->rateLimiter->isBannedIp('203.0.113.42')) {
echo 'This IP is currently banned.';
}
banIp(string $ip, ?int $duration = null): bool
Manually ban an IP address for a given duration. If the IP is already banned the existing ban is replaced (effectively extending or shortening it). Useful for admin panels, login-failure handlers, or automated abuse detection.
$ip— (string) The IP address to ban.$duration— (int|null) Ban length in seconds. Defaults toConfig::$rateLimitBanDurationwhennull.
Returns: bool — true on success, false when the backend is unavailable.
// Ban for the default duration (from config)
$app->rateLimiter->banIp('203.0.113.42');
// Ban for a specific duration
$app->rateLimiter->banIp('203.0.113.42', 86400); // 24 hours
unbanIp(string $ip): bool
Remove an active ban for an IP address. This operation is idempotent — calling it on an IP that is not banned returns true without error.
$ip— (string) The IP address to unban.
Returns: bool — true on success, false when the backend is unavailable.
// Admin lifts a ban
$app->rateLimiter->unbanIp('203.0.113.42');
// Safe to call even if the IP is not currently banned
$app->rateLimiter->unbanIp('10.0.0.1'); // returns true, does nothing
getBanInfo(string $ip): array|null
Return detailed information about an active ban. Returns null if the IP is not currently banned or the backend is unavailable.
$ip— (string) The IP address to query.
Returns: array|null — associative array on an active ban, null otherwise.
The returned array contains:
ip— (string) The banned IP address.ban_expires— (int) Unix timestamp when the ban expires.seconds_remaining— (int) Seconds until the ban lifts. Always>= 0.
$info = $app->rateLimiter->getBanInfo('203.0.113.42');
if ($info !== null) {
echo 'Banned until ' . date('Y-m-d H:i:s', $info['ban_expires']);
echo ' (' . $info['seconds_remaining'] . ' seconds remaining)';
} else {
echo 'IP is not banned.';
}
getRemainingRequests(?string $ip = null): int
Return how many requests the given IP can still make within the current time window. When no IP is supplied, the current request's IP is used. Returns the full limit when the backend is unavailable.
$ip— (string|null) IP address to check. Uses the current visitor's IP whennull.
Returns: int — remaining requests in the current window. Always >= 0.
// Current visitor
$left = $app->rateLimiter->getRemainingRequests();
header('X-RateLimit-Remaining: ' . $left);
// Specific IP (e.g. for an admin dashboard)
$left = $app->rateLimiter->getRemainingRequests('203.0.113.42');
echo '203.0.113.42 has ' . $left . ' requests remaining.';
resetIp(string $ip): bool
Clear all rate limit data for an IP address in one atomic operation. Deletes both the request history and any active ban. Useful for admin panels that need to give a visitor a clean slate.
$ip— (string) The IP address to reset.
Returns: bool — true on success, false when the backend is unavailable or a database error occurs.
// Fully reset a visitor (removes ban + clears request history)
$ok = $app->rateLimiter->resetIp('203.0.113.42');
if ($ok) {
echo 'Rate limit data cleared. Visitor can make ' . Config::$rateLimitRequests . ' fresh requests.';
}
Note: resetIp() uses a database transaction — either both tables are cleared or neither is, preventing partial state.
Testing IP features on localhost
When developing locally REMOTE_ADDR is always 127.0.0.1 or ::1, which makes it impossible to test IP banning, rate limiting per user, or access control rules without deploying to a real server.
The debug_ip config key solves this. Set it to any IP address and the framework will use that IP for all IP-based checks — rate limiting, access control, and any direct calls to $app->request->getClientIp().
// config/jiconfig.php
return [
'app_mode' => 'development',
'debug_ip' => '203.0.113.42', // your real public IP or any test IP
];
Safety rules:
debug_ipis only honoured whenapp_mode = development. In production it is silently ignored and a warning is written to the log.- Never commit a
jiconfig.phpwithdebug_ipset to a real IP address. - Use a documentation-range IP (e.g.
203.0.113.xfrom RFC 5737) during development so it is clearly fictional.
You can also use it alongside trusted_proxies to simulate running behind a load balancer:
// config/jiconfig.php
return [
'app_mode' => 'development',
'trusted_proxies' => ['127.0.0.1'], // simulate: request arrives via local proxy
'debug_ip' => '203.0.113.42', // the "real" client IP you want to test
];
Add rate-limit headers to every response
// index.php — after $app = new App()
$remaining = $app->rateLimiter->getRemainingRequests();
header('X-RateLimit-Limit: ' . Config::$rateLimitRequests);
header('X-RateLimit-Remaining: ' . $remaining);
Auto-ban after repeated failed logins
// pages/login.php
$ip = $app->request->getClientIp();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$ok = $app->auth->loginUser($_POST['email'], $_POST['password']);
if (!$ok) {
$cacheKey = 'login_fails_' . md5($ip);
$fails = (int) ($app->cache->get($cacheKey) ?? 0) + 1;
$app->cache->set($cacheKey, $fails, 900); // track for 15 minutes
if ($fails >= 5) {
$app->rateLimiter->banIp($ip, 3600); // ban for 1 hour
$app->logger->warning('IP banned after {n} failed logins: {ip}', [
'n' => $fails,
'ip' => $ip,
]);
$app->abort(429, 'Too many failed attempts. Try again in 1 hour.');
}
}
}
Admin panel — view and manage bans
// pages/admin/rate-limits.php
$action = $_POST['action'] ?? '';
$ip = trim($_POST['ip'] ?? '');
if ($action === 'ban' && $ip) {
$app->rateLimiter->banIp($ip, 86400);
$msg = "$ip banned for 24 hours.";
}
if ($action === 'unban' && $ip) {
$app->rateLimiter->unbanIp($ip);
$msg = "$ip has been unbanned.";
}
if ($action === 'reset' && $ip) {
$app->rateLimiter->resetIp($ip);
$msg = "All rate limit data cleared for $ip.";
}
// Show ban info for a looked-up IP
if ($ip) {
$info = $app->rateLimiter->getBanInfo($ip);
if ($info) {
echo "$ip is banned for another {$info['seconds_remaining']}s.";
} else {
$left = $app->rateLimiter->getRemainingRequests($ip);
echo "$ip has $left requests remaining in the current window.";
}
}
Tighter limits on a public API endpoint
// api/search.php — stricter limit than the global default
use JiFrameworkConfigConfig;
// Temporarily tighten the limit for this endpoint only
$origRequests = Config::$rateLimitRequests;
$origWindow = Config::$rateLimitTimeWindow;
Config::$rateLimitRequests = 10; // only 10 search requests ...
Config::$rateLimitTimeWindow = 60; // ... per minute
$app->rateLimiter->enforceRateLimit();
// Restore global settings
Config::$rateLimitRequests = $origRequests;
Config::$rateLimitTimeWindow = $origWindow;
// ... handle the search request