Enterprise Middleware

Quotas Middleware

Enforce what each customer's plan allows, on requests and on units such as credits, per consumer, tenant or plan, after authentication.

Introduction

Note: This middleware is only available with Kipchak Enterprise.

Rate limiting protects the API from abuse, before anyone is authenticated. Quotas enforce what each customer has bought: "the free plan allows 1,000 requests a day", "Pro allows 50,000 credits a month", "everyone signing in through Acme's identity provider shares 10,000 requests an hour". They run after authentication, so they count verified consumers.

How quotas work

  1. The request's consumer (and tenant) comes from Consumer Identity: an API key's client name, a claim of a verified token, a signed request's key ID.
  2. The consumer's plan comes from, in order: a plan source (for example a claim your identity provider puts in the token), the consumers map, then default_plan. A consumer with no plan is not limited.
  3. The request is counted against each of the plan's request limits. If any is used up, the request is refused with 429 and is not counted against the others.
  4. If any of the plan's unit limits (credits, tokens, rows, ...) is already used up, the request is refused. Otherwise, after the route has run, what it recorded with Usage Metering is added.

Each limit counts per consumer by default, or per tenant, or once for everyone on the plan. Counters are shared by every worker on every host and are atomic (they use the same counters as Subashi's rate limiting), so a quota holds under concurrent load: in testing, 14 simultaneous requests on a plan allowing 10 let exactly 10 through, and four clients of one identity provider sharing 20 requests got exactly 20 between them.

Installation

To install this middleware, you need access to the Enterprise Composer repository at https://php.pkgs.1x.ax/.

composer require kipchak/middleware-quotas

It installs Kipchak Identity and Subashi, whose counters it uses. You do not need to enable the Subashi firewall.

Add it to middlewares/middlewares.php before the authentication middlewares, and before Metering if you use it, so that it runs after both:

use Kipchak\Middleware\Quotas\Quotas;
use Kipchak\Middleware\Metering\Metering;

Error::initialise($app);
Quotas::initialise($app);        // runs after authentication, and inside Metering
Metering::initialise($app);
Key::initialise($app);           // or JWKS, JWT, HMAC
JWKS::initialise($app);
SubashiPro::initialise($app);    // a firewall, if you use one, runs first

Define who requests are from in config/kipchak.identity.php (see Consumer Identity), and the plans in config/kipchak.quotas.php; the package's sample.config.php is a starting point.

Configuration

return [
    'enabled' => true,            // apply to every request (Quotas::initialise); false to attach Quota to routes instead

    // Counters: 'file' (one host only) by default. Use 'valkey' or 'memcached' on more than one host.
    'store' => 'valkey',
    'valkey_pool' => 'cache',
    'fail_open' => true,          // when the store is down: true allows requests, false refuses with 503
    'headers' => true,            // RateLimit-Policy and RateLimit headers on responses

    'ignore_options' => true,
    'ignore_paths' => ['/status'],

    // Which plan each consumer is on.
    'consumers' => [
        'acme-corp' => 'enterprise',
        'globex' => 'pro',
    ],
    'default_plan' => 'free',     // everyone else; null leaves unlisted consumers unlimited
    'anonymous' => 'allow',       // requests with no consumer: 'allow' (authentication decides) or 'reject' (401)

    'plans' => [
        'free' => [
            'requests' => [
                ['name' => 'burst', 'limit' => 10, 'window' => 1],         // 10 a second
                ['name' => 'daily', 'limit' => 1000, 'period' => 'day'],   // 1,000 a day
            ],
            'units' => [
                ['name' => 'monthly-credits', 'meter' => 'credits', 'limit' => 500, 'period' => 'month'],
            ],
        ],
        'pro' => [
            'requests' => [
                ['name' => 'burst', 'limit' => 50, 'window' => 1],
                ['name' => 'daily', 'limit' => 100000, 'period' => 'day'],
            ],
            'units' => [
                ['name' => 'monthly-credits', 'meter' => 'credits', 'limit' => 50000, 'period' => 'month'],
            ],
        ],
        'enterprise' => [],           // no limits
    ],
];

Each limit has:

KeyDescription
nameIdentifies the limit in responses, logs and counters. Unique within a plan.
limitRequests (or units) allowed. 0 allows none.
windowSeconds, for a sliding window: "at most limit in any window seconds". Request limits only.
periodminute, hour, day or month, in UTC: the count starts again at the start of each period, at the same moment for everyone.
meterFor unit limits: the unit's name as recorded with Usage Metering, such as credits.
perconsumer (default), tenant, or plan: one counter shared by everyone on the plan.

Use window for limits about load ("10 a second", "100 every 5 minutes": 'window' => 300) and period for limits about what was sold ("1,000 a day", "50,000 a month"), which customers expect to reset at a fixed time.

Configuration errors, such as a consumer mapped to a plan that does not exist or a limit with both a window and a period, stop the API at startup with a message naming the key.

Counters are kept per consumer and limit name, not per plan, so a consumer who upgrades mid-month keeps the usage they already have; with the same limit names, they simply have more headroom.

Responses

A refused request receives 429, a Retry-After header and a body with a stable error code:

HTTP/1.1 429 Too Many Requests
Retry-After: 3600
RateLimit-Policy: "burst";q=10;w=1, "daily";q=1000;w=86400
RateLimit: "daily";r=0;t=3600
{
  "code": 429,
  "status": "TOO MANY REQUESTS",
  "data": {
    "error": "quota_exceeded",
    "message": "The 'daily' quota of the 'free' plan is used up",
    "plan": "free",
    "limit": "daily",
    "retry_after": 3600
  }
}

Retry-After is the time until a sliding window frees a request, or until the period ends. Successful responses carry the RateLimit-Policy and RateLimit headers too, so clients can see their remaining allowance. With 'anonymous' => 'reject', a request whose consumer cannot be identified receives 401 with the error consumer_unidentified. When the store is down and fail_open is false, requests receive 503.

Each refusal is logged at warning with the consumer, tenant, plan, limit and path.

Examples

API key clients on plans (auth-key)

Clients and their keys are in kipchak.auth.key; their plans are here, by client name:

// config/kipchak.auth.key.php
'authorised_keys' => [
    'k_live_8f2c...' => 'acme-corp',
    'k_live_31d0...' => 'globex',
    'k_live_77aa...' => 'initech',
],

// config/kipchak.identity.php
'consumer' => [['source' => 'api_key']],

// config/kipchak.quotas.php
return [
    'enabled' => true,
    'consumers' => ['acme-corp' => 'pro'],
    'default_plan' => 'free',                 // globex and initech
    'plans' => [
        'free' => ['requests' => [['name' => 'daily', 'limit' => 1000, 'period' => 'day']]],
        'pro' => ['requests' => [['name' => 'daily', 'limit' => 100000, 'period' => 'day']]],
    ],
];

Each client has its own count: globex using its 1,000 requests does not affect initech.

Plans from your identity provider (auth-jwks, auth-jwt)

When the identity provider knows each client's subscription, put it in the token as a claim and let the token choose the plan. There is no consumers map to keep in step:

// config/kipchak.identity.php
'consumer' => [['source' => 'token', 'claim' => 'client_id']],

// config/kipchak.quotas.php
return [
    'enabled' => true,
    'plan' => [['source' => 'token', 'claim' => 'plan']],    // e.g. "plan": "pro" in the token
    'default_plan' => 'free',                                // tokens without the claim, or naming no defined plan
    'plans' => [
        'free' => ['requests' => [['name' => 'hourly', 'limit' => 100, 'period' => 'hour']]],
        'pro' => ['requests' => [['name' => 'hourly', 'limit' => 5000, 'period' => 'hour']]],
    ],
];

The claim is read from the token after auth-jwks or auth-jwt has verified it, so a client cannot raise its own plan by editing its token. A claim naming a plan that is not defined falls back to the consumers map and then default_plan.

Providers carry such claims differently: a custom claim added by a mapper (Keycloak) or an action (Auth0), an app role (Entra ID), or a group. A map turns whatever value you have into a plan name:

'plan' => [
    ['source' => 'token', 'claim' => 'subscription_tier', 'map' => ['T1' => 'free', 'T2' => 'pro', 'T3' => 'enterprise']],
],

Limits shared by everyone from one identity provider

To say "all consumers who sign in through this identity provider can make, between them, at most 10,000 requests an hour", put everyone from that provider on one plan and give the plan a limit with 'per' => 'plan'. The provider is identified by the token's issuer (iss), and map turns it into the plan:

return [
    'enabled' => true,
    'plan' => [
        ['source' => 'token', 'claim' => 'iss', 'map' => [
            'https://login.acme.example/realms/main' => 'acme-sso',
        ]],
    ],
    'plans' => [
        'acme-sso' => [
            'requests' => [
                // Everyone from this provider together: 10,000 an hour, and no more than 2,000 in any 10 minutes.
                ['name' => 'acme-sso-hourly', 'limit' => 10000, 'period' => 'hour', 'per' => 'plan'],
                ['name' => 'acme-sso-burst', 'limit' => 2000, 'window' => 600, 'per' => 'plan'],
                // And each consumer on their own: 500 an hour.
                ['name' => 'acme-sso-each', 'limit' => 500, 'period' => 'hour'],
            ],
        ],
    ],
];

Any mix of windows and periods works: 'window' => 300 for "every 5 minutes", 'period' => 'day' for "a day". Shared limits and per-consumer limits combine; a request must fit within all of them. Limits are checked from the narrowest to the widest, so a consumer who has used up their own allowance never takes up room in the shared one, even for an instant.

The same applies to an API that accepts tokens from only one provider: put everyone on one plan with default_plan, and every consumer with a verified token shares its 'per' => 'plan' limits.

To limit each organisation rather than everyone, use 'per' => 'tenant' with the tenant from an organisation claim:

// config/kipchak.identity.php
'tenant' => [['source' => 'token', 'claim' => 'org_id']],

// a plan's request limit
['name' => 'org-daily', 'limit' => 20000, 'period' => 'day', 'per' => 'tenant'],

Credits, tokens and other units

Unit limits count what each request consumed, as recorded with Usage Metering: a fixed amount per route, or an amount the route records itself.

// config/kipchak.metering.php: every report costs 5 credits
'units' => ['POST /v1/reports' => ['credits' => 5]],

// a route that knows its own cost
Usage::of($request)?->add('credits', $pages);
Usage::of($request)?->add('tokens', $completion->usage->totalTokens);

// config/kipchak.quotas.php
'plans' => [
    'free' => ['units' => [
        ['name' => 'monthly-credits', 'meter' => 'credits', 'limit' => 500, 'period' => 'month'],
        ['name' => 'daily-tokens', 'meter' => 'tokens', 'limit' => 200000, 'period' => 'day'],
    ]],
],

A request is allowed while the consumer is under the limit, and what it consumed is added once it has run, so the request that crosses the limit completes and the next one is refused. Fractional amounts are rounded up. The units counted are the ones Metering bills, so the quota and the invoice agree.

Unit limits need Metering. Without it there is nothing to count, and a warning is logged once per worker.

Signed requests (auth-hmac)

// config/kipchak.identity.php
'consumer' => [['source' => 'attribute', 'name' => 'kipchak.hmac.key_id']],

// config/kipchak.quotas.php
'consumers' => ['billing-service' => 'internal', 'partner-gateway' => 'partner'],

Quotas on some routes only

Leave enabled as false and attach the middleware where it applies, after authentication on the same route or group:

use Kipchak\Middleware\Quotas\Handlers\Quota;

$app->group('/v1/ai', function ($group) {
    // ...
})->add(new Quota($app->getContainer()));

With global authentication this is all that is needed. With authentication on the group, add the quota first and the authentication middleware after it, so that authentication runs first.

Quotas and Subashi's rate limiting

Rate limitingQuotas
PurposeStop abuseEnforce what each customer bought
RunsBefore authenticationAfter authentication
Counts byIP address, or a secret (API key, bearer token)Verified consumer, tenant or plan
LimitsOne set of rules for allPer plan
UnitsRequestsRequests, and units such as credits

Most APIs use both: a per-IP rate limit to absorb floods cheaply, and quotas for the plans.

Upgrading from Subashi Pro 1.31

Quotas were part of Subashi Pro in release 1.31 and moved to this package in Subashi Pro 1.32. Install kipchak/middleware-quotas, move the contents of the quotas block in kipchak.subashi.pro.php into config/kipchak.quotas.php (the same keys, at the top level, and set store there if you relied on the firewall's rate_limiting store), and replace SubashiPro::quotas($app) with Quotas::initialise($app).

Git Repository

The source code for this middleware is hosted internally.

Previous
Usage Metering