Rate limit
Apply rate limits per call or via defaults. Supports fixed window and token bucket.
How it works
- Applies a rate limit per verification call using an identifier and namespace. If no per-call
rateLimit
is provided, a plugin default can apply. - When exceeded, verification rejects with
"rate_limited"
(customizable).
Default vs per-call precedence
- If
verifyKey
provides arateLimit
, that per-call limit is used and any plugindefault
is ignored for that call. - Otherwise, if the plugin is configured with a
default
, that default is used. - If neither is present, no rate limiting is applied.
- Limits are not combined or applied cumulatively; exactly one configuration is enforced per call.
Precedence in detail
The plugin evaluates the effective configuration in this order per call:
- Use
input.rateLimit
if present (per-call override). - Else, use the plugin
default
if configured during setup. - Else, skip rate limiting for that call.
Examples:
// 1) Per-call override wins
await uk.verifyKey({
key,
identifier: "ip_1.1.1.1",
namespace: "api",
rateLimit: { kind: "fixed", limit: 100, duration: "1m" },
});
// Uses limit:100/1m (ignores any plugin default)
// 2) Falls back to plugin default when per-call not set
await uk.verifyKey({
key,
identifier: "ip_1.1.1.1",
namespace: "api",
});
// Uses the plugin default if configured (e.g., limit:10/30s)
// 3) No limit if neither per-call nor default present
await uk.verifyKey({ key, identifier: "ip_1.1.1.1", namespace: "api" });
// If the plugin was configured without a default, this call is not limited
Note: You must provide a
namespace
to enable limiting. Calls without anamespace
skip rate limiting entirely.
Settings
You can configure the plugin either via a shorthand or with full defaults.
Option | Type | Default | Description |
---|---|---|---|
limit | number | — | Shorthand: fixed window limit. Requires duration . |
duration | `string | number` | — |
default | RateLimitRequest | undefined | Default rate-limit applied when a call omits rateLimit . |
identify | `(i: VerifyOptions) => string | null` | identifier ?? ip ?? key |
reason | string | "rate_limited" | Reject reason when limit exceeded. |
analyticsKind | string | undefined | Overrides analytics event kind label (fixed , tokenBucket ). |
RateLimitRequest
shapes:
- Fixed window:
{ kind: "fixed", limit: number, duration: string | number }
- Token bucket:
{ kind: "tokenBucket", capacity: number, refill: { tokens: number, interval: string | number }, cost?: number }
Per-call options for verify
Alongside the key and optional IP, pass:
Field | Type | Required | Description |
---|---|---|---|
identifier | string | No | Overrides identifier used for limiting. |
namespace | string | Yes (to enable) | Namespaces counters; required when using rate limits. |
rateLimit | RateLimitRequest | No | Per-call limit; overrides the plugin default if provided. |
Emitted analytics on block: "ratelimit.blocked"
with payload { kind, namespace, identifier, reset, limit?, capacity?, remaining, ts }
.
Usage
import { usefulkey } from "betterkey";
import { ratelimit } from "usefulkey/plugins/rate-limit/global";
// Optional default (applies when a verify call does not pass rateLimit)
const uk = usefulkey({}, {
plugins: [
ratelimit({ default: { kind: "fixed", limit: 10, duration: "30s" } }),
],
});
// Per-call override
await uk.verifyKey({
key,
identifier: req.ip,
namespace: "app",
rateLimit: { kind: "fixed", limit: 100, duration: "1m" },
});
// Note: `namespace` must be provided to enable limiting. Calls without a
// namespace skip rate limiting entirely.
Verification behavior
- Exceeded →
{ valid: false, reason: "rate_limited" }
(or customreason
). - Identifier resolution:
identifier ?? ip ?? key
unless overridden.
Namespaces
Namespaces partition rate-limit counters. Limits are tracked per (namespace, identifier)
pair, so changing either creates an independent counter.
- Why use namespaces? To isolate rate limits by API surface, plan, or feature. Common choices:
"global"
,"api"
,"auth"
,"uploads"
, or per-tenant values. - Storage shape (conceptual): counters are keyed like
"<namespace>:<identifier>"
. For example,"global:ip_1.1.1.1"
. - Omitting namespace: disables rate limiting for that call.
- Changing namespace: resets the window/bucket for the call because it addresses a different counter.
Identifier defaults to identifier ?? ip ?? key
but can be overridden per call (set identifier
) or globally via the plugin identify
function.
Configure a default
import { usefulkey } from "betterkey";
import { ratelimit } from "usefulkey/plugins/rate-limit/global";
const uk = usefulkey({}, {
plugins: [
ratelimit({
// Applies when a call does not pass its own rateLimit
default: { kind: "fixed", limit: 100, duration: "1m" },
}),
],
});
Per-call override
await uk.verifyKey({
key,
identifier: req.ip, // who to limit (override if needed)
namespace: "api", // required to enable limiting
rateLimit: { kind: "tokenBucket", capacity: 120, refill: { tokens: 2, interval: "1s" } },
});
Namespaces example
import { usefulkey } from "betterkey";
import { ratelimit } from "usefulkey/plugins/rate-limit/global";
// Configure a default global limit: 100 requests per minute
const uk = usefulkey({}, {
plugins: [
ratelimit({ default: { kind: "fixed", limit: 100, duration: "1m" } }),
],
});
// 1) Global traffic — counts per (namespace="global", identifier)
await uk.verifyKey({
key,
identifier: req.ip, // who is limited (could be userId or key)
namespace: "global", // what is limited (global bucket)
});
// 2) Auth endpoints — their own tighter bucket
await uk.verifyKey({
key,
identifier: req.ip,
namespace: "auth", // separate counters from "global"
rateLimit: { kind: "fixed", limit: 10, duration: "1m" }, // per-call override
});
// 3) Uploads — token bucket for smoother bursts
await uk.verifyKey({
key,
identifier: userId, // limit per user
namespace: "uploads",
rateLimit: {
kind: "tokenBucket",
capacity: 30,
refill: { tokens: 1, interval: "2s" },
},
});
// 4) Tenant isolation — each tenant has its own namespace
await uk.verifyKey({
key,
identifier: userId,
namespace: `tenant:${tenantId}`, // independent counters per tenant
});
Notes:
- Only one configuration is applied per call (they are not combined).
- A
namespace
is required; if omitted, rate limiting is skipped.
Choosing a storage backend
Rate limits are persisted using a rate limit store adapter. Choose one that matches your environment:
- Memory (default): Best for tests or single-process demos.
- Redis: Great for distributed environments; fast and scalable.
- Postgres or SQLite: Useful when you prefer SQL or already have a database.
See available adapters: Rate limit store adapters.
Best practices
- Pick clear namespaces: Separate
"api"
,"auth"
,"uploads"
, or per-tenant values to isolate counters. - Be consistent with identifiers: Use the same identifier source for comparable calls (for example, always IP or always user ID).
- Start with conservative defaults: Then relax as you learn real traffic patterns.
- Handle rejections gracefully: Return a clear error and include the reset time if possible.
- Monitor and tune: Use analytics to track blocks and adjust limits over time.
Troubleshooting
- Limits never trigger: Ensure you passed a
namespace
and that your identifier is notnull
. - Unexpectedly tight or loose limiting: Double-check the strategy and values (for example,
duration
units, refill rates, or capacity). - Different endpoints share limits: Use different
namespace
values to separate them.
See also
- Adapter reference: Rate limit store adapters