#5 - Rate Limiter Class

Date: 2018-04-21 12:00 - PHP

Allows to rate limit a request to an API or page, for example for login purposes. Makes use of the session save path to save the rate files, which might not be ideal.

<?php
class RateLimit {
    private static $rateLimit;

    private static function init($id, $rate) {
        $rate_file = sha1($id);
        $fp = @fopen(session_save_path() . '/rate_' . $rate_file, 'c+');

        $count = 0;
        $timeout = 5;
        $got_lock = true;

        while ($fp && !@flock($fp, LOCK_EX | LOCK_NB, $would_block)) {
            if ($would_block && $count++ < $timeout) {
                sleep(1);
            } else {
                $got_lock = false;
                break;
            }
        }

        if ($fp && $got_lock) {
            $contents = stream_get_contents($fp);

            if (!empty($contents))
                self::$rateLimit = json_decode($contents, true);
            else
                self::$rateLimit = array('last' => microtime(true), 'allowance' => $rate);

            self::$rateLimit['_fp'] = $fp;
            return;
        }

        self::$rateLimit = array('last' => microtime(true), 'allowance' => $rate);
        if ($fp !== FALSE)
            fclose($fp);
    }

    private static function finish($save) {
        if (!array_key_exists('_fp', self::$rateLimit))
            return;

        $fp = self::$rateLimit['_fp'];
        unset(self::$rateLimit['_fp']);

        if ($save) {
            $str = json_encode(self::$rateLimit);
            ftruncate($fp, 0);
            fseek($fp, 0);
            fputs($fp, $str);
            fflush($fp);
        }

        self::$rateLimit = null;

        flock($fp, LOCK_UN);
        fclose($fp);
        self::gc();
    }

    private static function gc() {
        if (rand(0, 1) != 0)
            return;
        $max_lifetime = 3600 * 24 * 14;
        foreach (glob(session_save_path() . '/rate_*') as $file) {
            if (filemtime($file) + $max_lifetime < time() && file_exists($file)) {
                unlink($file);
            }
        }
    }

    private static function consume($consume, $rate, $time, $limit_fn) {
        $current = microtime('true');
        $time_passed = $current - self::$rateLimit['last'];
        self::$rateLimit['last'] = $current;

        self::$rateLimit['allowance'] += $time_passed * ($rate / $time);
        if (self::$rateLimit['allowance'] > $rate)
            self::$rateLimit['allowance'] = $rate;

        if (self::$rateLimit['allowance'] < $consume) {
            self::finish(false);
            if (is_callable($limit_fn))
                $limit_fn();
            return true;
        }

        self::$rateLimit['allowance'] -= $consume;
        self::finish(true);
        return false;
    }

    public static function limit($rate, $time, $id = '', $limit_fn = 'rate_limit_default_func') {
        if (empty($id))
            $id = rate_limit_default_id();
        if (self::$rateLimit === null)
            self::init($id, $rate);
        return self::consume(1, $rate, $time, $limit_fn);
    }

    public static function delete($id) {
        if (empty($id))
            $id = rate_limit_default_id();
        $file = session_save_path() . '/rate_' . sha1($id);
        if (file_exists($file))
            unlink($file);
    }
}

function rate_limit_default_func() {
    http_response_code(429);
    echo 'rate_limited';
    exit();
}

function rate_limit_default_id() {
    return $_SERVER['SCRIPT_NAME'] . $_SERVER['REMOTE_ADDR'];
}

Usage:

<?php
$accountName = 'myaccount';
$limited = RateLimit::limit(3, 60 * 30, 'login-' . strtolower($accountName), null);
if ($limited) {
    echo 'You reached the limit of attempts of login. Try again in 30 minutes.';
}

// TODO: confirm login

RateLimit::delete('login-' . strtolower($accountName)); // removes the limit in case of successful login

Previous snippet | Next snippet