@pegma/rate-limit 0.0.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 CHANGED
@@ -9,9 +9,10 @@ Two explicitly named application rate-limit tiers:
9
9
  abuse-critical operations.
10
10
 
11
11
  > [!IMPORTANT]
12
- > This source package is at the one-time `0.0.0` package-name bootstrap stage.
13
- > It is not an advertised release. A separate `0.1.0` release follows
14
- > bootstrap and consumer validation.
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.
15
16
 
16
17
  There is no default tier and no automatic upgrade. This package is application
17
18
  abuse control, not DDoS protection, HTTP middleware, or billing metering.
@@ -42,6 +43,25 @@ const decision = await limiter.allow(clientAddress);
42
43
  With N host instances this can admit approximately N times `limit`. Choosing
43
44
  it means accepting that property.
44
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
+
45
65
  ## Durable tier
46
66
 
47
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";
@@ -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;AAIrB;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,eAAe,EAC5B,OAAO,GAAE,cAAmB,GAC3B,WAAW,CA+Cb;AAED,YAAY,EACV,cAAc,EACd,iBAAiB,EACjB,WAAW,EACX,eAAe,GAChB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,qBAAqB,EAAE,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
- const PRUNE_THRESHOLD = 10_000;
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
- else {
26
- hits.set(key, kept);
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
- assertRateLimitKey(key);
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 entries = liveEntries(hits.get(key) ?? [], cutoff);
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
  };
@@ -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,eAAe,GAAG,MAAM,CAAC;AAE/B;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CACjC,WAA4B,EAC5B,OAAO,GAAmB,EAAE;IAE5B,MAAM,MAAM,GAAG,qBAAqB,CAAC,WAAW,CAAC,CAAC;IAClD,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,WAAW,CAAC;IAC3C,MAAM,IAAI,GAAG,IAAI,GAAG,EAAoB,CAAC;IACzC,IAAI,WAAW,GAAG,MAAM,CAAC,iBAAiB,CAAC;IAE3C,SAAS,WAAW,CAAC,OAA0B,EAAE,MAAc;QAC7D,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,GAAG,MAAM,CAAC,CAAC;IAC7C,CAAC;IAED,SAAS,KAAK,CAAC,MAAc;QAC3B,KAAK,MAAM,CAAC,GAAG,EAAE,OAAO,CAAC,IAAI,IAAI,EAAE,CAAC;YAClC,MAAM,IAAI,GAAG,WAAW,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC1C,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBACtB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACnB,CAAC;iBAAM,CAAC;gBACN,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;YACtB,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,GAAW,EAAE,EAAiB;YACxC,kBAAkB,CAAC,GAAG,CAAC,CAAC;YACxB,MAAM,GAAG,GAAG,kBAAkB,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YAC1C,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;gBACjB,OAAO,aAAa,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;YACxC,CAAC;YAED,MAAM,MAAM,GAAG,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC;YACrC,MAAM,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,MAAM,CAAC,CAAC;YACzD,IAAI,OAAO,CAAC,MAAM,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;gBACnC,OAAO;oBACL,OAAO,EAAE,KAAK;oBACd,UAAU,EAAE,UAAU,CAAE,OAAO,CAAC,CAAC,CAAY,GAAG,MAAM,CAAC,QAAQ,EAAE,GAAG,CAAC;iBACtE,CAAC;YACJ,CAAC;YAED,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAClB,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YACvB,IAAI,IAAI,CAAC,IAAI,GAAG,eAAe,IAAI,GAAG,IAAI,WAAW,EAAE,CAAC;gBACtD,KAAK,CAAC,MAAM,CAAC,CAAC;gBACd,WAAW,GAAG,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC;YACtC,CAAC;YACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;QAC3B,CAAC;KACF,CAAC;AACJ,CAAC;AAQD,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pegma/rate-limit",
3
- "version": "0.0.0",
3
+ "version": "0.1.1",
4
4
  "description": "Explicit per-instance sliding-window and shared durable fixed-window rate limiters.",
5
5
  "license": "MIT",
6
6
  "type": "module",