@profullstack/throttle 0.3.4 → 0.4.0

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
@@ -70,6 +70,20 @@ export async function proxy(request) {
70
70
  Without a `gateway` the same throttle answers `429` with `Retry-After`. That is
71
71
  the fallback, not the goal.
72
72
 
73
+ ## Headroom: only offenders are refused
74
+
75
+ Since 0.4.0 every number a site configures is multiplied by `headroom` (default
76
+ 5): the default allowance, every rule, the credential budget and ceiling, and
77
+ the subresource bucket. Write the limits as "what a normal reader uses"; the
78
+ throttle refuses only a caller five times past that. People were being refused
79
+ for ordinary heavy use when the limits had no room above typical consumption.
80
+
81
+ Brute-force guards keep the number as written. A rule is a guard when it is
82
+ marked `guard: true`, marked `credential: false` (the sign-in pattern), or its
83
+ path looks like auth (`/auth/`, `/login`, `/signup`, `*-password`, `/reset`,
84
+ `/otp`, `/verify-*`, `/oauth/`, `2fa`/`mfa`). So `{ path: '/api/auth/', limit: 10,
85
+ credential: false }` stays 10 a minute while `/feed/` at 30 becomes 150.
86
+
73
87
  ## What a refused caller sees
74
88
 
75
89
  ```
@@ -105,7 +119,8 @@ selling the same minute twice.
105
119
 
106
120
  | Option | Default | |
107
121
  |---|---|---|
108
- | `limit` | `100` | requests per window, per caller |
122
+ | `limit` | `100` | requests per window, per caller, before `headroom` |
123
+ | `headroom` | `5` | multiplier on every limit except brute-force guards; `1` uses the numbers as written |
109
124
  | `windowSeconds` | `60` | |
110
125
  | `gateway` | — | an `@profullstack/x402-gateway`; makes over-limit a 402 |
111
126
  | `rules` | `[]` | per-path allowances; most specific match wins |
package/index.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { Gateway, QuotaStore } from '@profullstack/x402-gateway';
3
3
  /** The house default: a hundred requests a minute, per caller, everywhere. */
4
4
  export const DEFAULT_LIMIT: 100;
5
5
  export const DEFAULT_WINDOW_SECONDS: 60;
6
+ /** Every limit is multiplied by this unless the rule is a brute-force guard. */
7
+ export const DEFAULT_HEADROOM: 5;
6
8
  export const ALWAYS_OPEN: string[];
7
9
 
8
10
  export interface ThrottleRule {
@@ -18,6 +20,11 @@ export interface ThrottleRule {
18
20
  * request is authenticated — what the sign-in routes need.
19
21
  */
20
22
  credential?: false | { limit?: number; ceiling?: number };
23
+ /**
24
+ * A brute-force guard: its numbers are used as written, never multiplied by
25
+ * headroom. Implied by `credential: false` and by auth-looking paths.
26
+ */
27
+ guard?: boolean;
21
28
  }
22
29
 
23
30
  export interface CredentialBudget {
@@ -38,10 +45,15 @@ export interface ThrottleEvent {
38
45
  }
39
46
 
40
47
  export interface ThrottleOptions {
41
- /** Default 100. */
48
+ /** Default 100, before headroom. */
42
49
  limit?: number;
43
50
  /** Default 60. */
44
51
  windowSeconds?: number;
52
+ /**
53
+ * Multiplier on every limit (default, rules, credential budget, subresources)
54
+ * except brute-force guards. Default 5; pass 1 to use the numbers as written.
55
+ */
56
+ headroom?: number;
45
57
  /** Over-limit is answered 402 with this gateway's offer instead of 429. */
46
58
  gateway?: Gateway | null;
47
59
  /** Shared counter. Default: one per process, so each instance grants its own. */
@@ -79,6 +91,7 @@ export interface Throttle {
79
91
  options: {
80
92
  limit: number;
81
93
  windowSeconds: number;
94
+ headroom: number;
82
95
  rules: Required<ThrottleRule>[];
83
96
  credential: { limit: number; ceiling: number } | null;
84
97
  subresources: { limit: number } | null;
@@ -106,3 +119,7 @@ export function compileRules(
106
119
  defaults: { limit: number; windowSeconds: number },
107
120
  ): Required<ThrottleRule>[];
108
121
  export function ruleFor<T>(compiled: Required<ThrottleRule>[], path: string, defaults: T): T;
122
+ /** Whether a compiled rule is a brute-force guard (never scaled by headroom). */
123
+ export function isGuard(rule: { path: string; credential?: unknown; guard?: boolean }): boolean;
124
+ /** A compiled rule with its limits multiplied by `headroom`, unless it is a guard. */
125
+ export function withHeadroom<T extends { path: string; limit: number }>(rule: T, headroom: number): T;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@profullstack/throttle",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
- "description": "An app-wide rate limit whose answer to going over is a price, not a 429. 100 requests a minute free, then x402 Payment Required with an offer, settled by CoinPay. Meters every route, so a scraper wearing a browser user agent is charged like any other crawler.",
5
+ "description": "An app-wide rate limit whose answer to going over is a price, not a 429. 100 requests a minute free (times 5 headroom), then x402 Payment Required with an offer, settled by CoinPay. Meters every route, so a scraper wearing a browser user agent is charged like any other crawler.",
6
6
  "keywords": [
7
7
  "rate-limit",
8
8
  "throttle",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "sideEffects": false,
56
56
  "dependencies": {
57
- "@profullstack/x402-gateway": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0"
57
+ "@profullstack/x402-gateway": "^0.6.0 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0"
58
58
  },
59
59
  "scripts": {
60
60
  "test": "node --test"
package/src/index.js CHANGED
@@ -3,10 +3,13 @@ export { memoryStore } from './store.js';
3
3
  export { addressOf, fingerprint, presentedCredential } from './identify.js';
4
4
  export {
5
5
  ALWAYS_OPEN,
6
+ DEFAULT_HEADROOM,
6
7
  DEFAULT_LIMIT,
7
8
  DEFAULT_WINDOW_SECONDS,
8
9
  compileRules,
9
10
  matches,
10
11
  matchesAny,
12
+ isGuard,
11
13
  ruleFor,
14
+ withHeadroom,
12
15
  } from './rules.js';
package/src/rules.js CHANGED
@@ -15,6 +15,23 @@
15
15
  export const DEFAULT_LIMIT = 100;
16
16
  export const DEFAULT_WINDOW_SECONDS = 60;
17
17
 
18
+ /**
19
+ * How far above the configured numbers a caller may go before it is refused.
20
+ * Every limit a site writes down is multiplied by this, so the numbers in a
21
+ * config read as "what a normal reader uses" and the throttle only catches
22
+ * callers far past that. On 2026-10-28 people complained that ordinary heavy
23
+ * use was being refused; the limits had been sized to typical consumption
24
+ * with no room above it. Brute-force guards (see `isGuard`) are never scaled.
25
+ */
26
+ export const DEFAULT_HEADROOM = 5;
27
+
28
+ /**
29
+ * Paths whose limit is a brute-force budget, not a reading allowance. Ten
30
+ * guesses a minute at a password is the point of the rule; five times that is
31
+ * a giveaway, so these keep the number the site wrote.
32
+ */
33
+ const GUARD_PATH = /(^|[/_-])(auth|oauth|log-?in|sign-?in|sign-?up|register|password|passwd|reset|otp|verify|magic|2fa|mfa|totp)([/_.-]|$)/i;
34
+
18
35
  /**
19
36
  * Paths that are never metered, whatever the rules say.
20
37
  *
@@ -61,9 +78,33 @@ function normaliseRule(rule, defaults) {
61
78
  open: Boolean(rule?.open),
62
79
  /** Credentialed callers on this rule, if it wants different numbers. */
63
80
  credential: rule?.credential ?? null,
81
+ /** A brute-force guard: its numbers are never multiplied by headroom. */
82
+ guard: Boolean(rule?.guard),
64
83
  };
65
84
  }
66
85
 
86
+ /**
87
+ * Whether a compiled rule is a brute-force guard: marked `guard: true`, marked
88
+ * `credential: false` (the documented sign-in pattern), or on an auth-looking
89
+ * path.
90
+ */
91
+ export function isGuard(rule) {
92
+ return Boolean(rule.guard) || rule.credential === false || GUARD_PATH.test(rule.path);
93
+ }
94
+
95
+ /** A rule with its limits multiplied by `headroom`, unless it is a guard. */
96
+ export function withHeadroom(rule, headroom) {
97
+ if (headroom === 1 || isGuard(rule)) return rule;
98
+ const credential = rule.credential && typeof rule.credential === 'object'
99
+ ? {
100
+ ...rule.credential,
101
+ ...(rule.credential.limit ? { limit: Math.floor(rule.credential.limit * headroom) } : {}),
102
+ ...(rule.credential.ceiling ? { ceiling: Math.floor(rule.credential.ceiling * headroom) } : {}),
103
+ }
104
+ : rule.credential;
105
+ return { ...rule, limit: Math.floor(rule.limit * headroom), credential };
106
+ }
107
+
67
108
  /**
68
109
  * Compile the rule list. Longest pattern first, so `/api/auth/` is consulted
69
110
  * before `/api/` however the site happened to order them -- the alternative is
package/src/throttle.js CHANGED
@@ -17,25 +17,30 @@
17
17
  * the caller has just demonstrated it wants more than the free tier and is
18
18
  * still holding the request open.
19
19
  *
20
- * A hundred requests a minute is the default. A person reading a site produces
21
- * a few dozen; a scraper produces that in a second.
20
+ * A hundred requests a minute is the default, and every number is then
21
+ * multiplied by `headroom` (5): a heavy reader is never refused, only a
22
+ * caller five times past what the site considers normal. A person reading a
23
+ * site produces a few dozen; a scraper produces that in a second.
22
24
  */
23
25
 
24
26
  import { quotaHeaders, spend } from '@profullstack/x402-gateway';
25
27
  import { addressOf, fingerprint, presentedCredential } from './identify.js';
26
28
  import {
27
29
  ALWAYS_OPEN,
30
+ DEFAULT_HEADROOM,
28
31
  DEFAULT_LIMIT,
29
32
  DEFAULT_WINDOW_SECONDS,
30
33
  compileRules,
31
34
  matchesAny,
32
35
  ruleFor,
36
+ withHeadroom,
33
37
  } from './rules.js';
34
38
  import { memoryStore } from './store.js';
35
39
 
36
40
  /**
37
41
  * @param {object} options
38
- * @param {number} [options.limit=100] requests per window, per caller
42
+ * @param {number} [options.limit=100] requests per window, per caller, before headroom
43
+ * @param {number} [options.headroom=5] multiplier on every limit except brute-force guards; 1 to use the numbers as written
39
44
  * @param {number} [options.windowSeconds=60]
40
45
  * @param {object} [options.gateway] a @profullstack/x402-gateway; over-limit is answered 402 with its offer
41
46
  * @param {object} [options.store] shared counter; default is per-process
@@ -49,16 +54,24 @@ import { memoryStore } from './store.js';
49
54
  * @param {(event: object) => void|Promise<void>} [options.onThrottle] told about every refusal; its errors are swallowed
50
55
  */
51
56
  export function createThrottle(options = {}) {
52
- const defaults = {
57
+ const headroom = positiveReal(options.headroom, DEFAULT_HEADROOM);
58
+ const scale = (n) => Math.floor(n * headroom);
59
+
60
+ /*
61
+ * Rules inherit the unscaled default and are scaled once afterwards, so a
62
+ * rule that leaves out its limit is not multiplied twice.
63
+ */
64
+ const base = {
53
65
  path: '',
54
66
  limit: positive(options.limit, DEFAULT_LIMIT),
55
67
  windowSeconds: positive(options.windowSeconds, DEFAULT_WINDOW_SECONDS),
56
68
  open: false,
57
69
  credential: null,
58
70
  };
71
+ const defaults = { ...base, limit: scale(base.limit) };
59
72
 
60
73
  const store = options.store?.hit ? options.store : memoryStore();
61
- const rules = compileRules(options.rules, defaults);
74
+ const rules = compileRules(options.rules, base).map((rule) => withHeadroom(rule, headroom));
62
75
  const gateway = options.gateway ?? null;
63
76
  const identify = options.identify ?? addressOf;
64
77
  /*
@@ -79,8 +92,8 @@ export function createThrottle(options = {}) {
79
92
  * this layer has not authenticated any of them.
80
93
  */
81
94
  const credential = options.credential === false ? null : {
82
- limit: positive(options.credential?.limit, 600),
83
- ceiling: positive(options.credential?.ceiling, 1200),
95
+ limit: scale(positive(options.credential?.limit, 600)),
96
+ ceiling: scale(positive(options.credential?.ceiling, 1200)),
84
97
  };
85
98
 
86
99
  /*
@@ -104,7 +117,7 @@ export function createThrottle(options = {}) {
104
117
  * buys itself a bigger bucket, not an unmetered site.
105
118
  */
106
119
  const subresources = options.subresources === false ? null : {
107
- limit: positive(options.subresources?.limit, 1000),
120
+ limit: scale(positive(options.subresources?.limit, 1000)),
108
121
  };
109
122
 
110
123
  const quotaFor = (rule) => ({
@@ -246,7 +259,7 @@ export function createThrottle(options = {}) {
246
259
  handle,
247
260
  /** For tests and for a site that wants to reclaim memory on demand. */
248
261
  store,
249
- options: { ...defaults, rules, credential, subresources, openPaths, gateway: Boolean(gateway) },
262
+ options: { ...defaults, headroom, rules, credential, subresources, openPaths, gateway: Boolean(gateway) },
250
263
  };
251
264
  }
252
265
 
@@ -261,6 +274,11 @@ export function isSubresource(request) {
261
274
  return SUBRESOURCE.has(request.headers.get('sec-fetch-dest') ?? '');
262
275
  }
263
276
 
277
+ function positiveReal(value, fallback) {
278
+ const n = Number(value);
279
+ return value !== undefined && value !== null && Number.isFinite(n) && n >= 1 ? n : fallback;
280
+ }
281
+
264
282
  function positive(value, fallback) {
265
283
  const n = Number(value);
266
284
  return Number.isFinite(n) && n >= 1 ? Math.floor(n) : fallback;