Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

Per-IP rate limiting in plain PHP with no Redis: a file-based counter that keeps my downloader alive

My downloader site runs on Hostinger's cheapest shared plan, which means no Redis, no Memcached, and no background workers. When I needed to stop abusive clients hammering the resolve endpoint, I had to do per-IP rate li

My downloader site runs on Hostinger's cheapest shared plan, which means no Redis, no Memcached, and no background workers. When I needed to stop abusive clients hammering the resolve endpoint, I had to do per-IP rate limiting with the tools PHP actually gives you on shared hosting: files.

The endpoint is resolve.php. It takes a pasted link, calls a provider API, and hands back a download URL. Every call costs me real money because the provider APIs are paid per request. One scraper looping that endpoint in a tight loop would burn through my quota in minutes, so the very first thing the script does now is check the caller's IP against a tiny counter.

The idea

Each visitor's IP gets its own small JSON file in a ratelimit/ directory outside the web root. The file holds two numbers: how many requests have been counted in the current window, and when the window started. The rule is simple: 30 requests per minute per IP. Over the limit, the script returns a 429 and a plain-text message, and nothing else runs — no provider call, no money spent.

Handling concurrency with file locks

The naive version reads the file, increments, and writes it back. That breaks when two requests land in the same millisecond, because both read the same number and both write the same incremented value — one request goes uncounted. On shared hosting the fix is flock():

$fp = fopen($file, 'c+');
flock($fp, LOCK_EX);          // exclusive lock, blocks other requests
$data = json_decode(stream_get_contents($fp), true) ?? ['count' => 0, 'start' => time()];
if (time() - $data['start'] > 60) {
    $data = ['count' => 0, 'start' => time()]; // new window
}
$data['count']++;
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data));
flock($fp, LOCK_UN);
fclose($fp);
if ($data['count'] > 30) {
    http_response_code(429);
    echo 'Slow down — too many requests from your address.';
    exit;
}

The lock is the whole point. LOCK_EX makes every other request for that IP wait its turn, so the count is never lost. With a per-minute counter the critical section is microseconds long, so nobody notices the wait.

Cleaning up stale files

The obvious worry is that one file per IP piles up forever. In practice the fix is boring: every time the window rolls over, the file is rewritten fresh anyway, and a tiny probability-based sweep — something like if (mt_rand(1, 100) === 1) — deletes files older than an hour. A couple of thousand one-line files is nothing for a filesystem, and they rot away on their own.

Why not just check in the .htaccess or use a firewall?

Because my limit needs to sit in front of the expensive part but behind the cheap part: I want the request to reach PHP so the counter updates, but I want to bail before the provider API gets called. A web-server-level rule would either block everything (bad — shared hosting WAFs are blunt) or nothing. Doing it in code means the limit lives exactly where the cost is.

What I learned

File-based rate limiting is unfashionable, and for a service doing thousands of requests a second it would be a terrible idea. But for a small tool site — this is what keeps ramrom.io's resolve endpoint affordable — it is about thirty lines of code, zero dependencies, and it has survived every scrape attempt so far. Sometimes the right infrastructure is a directory full of tiny JSON files.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.