Search by

sugarcommerce / rate-limit

arraypress

Sliding-window rate limiting with atomic counters. APCu, SQLite, file and in-memory stores, standard rate-limit headers, and an API that makes forgetting the identity key impossible. Zero dependencies.

Package info

github.com/SugarCommerce/rate-limit

Homepage

Issues

pkg:composer/sugarcommerce/rate-limit

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

1.3.0 2026-10-22 09:26 UTC

This package is auto-updated.

Last update: 2026-10-22 15:44:40 UTC


README

Sliding-window rate limiting with atomic counters. APCu, SQLite, file and in-memory stores, standard headers, and an API that makes forgetting the identity key impossible. Zero dependencies.

Features

  • 🎯 Identity is mandatory — the API refuses a limit with nothing to count against.
  • 📉 Sliding window — no double-allowance burst across the window boundary.
  • ⚛️ Atomic counters, atomic decisions — no read-then-write race in the store, and the allow/deny is decided on the count the increment returned, so a concurrent burst gets exactly the allowance.
  • 🌐 One caller, one counter — an IPv6 address counts as its /64, addresses are compared in canonical form, case is folded beyond ASCII.
  • 🗄️ Pluggable stores — APCu, SQLite, file, memory; anything else is one interface.
  • 📤 Standard headers — X-RateLimit-* and Retry-After.
  • 🔒 Identities are hashed — emails and IPs never become cache keys or filenames.

Requirements

PHP 8.5+. ext-apcu recommended; pdo_sqlite with SQLite 3.35+ for the SQLite store.

Installation

composer require sugarcommerce/rate-limit

Usage

use SugarCommerce\RateLimit\{RateLimiter, Stores\Apcu};

$limiter  = new RateLimiter( new Apcu() );
$decision = $limiter->for( 'login', $ip )->check( 5, 60 );

if ( $decision->denied() ) {
    http_response_code( 429 );

    foreach ( $decision->headers() as $name => $value ) {
        header( "$name: $value" );
    }

    exit;
}

Clear the counter when the thing being protected succeeds, so someone who mistyped their password four times isn't left one attempt from a lockout:

$limiter->for( 'login', $ip )->clear( 60 );

The mistake this API prevents

$limiter->check( 'login', 5, 60 );          // one counter for the whole internet
$limiter->for( 'login', $ip )->check( 5, 60 );   // one per visitor

The first shares a single counter across every visitor, so five attempts from anyone locks out everybody. An attacker denies your entire customer base by hammering the endpoint from one machine. It reads like rate limiting and is closer to a self-inflicted outage — which is why for() requires the identity rather than accepting it optionally.

Key by the thing being protected, not just the network. IP is right for "requests to this endpoint". For "magic links sent to this address", key on the address — otherwise one mailbox can be flooded from a botnet, and an office behind one NAT gets blocked as a group. Often you want both:

$limiter->for( 'magic-link:ip', $ip )->check( 10, 3600 );
$limiter->for( 'magic-link:email', $email )->check( 3, 3600 );

An IPv6 address is counted as its /64. A single host is routinely handed a whole /64 and can send from any of its 2^64 addresses, so a counter per address is no limit at all. Addresses are also compared in canonical form — 2001:DB8:0::1 is 2001:db8::1, and ::ffff:192.0.2.1 is 192.0.2.1 — and case is folded beyond ASCII when mbstring is loaded. Limit::identity() shows the form that is counted. Normalise anything else yourself — the same way you do before the lookup the limit protects — or a caller with trailing junk on an email address is a new caller each time.

Named rules

An application's limits in one table, so a call site names the action and the numbers live in one place:

use SugarCommerce\RateLimit\{RateLimiter, Rules};

$rules = new Rules( RateLimiter::auto( __DIR__ . '/var/limits' ), array(
    'login'           => array( 10, 900 ),    // [ attempts, seconds ]
    'magic-link:mail' => array( 3, 3600 ),
) );

$decision = $rules->check( 'login', $ip );    // counts

if ( $decision->denied() ) {
    return new Response( 429, '', $decision->headers() );
}

$rules->allows( 'token.miss', $ip );          // looks, without counting

An action with no rule, or an empty key, is never limited: the answer is Decision::unlimited(), allowed and with no headers. A typo in an action name should not take an endpoint down, and every caller with nothing to count against sharing one '' counter would let any of them lock out the rest. has() is there for a test that proves every action you use has a rule.

RateLimiter::auto( $directory ) picks the APCu store when APCu is enabled in this process, and the file store otherwise. Checking extension_loaded( 'apcu' ) or apc.enabled is the common mistake: both are true on a command line where apc.enable_cli is off, the APCu store throws, and every test run and cron job dies on the servers that have APCu for their web workers.

Why sliding window

A fixed window resets on a clock boundary, so the full allowance can be spent at 0:59 and again at 1:01 — double the limit across two seconds, which is precisely the burst you were trying to stop.

This weights the previous window by how much of it is still in view. Ten seconds into a sixty-second window, fifty seconds of the previous one still fall inside the trailing minute, so it counts for 50/60. Same storage cost, no boundary hole.

Stores

Store Shared across Notes
Apcu all workers on one server Fast, atomic in the extension. The default choice.
Sqlite every worker that opens the database Survives restarts, no APCu needed. Atomic in one upsert; prunes itself.
File all workers on one server No extension needed. Touches disk per request; call prune() from cron. Throws when it cannot open or lock a counter, rather than counting the attempt as a first.
Memory one process Tests, and long-running workers. Useless for FPM — each worker gets its own counters.

SQLite

For an application already on SQLite: counters that every PHP-FPM worker shares and that survive a restart, without APCu.

use SugarCommerce\RateLimit\{RateLimiter, Stores\Sqlite};

$pdo = new PDO( 'sqlite:' . __DIR__ . '/var/app.sqlite' );
$pdo->exec( 'PRAGMA journal_mode = WAL' );
$pdo->exec( 'PRAGMA busy_timeout = 5000' );

$store = new Sqlite( $pdo );   // table `rate_limits`; or new Sqlite( $pdo, 'throttle' )
$store->install();             // CREATE TABLE / INDEX IF NOT EXISTS — or put $store->schema() in a migration

$limiter = new RateLimiter( $store );

Each increment is a single INSERT … ON CONFLICT DO UPDATE … RETURNING, so concurrent workers never read the same count. The table name is interpolated into SQL and must be a plain identifier; anything else throws.

The limiter writes a new key every window, so expired rows build up. One increment in 1,000 also deletes them — change the odds with the third argument, or pass 0 and call $store->prune() from cron. Set a busy_timeout, or a worker that finds the database locked fails instead of waiting.

Behind a load balancer, per-machine stores mean the effective limit multiplies by the server count. Implement Store against Redis or your database when that matters — it's three methods, and the only requirement is that increment() is atomic.

Testing

composer install
composer test

92 tests. Includes an explicit boundary-burst test proving a fixed-window implementation would let double the allowance through, a concurrent burst that must get exactly the allowance, IPv6 /64 and canonical-address counting, named rules with their never-limited cases, the store auto() picks, and the SQLite store's window semantics, pruning and table-name checks.

License

GPL-2.0-or-later