@pegma/rate-limit 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -4
- package/dist/memory.d.ts +8 -0
- package/dist/memory.d.ts.map +1 -1
- package/dist/memory.js +73 -9
- package/dist/memory.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,10 +9,10 @@ Two explicitly named application rate-limit tiers:
|
|
|
9
9
|
abuse-critical operations.
|
|
10
10
|
|
|
11
11
|
> [!IMPORTANT]
|
|
12
|
-
>
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
12
|
+
> `0.1.0` is the first advertised release, published through
|
|
13
|
+
> trusted-publisher OIDC with provenance. The exact reviewed `0.0.0`
|
|
14
|
+
> package-name bootstrap remains isolated under the `bootstrap` dist-tag and
|
|
15
|
+
> is never advertised as the current supported release.
|
|
16
16
|
|
|
17
17
|
There is no default tier and no automatic upgrade. This package is application
|
|
18
18
|
abuse control, not DDoS protection, HTTP middleware, or billing metering.
|
|
@@ -43,6 +43,25 @@ const decision = await limiter.allow(clientAddress);
|
|
|
43
43
|
With N host instances this can admit approximately N times `limit`. Choosing
|
|
44
44
|
it means accepting that property.
|
|
45
45
|
|
|
46
|
+
This tier holds its counters in memory, so its footprint is bounded on both
|
|
47
|
+
axes. It tracks at most 10,000 keys at once and accepts keys of at most 512
|
|
48
|
+
UTF-16 code units — the unit the retained string actually costs, so an astral
|
|
49
|
+
symbol such as an emoji counts two. A longer key is rejected as invalid, the
|
|
50
|
+
same way other malformed keys are. When the key cap is reached and no tracked
|
|
51
|
+
key has expired yet, a check for a key that is not already tracked fails closed
|
|
52
|
+
with `retryAfter` instead of growing the map. Keys already tracked keep their
|
|
53
|
+
own counts and are never evicted, so a spray of unique keys can neither exhaust
|
|
54
|
+
host memory nor reset another subject's window.
|
|
55
|
+
|
|
56
|
+
Expired keys are reclaimed as soon as they expire: the reclaim scan is aimed at
|
|
57
|
+
the moment the earliest tracked entry becomes reclaimable, so capacity returns
|
|
58
|
+
within 50 ms of a spray ageing out however long the window is. That 50 ms is a
|
|
59
|
+
floor on how often the scan may run, which is what keeps its cost amortized
|
|
60
|
+
constant while the cap is held.
|
|
61
|
+
|
|
62
|
+
Both bounds are specific to this tier: the durable tier hashes keys to
|
|
63
|
+
fixed-length identifiers and stores them, so it accepts keys of any length.
|
|
64
|
+
|
|
46
65
|
## Durable tier
|
|
47
66
|
|
|
48
67
|
```ts
|
package/dist/memory.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ import { type LimiterOptions, type RateLimiter, type RateLimitPolicy } from "./p
|
|
|
5
5
|
* This tier is per-instance. With N application instances it can admit about
|
|
6
6
|
* N times the configured limit; use it only where that approximation is an
|
|
7
7
|
* explicit host decision.
|
|
8
|
+
*
|
|
9
|
+
* It tracks at most {@link MAX_TRACKED_KEYS} keys. Once that cap is reached
|
|
10
|
+
* and expired keys cannot be reclaimed, checks for keys that are not already
|
|
11
|
+
* tracked deny with a retry-after rather than growing without bound, so a
|
|
12
|
+
* unique-key spray cannot exhaust the host's memory. Already-tracked keys are
|
|
13
|
+
* unaffected. Reclaim is attempted as soon as the earliest tracked entry
|
|
14
|
+
* expires, subject only to the {@link MIN_PRUNE_INTERVAL_MS} floor, so capacity
|
|
15
|
+
* returns within that floor of a spray ageing out.
|
|
8
16
|
*/
|
|
9
17
|
export declare function createMemoryLimiter(inputPolicy: RateLimitPolicy, options?: LimiterOptions): RateLimiter;
|
|
10
18
|
export type { LimiterOptions, RateLimitDecision, RateLimiter, RateLimitPolicy, } from "./policy.js";
|
package/dist/memory.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAGA,OAAO,EAML,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAGA,OAAO,EAML,KAAK,cAAc,EAEnB,KAAK,WAAW,EAChB,KAAK,eAAe,EACrB,MAAM,aAAa,CAAC;AAsCrB;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,eAAe,EAC5B,OAAO,GAAE,cAAmB,GAC3B,WAAW,CAkFb;AAED,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,WAAW,EACX,eAAe,GAChB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/memory.js
CHANGED
|
@@ -1,53 +1,117 @@
|
|
|
1
1
|
import { systemClock } from "@pegma/spine";
|
|
2
2
|
import { assertRateLimitKey, defineRateLimitPolicy, denyForWindow, retryAfter, timeInMilliseconds, } from "./policy.js";
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Hard cap on simultaneously tracked keys. Per-key state is already bounded by
|
|
5
|
+
* `policy.limit` timestamps, so this bounds the tier's total footprint.
|
|
6
|
+
*/
|
|
7
|
+
const MAX_TRACKED_KEYS = 10_000;
|
|
8
|
+
/**
|
|
9
|
+
* Shortest gap between expiry scans while the cap is reached. A scan is
|
|
10
|
+
* O(tracked keys), so this floor keeps the work per check amortized constant
|
|
11
|
+
* however a spray is spread across the window. Otherwise a scan is aimed at the
|
|
12
|
+
* moment reclaim actually becomes possible, so this floor — not the window — is
|
|
13
|
+
* the longest a reclaimable key can stay unreclaimed.
|
|
14
|
+
*/
|
|
15
|
+
const MIN_PRUNE_INTERVAL_MS = 50;
|
|
16
|
+
/**
|
|
17
|
+
* Maximum key length this tier will track, measured in UTF-16 code units
|
|
18
|
+
* because that is what the retained string costs: an astral symbol such as an
|
|
19
|
+
* emoji counts two. Only the memory tier retains the raw key — the durable tier
|
|
20
|
+
* hashes it to a fixed-length identifier — so only this tier needs the bound.
|
|
21
|
+
* Keys are opaque subject identifiers such as an address, a principal, or a
|
|
22
|
+
* short composite of those, so the bound sits far above legitimate use.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_KEY_LENGTH = 512;
|
|
25
|
+
function assertMemoryLimiterKey(key) {
|
|
26
|
+
// Checked before the shared validator's well-formedness scan, which walks
|
|
27
|
+
// the whole string, so an oversized key is rejected without being traversed.
|
|
28
|
+
if (typeof key === "string" && key.length > MAX_KEY_LENGTH) {
|
|
29
|
+
throw new TypeError(`A memory-tier rate-limit key must be at most ${MAX_KEY_LENGTH} UTF-16 code units.`);
|
|
30
|
+
}
|
|
31
|
+
assertRateLimitKey(key);
|
|
32
|
+
}
|
|
4
33
|
/**
|
|
5
34
|
* Creates a zero-I/O sliding-window limiter.
|
|
6
35
|
*
|
|
7
36
|
* This tier is per-instance. With N application instances it can admit about
|
|
8
37
|
* N times the configured limit; use it only where that approximation is an
|
|
9
38
|
* explicit host decision.
|
|
39
|
+
*
|
|
40
|
+
* It tracks at most {@link MAX_TRACKED_KEYS} keys. Once that cap is reached
|
|
41
|
+
* and expired keys cannot be reclaimed, checks for keys that are not already
|
|
42
|
+
* tracked deny with a retry-after rather than growing without bound, so a
|
|
43
|
+
* unique-key spray cannot exhaust the host's memory. Already-tracked keys are
|
|
44
|
+
* unaffected. Reclaim is attempted as soon as the earliest tracked entry
|
|
45
|
+
* expires, subject only to the {@link MIN_PRUNE_INTERVAL_MS} floor, so capacity
|
|
46
|
+
* returns within that floor of a spray ageing out.
|
|
10
47
|
*/
|
|
11
48
|
export function createMemoryLimiter(inputPolicy, options = {}) {
|
|
12
49
|
const policy = defineRateLimitPolicy(inputPolicy);
|
|
13
50
|
const clock = options.clock ?? systemClock;
|
|
14
51
|
const hits = new Map();
|
|
52
|
+
const pruneFloorMs = Math.max(1, Math.min(MIN_PRUNE_INTERVAL_MS, policy.windowMs));
|
|
15
53
|
let nextPruneAt = Number.NEGATIVE_INFINITY;
|
|
16
54
|
function liveEntries(entries, cutoff) {
|
|
17
55
|
return entries.filter((at) => at > cutoff);
|
|
18
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Drops entries at or before `cutoff` and deletes keys left with none.
|
|
59
|
+
* Returns the earliest timestamp still retained, or positive infinity when
|
|
60
|
+
* nothing is, so the caller can aim the next scan at the moment the next key
|
|
61
|
+
* becomes reclaimable.
|
|
62
|
+
*/
|
|
19
63
|
function prune(cutoff) {
|
|
64
|
+
let earliestRetained = Number.POSITIVE_INFINITY;
|
|
20
65
|
for (const [key, entries] of hits) {
|
|
21
66
|
const kept = liveEntries(entries, cutoff);
|
|
22
67
|
if (kept.length === 0) {
|
|
23
68
|
hits.delete(key);
|
|
69
|
+
continue;
|
|
24
70
|
}
|
|
25
|
-
|
|
26
|
-
|
|
71
|
+
hits.set(key, kept);
|
|
72
|
+
for (const at of kept) {
|
|
73
|
+
if (at < earliestRetained) {
|
|
74
|
+
earliestRetained = at;
|
|
75
|
+
}
|
|
27
76
|
}
|
|
28
77
|
}
|
|
78
|
+
return earliestRetained;
|
|
29
79
|
}
|
|
30
80
|
return {
|
|
31
81
|
async allow(key, at) {
|
|
32
|
-
|
|
82
|
+
assertMemoryLimiterKey(key);
|
|
33
83
|
const now = timeInMilliseconds(clock, at);
|
|
34
84
|
if (now === null) {
|
|
35
85
|
return denyForWindow(policy.windowMs);
|
|
36
86
|
}
|
|
37
87
|
const cutoff = now - policy.windowMs;
|
|
38
|
-
const
|
|
88
|
+
const tracked = hits.get(key);
|
|
89
|
+
const entries = liveEntries(tracked ?? [], cutoff);
|
|
39
90
|
if (entries.length >= policy.limit) {
|
|
40
91
|
return {
|
|
41
92
|
allowed: false,
|
|
42
93
|
retryAfter: retryAfter(entries[0] + policy.windowMs, now),
|
|
43
94
|
};
|
|
44
95
|
}
|
|
96
|
+
// Only an untracked key can grow the map, so the cap is enforced here
|
|
97
|
+
// and never on the over-limit refusal path above, which stays read-only.
|
|
98
|
+
if (tracked === undefined && hits.size >= MAX_TRACKED_KEYS) {
|
|
99
|
+
if (now >= nextPruneAt) {
|
|
100
|
+
const earliestRetained = prune(cutoff);
|
|
101
|
+
// The earliest retained entry becomes reclaimable exactly one window
|
|
102
|
+
// after it was recorded, so aim the next scan there rather than at a
|
|
103
|
+
// fixed backoff, which could land past expiry and refuse capacity
|
|
104
|
+
// that was already reclaimable. The floor bounds scan frequency.
|
|
105
|
+
nextPruneAt = Math.max(now + pruneFloorMs, Number.isFinite(earliestRetained)
|
|
106
|
+
? earliestRetained + policy.windowMs
|
|
107
|
+
: now);
|
|
108
|
+
}
|
|
109
|
+
if (hits.size >= MAX_TRACKED_KEYS) {
|
|
110
|
+
return denyForWindow(policy.windowMs);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
45
113
|
entries.push(now);
|
|
46
114
|
hits.set(key, entries);
|
|
47
|
-
if (hits.size > PRUNE_THRESHOLD && now >= nextPruneAt) {
|
|
48
|
-
prune(cutoff);
|
|
49
|
-
nextPruneAt = now + policy.windowMs;
|
|
50
|
-
}
|
|
51
115
|
return { allowed: true };
|
|
52
116
|
},
|
|
53
117
|
};
|
package/dist/memory.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,OAAO,EACL,kBAAkB,EAClB,qBAAqB,EACrB,aAAa,EACb,UAAU,EACV,kBAAkB,GAKnB,MAAM,aAAa,CAAC;AAErB,MAAM,
|
|
1
|
+
{"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"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"}
|