@vsaas/loopback 12.1.0 → 12.2.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 +6 -0
- package/common/models/rate-limit-state.json +121 -0
- package/common/models/two-factor-challenge.json +112 -0
- package/common/models/two-factor-credential.json +117 -0
- package/common/models/two-factor-recovery-code.json +63 -0
- package/common/models/user.json +30 -0
- package/dist/_virtual/_rolldown/runtime.cjs +9 -2
- package/dist/common/models/access-token.d.cts +3 -0
- package/dist/common/models/acl.cjs +1 -3
- package/dist/common/models/acl.d.cts +1 -0
- package/dist/common/models/application.d.cts +3 -0
- package/dist/common/models/change.cjs +21 -18
- package/dist/common/models/change.d.cts +1 -0
- package/dist/common/models/checkpoint.d.cts +3 -0
- package/dist/common/models/email.d.cts +3 -0
- package/dist/common/models/key-value-model.d.cts +3 -0
- package/dist/common/models/rate-limit-state.cjs +7 -0
- package/dist/common/models/rate-limit-state.d.cts +3 -0
- package/dist/common/models/rate-limit-state2.cjs +109 -0
- package/dist/common/models/role-mapping.d.cts +3 -0
- package/dist/common/models/role.cjs +12 -8
- package/dist/common/models/role.d.cts +1 -0
- package/dist/common/models/scope.d.cts +3 -0
- package/dist/common/models/two-factor-challenge.cjs +7 -0
- package/dist/common/models/two-factor-challenge.d.cts +3 -0
- package/dist/common/models/two-factor-challenge2.cjs +108 -0
- package/dist/common/models/two-factor-credential.cjs +7 -0
- package/dist/common/models/two-factor-credential.d.cts +3 -0
- package/dist/common/models/two-factor-credential2.cjs +103 -0
- package/dist/common/models/two-factor-recovery-code.cjs +7 -0
- package/dist/common/models/two-factor-recovery-code.d.cts +3 -0
- package/dist/common/models/two-factor-recovery-code2.cjs +57 -0
- package/dist/common/models/user.cjs +258 -21
- package/dist/common/models/user.d.cts +3 -0
- package/dist/common/models/user2.cjs +30 -0
- package/dist/index.cjs +2 -0
- package/dist/index.d.cts +3 -0
- package/dist/lib/access-context.d.cts +33 -0
- package/dist/lib/application.d.cts +1 -0
- package/dist/lib/authentication-security-initializer.cjs +351 -0
- package/dist/lib/authentication-security-initializer.d.cts +4 -0
- package/dist/lib/builtin-models.cjs +12 -0
- package/dist/lib/builtin-models.d.cts +3 -0
- package/dist/lib/configure-shared-methods.cjs +7 -4
- package/dist/lib/configure-shared-methods.d.cts +3 -0
- package/dist/lib/connectors/base-connector.d.cts +6 -0
- package/dist/lib/connectors/mail-direct-transport.cjs +26 -23
- package/dist/lib/connectors/mail-direct-transport.d.cts +22 -0
- package/dist/lib/connectors/mail-stub-transport.d.cts +14 -0
- package/dist/lib/connectors/mail.d.cts +7 -0
- package/dist/lib/connectors/memory.d.cts +6 -0
- package/dist/lib/current-context.d.cts +3 -0
- package/dist/lib/globalize.d.cts +10 -0
- package/dist/lib/loopback.cjs +6 -0
- package/dist/lib/loopback.d.cts +1 -0
- package/dist/lib/model.d.cts +1 -0
- package/dist/lib/persisted-model.cjs +7 -12
- package/dist/lib/persisted-model.d.cts +1 -0
- package/dist/lib/rate-limit/algorithms/common.cjs +37 -0
- package/dist/lib/rate-limit/algorithms/failure-lockout.cjs +70 -0
- package/dist/lib/rate-limit/algorithms/fixed-window.cjs +61 -0
- package/dist/lib/rate-limit/algorithms/index.cjs +55 -0
- package/dist/lib/rate-limit/config.cjs +107 -0
- package/dist/lib/rate-limit/errors.cjs +58 -0
- package/dist/lib/rate-limit/index.cjs +15 -0
- package/dist/lib/rate-limit/index.d.cts +1 -0
- package/dist/lib/rate-limit/initializer.cjs +92 -0
- package/dist/lib/rate-limit/initializer.d.cts +4 -0
- package/dist/lib/rate-limit/limiter.cjs +368 -0
- package/dist/lib/rate-limit/registry.cjs +92 -0
- package/dist/lib/rate-limit/remote.cjs +180 -0
- package/dist/lib/rate-limit/remote.d.cts +6 -0
- package/dist/lib/rate-limit/stores/persisted-model.cjs +353 -0
- package/dist/lib/rate-limit/subject.cjs +127 -0
- package/dist/lib/rate-limit/types.cjs +1 -0
- package/dist/lib/rate-limit/types.d.cts +167 -0
- package/dist/lib/registry.d.cts +1 -0
- package/dist/lib/runtime.d.cts +10 -0
- package/dist/lib/server-app.d.cts +3 -0
- package/dist/lib/two-factor/config.cjs +199 -0
- package/dist/lib/two-factor/config.d.cts +55 -0
- package/dist/lib/two-factor/crypto.cjs +68 -0
- package/dist/lib/two-factor/crypto.d.cts +16 -0
- package/dist/lib/two-factor/errors.cjs +58 -0
- package/dist/lib/two-factor/errors.d.cts +18 -0
- package/dist/lib/two-factor/rate-limit.cjs +153 -0
- package/dist/lib/two-factor/rate-limit.d.cts +22 -0
- package/dist/lib/two-factor/recovery-codes.cjs +49 -0
- package/dist/lib/two-factor/recovery-codes.d.cts +16 -0
- package/dist/lib/two-factor/service.cjs +1108 -0
- package/dist/lib/two-factor/service.d.cts +78 -0
- package/dist/lib/two-factor/totp.cjs +50 -0
- package/dist/lib/two-factor/totp.d.cts +14 -0
- package/dist/lib/utils.d.cts +36 -0
- package/dist/package.cjs +46 -10
- package/dist/server/middleware/context.d.cts +3 -0
- package/dist/server/middleware/error-handler.d.cts +3 -0
- package/dist/server/middleware/favicon.d.cts +7 -0
- package/dist/server/middleware/rest.d.cts +6 -0
- package/dist/server/middleware/static.d.cts +3 -0
- package/dist/server/middleware/status.d.cts +19 -0
- package/dist/server/middleware/token.d.cts +3 -0
- package/dist/server/middleware/url-not-found.d.cts +13 -0
- package/docs/api-explorer-details.md +110 -0
- package/docs/application-guide.md +1110 -0
- package/docs/assets/explorer-api.png +0 -0
- package/docs/assets/explorer-endpoint.png +0 -0
- package/docs/assets/explorer-listing.png +0 -0
- package/docs/assets/explorer-req-res.png +0 -0
- package/docs/assets/lb-modules.png +0 -0
- package/docs/assets/loopback-architecture.png +0 -0
- package/docs/assets/loopback-concepts.png +0 -0
- package/docs/assets/loopback_ov.png +0 -0
- package/docs/rate-limiting.md +879 -0
- package/docs/two-factor-authentication.md +611 -0
- package/docs/two-factor-authenticator-loss-runbook.md +117 -0
- package/package.json +55 -19
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
# Rate limiting
|
|
2
|
+
|
|
3
|
+
LoopBack provides a framework-level rate limiter for remote methods, authentication flows, and application-specific operations. Policy, subject selection, and storage are separate, so applications can reuse the same enforcement engine without coupling it to HTTP, a `User` model, or a particular database.
|
|
4
|
+
|
|
5
|
+
This guide covers general rate limiting. Authentication-specific buckets are described in [Two-factor authentication](./two-factor-authentication.md#authentication-rate-limiting).
|
|
6
|
+
|
|
7
|
+
## Initialization
|
|
8
|
+
|
|
9
|
+
Configure every provider, store, subject keyring, programmatic rule, named policy, and resolver before initialization:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
app.set('rateLimit', {
|
|
13
|
+
limits: {
|
|
14
|
+
maxBatchEntries: 1000,
|
|
15
|
+
maxCostPerConsume: 100000,
|
|
16
|
+
},
|
|
17
|
+
subjectKeyrings: {
|
|
18
|
+
default: {
|
|
19
|
+
currentKeyId: '2026-01',
|
|
20
|
+
keys: {
|
|
21
|
+
'2026-01': process.env.RATE_LIMIT_SUBJECT_KEY,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
stores: {
|
|
26
|
+
primary: {
|
|
27
|
+
provider: 'persisted-model',
|
|
28
|
+
model: 'RateLimitState',
|
|
29
|
+
namespace: 'example-api-production',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Define programmatic rules and register custom providers or resolvers here.
|
|
35
|
+
await app.initializeRateLimiting();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Call `initializeRateLimiting()` after models and datasources have been attached and before the application starts accepting traffic. Initialization validates the complete configuration, probes the stores, freezes the registry, and installs the remote-method hooks.
|
|
39
|
+
|
|
40
|
+
If any concrete `User` model uses `twoFactor` or `authenticationRateLimit`, call `initializeAuthenticationSecurity()` instead. That initializer also initializes general rate limiting and must own the startup sequence:
|
|
41
|
+
|
|
42
|
+
```js
|
|
43
|
+
await app.initializeAuthenticationSecurity();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Both initializers support promises and Node-style callbacks and are idempotent. A configured authentication flow remains unavailable until initialization succeeds; it does not silently fall back to unprotected behavior.
|
|
47
|
+
|
|
48
|
+
## Mental model: what does one rule do?
|
|
49
|
+
|
|
50
|
+
A rate-limit rule creates counters. For every operation, it answers four questions:
|
|
51
|
+
|
|
52
|
+
1. **What is being limited?** The `bucket` identifies the action, for example `report-generation` or `file-upload`.
|
|
53
|
+
2. **Who owns the counter?** The `subject` identifies the caller or resource whose quota is consumed, for example one user, tenant, API key, device, source address, or the application as a whole.
|
|
54
|
+
3. **What is the quota?** The `limit` defines the capacity and time window.
|
|
55
|
+
4. **How much does this operation consume?** The `cost` is normally `1`, but it can represent rows, bytes, recipients, jobs, or any other whole-number unit.
|
|
56
|
+
|
|
57
|
+
For example, the following rule means “each API key can consume 100 units from the `report-generation` bucket every minute”:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
bucket: report-generation
|
|
61
|
+
subject: API key "key-42"
|
|
62
|
+
limit: 100
|
|
63
|
+
window: 60 seconds
|
|
64
|
+
cost of the current operation: 5
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
After that operation, the counter for `key-42` has consumed 5 of its 100 units. Another API key gets a different counter. The same API key using a different bucket also gets a different counter.
|
|
68
|
+
|
|
69
|
+
The complete persisted counter identity is derived from:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
namespace
|
|
73
|
+
+ scope
|
|
74
|
+
+ subjectType
|
|
75
|
+
+ HMAC(canonicalValue)
|
|
76
|
+
+ bucket
|
|
77
|
+
+ policyId
|
|
78
|
+
+ policyVersion
|
|
79
|
+
+ subject key ID
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This is why the labels in the subject descriptor must remain stable after deployment. Changing one of them starts addressing a different counter.
|
|
83
|
+
|
|
84
|
+
## Application configuration
|
|
85
|
+
|
|
86
|
+
The `rateLimit` application setting has three main sections.
|
|
87
|
+
|
|
88
|
+
### Safety limits
|
|
89
|
+
|
|
90
|
+
`limits.maxBatchEntries` and `limits.maxCostPerConsume` protect the limiter itself from unexpectedly large inputs. They do **not** define how many operations a user is allowed to perform. User-facing quotas belong in a rule's `limit`.
|
|
91
|
+
|
|
92
|
+
| Option | Default | Description |
|
|
93
|
+
| -------------------------- | -------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
+
| `limits.maxBatchEntries` | `1000` | Maximum number of entries accepted by one `consumeBatch()` call. Must be a positive safe integer. Validation happens before subject and policy resolvers run. |
|
|
95
|
+
| `limits.maxCostPerConsume` | `100000` | Maximum cost of one resolved consumption. Must be a positive safe integer. |
|
|
96
|
+
|
|
97
|
+
#### Example: `maxBatchEntries`
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
limits: {
|
|
101
|
+
maxBatchEntries: 500,
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With this configuration:
|
|
106
|
+
|
|
107
|
+
- a call to `consumeBatch()` with 500 entries is accepted for evaluation;
|
|
108
|
+
- a call with 501 entries fails immediately with `RATE_LIMIT_CONFIGURATION_INVALID`;
|
|
109
|
+
- the failure happens before `subject`, `limit`, or `cost` resolvers run;
|
|
110
|
+
- it does not matter whether those 501 entries would eventually belong to the same subject or different subjects.
|
|
111
|
+
|
|
112
|
+
An “entry” is one element of the array passed to `consumeBatch()`. It is not the number of database rows inside the entry and it is not the entry's cost.
|
|
113
|
+
|
|
114
|
+
Choose this value from the largest batch your public or internal API legitimately accepts. If an endpoint permits at most 200 work items, a value such as 250 leaves operational margin without allowing unbounded resolver work.
|
|
115
|
+
|
|
116
|
+
#### Example: `maxCostPerConsume`
|
|
117
|
+
|
|
118
|
+
```js
|
|
119
|
+
limits: {
|
|
120
|
+
maxCostPerConsume: 10_000,
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Suppose a rule uses the number of validated rows as its cost:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
cost: (context) => context.rows.length;
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With this configuration:
|
|
131
|
+
|
|
132
|
+
- an operation containing 2,000 rows resolves to `cost: 2000` and can be evaluated normally;
|
|
133
|
+
- an operation containing 10,001 rows fails with `RATE_LIMIT_CONFIGURATION_INVALID`;
|
|
134
|
+
- the failure means “the caller asked the limiter to process an unsafe cost”, not “the subject exhausted its quota”; therefore it is not a `429 RATE_LIMITED` response.
|
|
135
|
+
|
|
136
|
+
For `consumeBatch()`, the limit is checked again after entries for the same counter are grouped. If two entries for the same subject cost 6,000 each, their atomic group costs 12,000 and is rejected. Two entries for different subjects may each cost 6,000 because each becomes a separate atomic consumption.
|
|
137
|
+
|
|
138
|
+
Set `maxCostPerConsume` to the largest legitimate atomic consumption. If larger work is valid, either increase the safety bound deliberately or split the work into separately meaningful subjects or operations. Do not raise it merely to hide input-validation problems.
|
|
139
|
+
|
|
140
|
+
### Subject keyrings
|
|
141
|
+
|
|
142
|
+
Raw subject values are never persisted. The limiter derives a stable HMAC-SHA-256 identifier using a named subject keyring:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
subjectKeyrings: {
|
|
146
|
+
default: {
|
|
147
|
+
currentKeyId: '2026-01',
|
|
148
|
+
keys: {
|
|
149
|
+
'2025-06': process.env.RATE_LIMIT_SUBJECT_KEY_OLD,
|
|
150
|
+
'2026-01': process.env.RATE_LIMIT_SUBJECT_KEY,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Option | Required | Description |
|
|
157
|
+
| -------------- | -------- | ----------------------------------------------------------------- |
|
|
158
|
+
| `currentKeyId` | Yes | ID used for new state. It must reference an entry in `keys`. |
|
|
159
|
+
| `keys.<keyId>` | Yes | A `Buffer` or base64-encoded secret containing at least 32 bytes. |
|
|
160
|
+
|
|
161
|
+
Keyring, key, store, rule, namespace, scope, subject type, bucket, policy ID, and policy-version identifiers must use only letters, digits, `.`, `_`, and `-`, with a maximum length of 128 characters. Subject values may be strings or buffers from 1 to 4096 bytes.
|
|
162
|
+
|
|
163
|
+
Use a different secret for each environment. Do not use identifiers, passwords, API keys, IP addresses, or other subject values as HMAC keys.
|
|
164
|
+
|
|
165
|
+
### Stores
|
|
166
|
+
|
|
167
|
+
The built-in durable store uses a LoopBack `PersistedModel`:
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
stores: {
|
|
171
|
+
primary: {
|
|
172
|
+
provider: 'persisted-model',
|
|
173
|
+
model: 'RateLimitState',
|
|
174
|
+
namespace: 'example-api-production',
|
|
175
|
+
maxRetries: 20,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
| Option | Default | Description |
|
|
181
|
+
| ------------- | ------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
182
|
+
| `provider` | — | Store-provider name. The built-in provider is `persisted-model`. |
|
|
183
|
+
| `model` | — | Model name used by the persisted-model provider. It should be a private `RateLimitState` model attached to a durable datasource. |
|
|
184
|
+
| `namespace` | — | Isolates state belonging to an application or deployment. It is required by static remote policies and authentication rate limiting. Use a stable, unique value per environment. |
|
|
185
|
+
| `allowMemory` | `false` | Explicitly allows the resolved model to use the built-in memory connector. Intended for tests and single-instance development; memory state is private to each process and does not survive restarts or share counters between instances. |
|
|
186
|
+
| `maxRetries` | `20` | Maximum compare-and-swap retry count for concurrent state changes. |
|
|
187
|
+
|
|
188
|
+
At startup, the persisted-model provider verifies deterministic string-ID
|
|
189
|
+
round-tripping, duplicate-ID enforcement, two concurrent conditional updates,
|
|
190
|
+
a stale update, and cleanup. Initialization fails if exactly one concurrent
|
|
191
|
+
CAS does not win or if the connector's duplicate-key error cannot be
|
|
192
|
+
recognized. This runtime probe protects every deployment; it does not replace
|
|
193
|
+
the connector contract suite.
|
|
194
|
+
|
|
195
|
+
MongoDB deployments can run the real multi-store contract before adopting a
|
|
196
|
+
connector or connector upgrade:
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
MONGODB_RATE_LIMIT_TEST_URL=mongodb://127.0.0.1:27017/loopback-rate-limit-contract \
|
|
200
|
+
pnpm --filter @vsaas/loopback test:run -- test/rate-limit-persistence.mongodb.test.js
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The contract starts two store instances, exercises concurrent creates and CAS,
|
|
204
|
+
and confirms that one deterministic string ID owns the resulting state. Add an
|
|
205
|
+
equivalent real-database contract before documenting another connector as
|
|
206
|
+
supported. Memory-backed stores are never selected implicitly; they always
|
|
207
|
+
require explicit opt-in through `allowMemory`.
|
|
208
|
+
|
|
209
|
+
`RateLimitState` is security state. Keep it private, do not expose it through REST, and use a datasource shared by all application instances. There is no automatic production memory fallback.
|
|
210
|
+
|
|
211
|
+
Redis is optional and therefore is not bundled with the framework. Install
|
|
212
|
+
[`@vsaas/loopback-component-rate-limit-redis`](https://github.com/vsaas/loopback-monorepo/tree/main/loopback-component-rate-limit-redis)
|
|
213
|
+
only in applications that need it. The package is a LoopBack component: it is loaded from
|
|
214
|
+
`component-config.json`, owns a Redis connection pool, and registers the `redis` provider before
|
|
215
|
+
rate-limit initialization.
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"@vsaas/loopback-component-rate-limit-redis": {
|
|
220
|
+
"redis": {
|
|
221
|
+
"url": "${REDIS_URL}"
|
|
222
|
+
},
|
|
223
|
+
"pool": {
|
|
224
|
+
"minimum": 1,
|
|
225
|
+
"maximum": 10,
|
|
226
|
+
"acquireTimeout": 5000
|
|
227
|
+
},
|
|
228
|
+
"keyPrefix": "example-api:rate-limit:"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Select it like any other store:
|
|
234
|
+
|
|
235
|
+
```js
|
|
236
|
+
stores: {
|
|
237
|
+
primary: {
|
|
238
|
+
provider: 'redis',
|
|
239
|
+
namespace: 'example-api-production',
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The Redis package uses isolated pooled connections for standalone Redis or
|
|
245
|
+
slot-primary routing with per-primary serialization for Redis Cluster.
|
|
246
|
+
`WATCH` and `MULTI/EXEC` provide atomic updates, while Redis `TIME` supplies
|
|
247
|
+
the authoritative clock. It preserves TTL, revision checks, `operationId`
|
|
248
|
+
deduplication, and fail-closed recovery of uncertain writes. Connection,
|
|
249
|
+
pool, and Cluster settings live exclusively in `component-config.json`;
|
|
250
|
+
supplying them inside a store definition fails configuration on purpose so a
|
|
251
|
+
single owner controls connection lifecycle. Store definitions may override
|
|
252
|
+
only `keyPrefix`, `maxRetries`, and `maxStateBytes`. See the package's dedicated
|
|
253
|
+
manual for every connection, Cluster, pool, store, security, health, shutdown,
|
|
254
|
+
and test option.
|
|
255
|
+
|
|
256
|
+
The existing generic provider API remains available for other storage implementations:
|
|
257
|
+
|
|
258
|
+
```js
|
|
259
|
+
app.registerRateLimitProvider('custom-store', (options) => createCustomStore(options));
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
A custom store must implement:
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
initialize()
|
|
266
|
+
healthCheck()
|
|
267
|
+
inspect(input)
|
|
268
|
+
consume(input)
|
|
269
|
+
reset(input)
|
|
270
|
+
delete(input)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
TypeScript consumers and optional store packages can import the shared store
|
|
274
|
+
contract from `@vsaas/loopback/rate-limit`:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import type {
|
|
278
|
+
RateLimitStore,
|
|
279
|
+
RateLimitInspection,
|
|
280
|
+
RateLimitConsumption,
|
|
281
|
+
RateLimitReset,
|
|
282
|
+
RateLimitDelete,
|
|
283
|
+
RateLimitDecision,
|
|
284
|
+
} from '@vsaas/loopback/rate-limit';
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Mutating operations must preserve the framework's `operationId` idempotency and revision/compare-and-swap guarantees. An ambiguous write outcome must fail closed instead of being retried as a new operation.
|
|
288
|
+
|
|
289
|
+
## Programmatic rules
|
|
290
|
+
|
|
291
|
+
Define a rule before initialization:
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
app.rateLimiter.defineRule({
|
|
295
|
+
id: 'report-generation-by-api-key',
|
|
296
|
+
algorithm: 'fixed-window',
|
|
297
|
+
store: 'primary',
|
|
298
|
+
subjectKeyring: 'default',
|
|
299
|
+
bucket: 'report-generation',
|
|
300
|
+
phase: 'domain',
|
|
301
|
+
order: 0,
|
|
302
|
+
onStoreError: 'deny',
|
|
303
|
+
|
|
304
|
+
subject: (context) => ({
|
|
305
|
+
namespace: 'example-api-production',
|
|
306
|
+
scope: 'reporting',
|
|
307
|
+
subjectType: 'api-key',
|
|
308
|
+
canonicalValue: context.apiKeyId,
|
|
309
|
+
}),
|
|
310
|
+
|
|
311
|
+
limit: (context) => ({
|
|
312
|
+
limit: context.plan.unitsPerMinute,
|
|
313
|
+
windowMs: 60_000,
|
|
314
|
+
blockDurationMs: 60_000,
|
|
315
|
+
stateTtlMs: 120_000,
|
|
316
|
+
revision: context.plan.revision,
|
|
317
|
+
}),
|
|
318
|
+
|
|
319
|
+
cost: (context) => context.reportCount,
|
|
320
|
+
});
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
The names `report-generation`, `reporting`, and `api-key` are examples chosen by this application. The framework does not contain a built-in reporting or API-key model.
|
|
324
|
+
|
|
325
|
+
After initialization, the application supplies the context expected by the three resolvers:
|
|
326
|
+
|
|
327
|
+
```js
|
|
328
|
+
const decision = await app.rateLimiter.consume('report-generation-by-api-key', {
|
|
329
|
+
apiKeyId: 'key-42',
|
|
330
|
+
reportCount: 5,
|
|
331
|
+
plan: {
|
|
332
|
+
unitsPerMinute: 100,
|
|
333
|
+
revision: 'standard-plan-v1',
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
For this call:
|
|
339
|
+
|
|
340
|
+
- `subject()` selects the counter belonging to API key `key-42`;
|
|
341
|
+
- `limit()` assigns that counter a capacity of 100 units for 60 seconds;
|
|
342
|
+
- `cost()` consumes 5 units;
|
|
343
|
+
- `consume()` returns the resulting decision without throwing merely because it is denied.
|
|
344
|
+
|
|
345
|
+
### Rule options
|
|
346
|
+
|
|
347
|
+
| Option | Default | Description |
|
|
348
|
+
| -------------------- | -------: | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
349
|
+
| `id` | — | Unique rule identifier. |
|
|
350
|
+
| `algorithm` | — | Currently only `fixed-window` is supported for registered programmatic rules. |
|
|
351
|
+
| `store` | — | Name of a registered store. |
|
|
352
|
+
| `subjectKeyring` | — | Keyring used to HMAC the subject. |
|
|
353
|
+
| `bucket` | — | Logical counter bucket. |
|
|
354
|
+
| `phase` | `domain` | Composition phase: `early`, `resolved`, or `domain`. |
|
|
355
|
+
| `order` | `0` | Integer ordering within a phase. |
|
|
356
|
+
| `shortCircuitOnDeny` | `false` | Stops evaluation after this rule denies. Leave it disabled when all applicable decisions or audit records are needed. |
|
|
357
|
+
| `onStoreError` | — | Required: `deny` or `allow`. |
|
|
358
|
+
| `subject(context)` | — | Synchronous or asynchronous resolver returning the subject descriptor. |
|
|
359
|
+
| `limit` | — | Static limit object or synchronous/asynchronous resolver. Returning `null` disables the rule for that context without touching the store. |
|
|
360
|
+
| `cost` | `1` | Positive integer or resolver. It cannot exceed `limits.maxCostPerConsume`. |
|
|
361
|
+
|
|
362
|
+
`onStoreError: 'allow'` converts a store failure into an allowed decision. Reserve it for explicitly best-effort, non-security use cases. Authentication rate limiting always fails closed.
|
|
363
|
+
|
|
364
|
+
### `phase` and `order`: ordering several rules
|
|
365
|
+
|
|
366
|
+
`phase` and `order` determine the order in which **rules** are evaluated when an integration composes several rules for one operation. They do not order incoming requests, give priority to one customer, change a quota, or control database execution order.
|
|
367
|
+
|
|
368
|
+
Rules are sorted by:
|
|
369
|
+
|
|
370
|
+
1. `phase`: `early`, then `resolved`, then `domain`;
|
|
371
|
+
2. `order`: lower integers first within the same phase;
|
|
372
|
+
3. original array order when both values are equal.
|
|
373
|
+
|
|
374
|
+
The three phase names are framework-defined ordering slots:
|
|
375
|
+
|
|
376
|
+
| Phase | Runs | Intended use |
|
|
377
|
+
| ---------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
378
|
+
| `early` | 1st | Coarse limits that can be derived from information available immediately, such as a global endpoint budget or a trusted request source. |
|
|
379
|
+
| `resolved` | 2nd | Limits that use an identity already resolved by the integration, such as an authenticated principal, tenant, or API key. Static remote-method policies use this default. |
|
|
380
|
+
| `domain` | 3rd | Limits based on application or resource context, such as a project, document, subscription plan, or job. Programmatic rules use this default. |
|
|
381
|
+
|
|
382
|
+
The framework only enforces this ordering. It does not verify that an `early` rule really uses request-level data or that a `domain` rule uses a domain object. Treat the phase names as shared conventions so integrations compose predictably.
|
|
383
|
+
|
|
384
|
+
Example:
|
|
385
|
+
|
|
386
|
+
```js
|
|
387
|
+
[
|
|
388
|
+
{ ruleId: 'endpoint-global', phase: 'early', order: 0 },
|
|
389
|
+
{ ruleId: 'source-volume', phase: 'early', order: 10 },
|
|
390
|
+
{ ruleId: 'account-quota', phase: 'resolved', order: 0 },
|
|
391
|
+
{ ruleId: 'project-quota', phase: 'domain', order: 0 },
|
|
392
|
+
];
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
The execution order is exactly the order shown. `order: 10` does not mean “ten requests later”; it only places `source-volume` after rules with a lower order in the `early` phase.
|
|
396
|
+
|
|
397
|
+
Ordering matters most when `shortCircuitOnDeny` is `true`: after that rule denies, later rules are not evaluated or consumed. With the default `false`, all applicable rules are evaluated in order and the final operation is denied if **any** rule denies. A later allow never erases an earlier denial.
|
|
398
|
+
|
|
399
|
+
For a single call to a registered rule through `inspect()`, `consume()`, `enforce()`, or `consumeBatch()`, there is only one rule, so its `phase` and `order` have no observable effect. They become relevant when the resolved rule is composed with others, especially through remote-method resolvers and the lower-level `consumeAll()` API.
|
|
400
|
+
|
|
401
|
+
### Subject descriptor
|
|
402
|
+
|
|
403
|
+
The `subject` resolver returns:
|
|
404
|
+
|
|
405
|
+
| Field | Who defines its value? | Description |
|
|
406
|
+
| ---------------- | ---------------------- | ---------------------------------------------------------------------------------- |
|
|
407
|
+
| `namespace` | Application | Application or deployment boundary. |
|
|
408
|
+
| `scope` | Application | Stable label that groups a family of subjects or counters. |
|
|
409
|
+
| `subjectType` | Application | Stable label describing the kind of value contained in `canonicalValue`. |
|
|
410
|
+
| `canonicalValue` | Application | Raw string or buffer that identifies the subject. It is HMACed before persistence. |
|
|
411
|
+
|
|
412
|
+
`scope` and `subjectType` are **not predefined enums**. LoopBack does not obtain them from models, dependency injection, HTTP, authentication, or a business hierarchy. The application chooses them. The only framework restriction is their format: 1 to 128 characters using letters, digits, `.`, `_`, or `-`.
|
|
413
|
+
|
|
414
|
+
This keeps the implementation generic. Examples of valid application-defined subjects are:
|
|
415
|
+
|
|
416
|
+
| Use case | `scope` | `subjectType` | `canonicalValue` |
|
|
417
|
+
| --------------------------- | ------------- | ------------- | ---------------------------- |
|
|
418
|
+
| One quota per account | `billing` | `account` | `String(account.id)` |
|
|
419
|
+
| One quota per API key | `public-api` | `api-key` | `String(apiKey.id)` |
|
|
420
|
+
| One quota per import source | `imports` | `source` | normalized source ID |
|
|
421
|
+
| One quota per device | `telemetry` | `device` | `String(device.id)` |
|
|
422
|
+
| One global counter | `maintenance` | `global` | a stable value such as `all` |
|
|
423
|
+
|
|
424
|
+
These words are illustrative, not reserved. An application could use `workspace`, `organization`, `queue`, or another vocabulary that matches its domain.
|
|
425
|
+
|
|
426
|
+
Think of the fields this way:
|
|
427
|
+
|
|
428
|
+
- `scope` answers “in which part of the application does this identity live?”;
|
|
429
|
+
- `subjectType` answers “what kind of identity is this?”;
|
|
430
|
+
- `canonicalValue` answers “which exact identity is it?”;
|
|
431
|
+
- `bucket` answers “which action is being counted for that identity?”.
|
|
432
|
+
|
|
433
|
+
For example, these are different counters even though their raw value is the same:
|
|
434
|
+
|
|
435
|
+
```js
|
|
436
|
+
{ scope: "billing", subjectType: "account", canonicalValue: "42" }
|
|
437
|
+
{ scope: "public-api", subjectType: "api-key", canonicalValue: "42" }
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Use stable IDs rather than display names or mutable attributes. Normalize equivalent values before returning them—for example, lowercase a case-insensitive email or use a canonical textual representation for an address. The framework HMACs the final value but does not canonicalize business data for you.
|
|
441
|
+
|
|
442
|
+
State identity also includes the subject key ID, bucket, policy ID, and policy version. Changing any identity component creates or addresses an independent counter, so renaming `scope` or `subjectType` is a state migration rather than a cosmetic refactor.
|
|
443
|
+
|
|
444
|
+
### Limit object
|
|
445
|
+
|
|
446
|
+
Programmatic duration values are in **milliseconds**.
|
|
447
|
+
|
|
448
|
+
| Option | Default | Description |
|
|
449
|
+
| ----------------- | -----------------------------------: | ----------------------------------------------------------------------------------- |
|
|
450
|
+
| `limit` | — | Positive integer capacity for the fixed window. |
|
|
451
|
+
| `windowMs` | — | Positive window duration in milliseconds. |
|
|
452
|
+
| `blockDurationMs` | `windowMs` | How long subsequent attempts remain blocked after capacity is exhausted. |
|
|
453
|
+
| `stateTtlMs` | `2 × max(windowMs, blockDurationMs)` | Persistence TTL. It must cover both durations and cannot exceed 365 days. |
|
|
454
|
+
| `revision` | — | Required non-empty policy revision. Change it whenever the policy semantics change. |
|
|
455
|
+
|
|
456
|
+
`limit` can be either one fixed object for every subject or a resolver function.
|
|
457
|
+
|
|
458
|
+
#### Fixed limit
|
|
459
|
+
|
|
460
|
+
Use a fixed object when every subject receives the same quota:
|
|
461
|
+
|
|
462
|
+
```js
|
|
463
|
+
limit: {
|
|
464
|
+
limit: 100,
|
|
465
|
+
windowMs: 60_000,
|
|
466
|
+
blockDurationMs: 60_000,
|
|
467
|
+
stateTtlMs: 120_000,
|
|
468
|
+
revision: "1",
|
|
469
|
+
}
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
This means:
|
|
473
|
+
|
|
474
|
+
- each counter has 100 units per 60-second fixed window;
|
|
475
|
+
- an operation consumes the rule's resolved `cost`;
|
|
476
|
+
- the operation that brings consumption to exactly 100 is allowed;
|
|
477
|
+
- the next operation is denied while the counter is blocked;
|
|
478
|
+
- when either the fixed window or the active block expires, the state resets and a new window can start with zero consumption.
|
|
479
|
+
|
|
480
|
+
The quota is measured in **cost units**, not automatically in HTTP requests. With the default `cost: 1`, 100 units means 100 operations. With `cost: context.rows.length`, it means 100 rows across any number of operations.
|
|
481
|
+
|
|
482
|
+
The framework snapshots a fixed limit when `defineRule()` is called. Mutating the original JavaScript object later does not change the registered rule. Restart and re-register the rule with a new `revision` to deploy a policy change.
|
|
483
|
+
|
|
484
|
+
#### Dynamic limit
|
|
485
|
+
|
|
486
|
+
Use a resolver when the quota depends on runtime context:
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
limit: async (context) => {
|
|
490
|
+
const plan = await findPlan(context.accountId);
|
|
491
|
+
|
|
492
|
+
if (!plan.rateLimitEnabled) return null;
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
limit: plan.unitsPerHour,
|
|
496
|
+
windowMs: 60 * 60 * 1000,
|
|
497
|
+
blockDurationMs: 5 * 60 * 1000,
|
|
498
|
+
stateTtlMs: 2 * 60 * 60 * 1000,
|
|
499
|
+
revision: String(plan.rateLimitRevision),
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The resolver may be synchronous or asynchronous. It receives the same read-only context passed to `inspect()`, `consume()`, `enforce()`, or each batch entry.
|
|
505
|
+
|
|
506
|
+
Returning `null` means “this rule does not apply to this context”:
|
|
507
|
+
|
|
508
|
+
- no store is read or mutated;
|
|
509
|
+
- the `subject` and `cost` resolvers are not called;
|
|
510
|
+
- the public decision contains `applied: false` and `allowed: true`;
|
|
511
|
+
- no counter is created.
|
|
512
|
+
|
|
513
|
+
This is useful for an unlimited plan or a feature that is disabled for a particular context. It is not the same as returning an extremely large limit.
|
|
514
|
+
|
|
515
|
+
#### What each field controls
|
|
516
|
+
|
|
517
|
+
`limit` is the total capacity of one fixed window. It must be a positive integer.
|
|
518
|
+
|
|
519
|
+
`windowMs` is the length of the fixed window. A window begins when the first applicable consumption creates fresh state; it is not aligned automatically to wall-clock boundaries such as the beginning of a minute.
|
|
520
|
+
|
|
521
|
+
`blockDurationMs` is the requested block duration once capacity has been filled or an operation exceeds the remaining capacity. It defaults to `windowMs`.
|
|
522
|
+
|
|
523
|
+
```text
|
|
524
|
+
limit = 10
|
|
525
|
+
windowMs = 60 seconds
|
|
526
|
+
blockDurationMs = 15 seconds
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
The tenth unit is allowed and starts the block for following operations. After the 15-second block expires, the fixed-window state resets and a new window can start.
|
|
530
|
+
|
|
531
|
+
Fixed-window state also resets when the original window expires. Therefore the effective wait ends at the earlier of the block expiry and the window expiry. A `blockDurationMs` longer than `windowMs` does not extend the block beyond that window. If the application needs escalating or independent lockout periods for failures, use a `failure-lockout` policy through a remote resolver or the authentication adapter rather than a registered fixed-window rule.
|
|
532
|
+
|
|
533
|
+
If the counter has 8 of 10 units consumed and the next operation costs 3:
|
|
534
|
+
|
|
535
|
+
- the operation is denied in full;
|
|
536
|
+
- the counter does not partially accept 2 units;
|
|
537
|
+
- the block begins;
|
|
538
|
+
- the caller must retry the whole operation after the block expires.
|
|
539
|
+
|
|
540
|
+
`stateTtlMs` controls how long persisted counter state remains eligible for cleanup. It is not the quota window and it is not the client retry delay. It must be at least as long as both `windowMs` and `blockDurationMs`. When omitted, it defaults to twice the longer of those durations, capped at 365 days.
|
|
541
|
+
|
|
542
|
+
`revision` identifies the semantics of a dynamic policy. Use a stable string such as a configuration version, subscription-policy version, or explicit deployment revision:
|
|
543
|
+
|
|
544
|
+
```js
|
|
545
|
+
revision: 'free-plan-v3';
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Change it whenever `limit`, `windowMs`, `blockDurationMs`, or `stateTtlMs` changes. A new revision addresses new policy state. Reusing the same rule ID and revision with different values is treated as a configuration error because different application instances could otherwise enforce incompatible policies against the same counter.
|
|
549
|
+
|
|
550
|
+
The revision identifies policy semantics across the entire rule, not only within one subject. If two plans return different limits, they must also return revisions that distinguish those policies:
|
|
551
|
+
|
|
552
|
+
```js
|
|
553
|
+
// Good: different semantics have different revisions.
|
|
554
|
+
{ limit: 100, revision: "free-v2", /* ... */ }
|
|
555
|
+
{ limit: 10_000, revision: "pro-v4", /* ... */ }
|
|
556
|
+
|
|
557
|
+
// Invalid: the same rule and revision resolve to two different limits.
|
|
558
|
+
{ limit: 100, revision: "1", /* ... */ }
|
|
559
|
+
{ limit: 10_000, revision: "1", /* ... */ }
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
Changing a revision starts addressing different policy state, so active consumption from the previous revision does not carry over automatically. Coordinate revision changes as quota migrations, especially across multiple application instances.
|
|
563
|
+
|
|
564
|
+
The policy resolver may determine a quota from one object while the counter belongs to another. For example, an account's subscription plan can supply the limit while `subject()` returns an API-key ID, creating one counter per key under the account's plan. The framework deliberately does not prescribe how policies are inherited or who owns the counter.
|
|
565
|
+
|
|
566
|
+
### Operations
|
|
567
|
+
|
|
568
|
+
```js
|
|
569
|
+
const preview = await app.rateLimiter.inspect('report-generation-by-api-key', context);
|
|
570
|
+
const decision = await app.rateLimiter.consume('report-generation-by-api-key', context);
|
|
571
|
+
await app.rateLimiter.enforce('report-generation-by-api-key', context);
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
| Operation | Behavior |
|
|
575
|
+
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
576
|
+
| `inspect` | Reads a decision without consuming capacity. Do not use it as an authorization check followed by a later consume; another request can race between them. |
|
|
577
|
+
| `consume` | Atomically consumes capacity and returns a decision. |
|
|
578
|
+
| `enforce` | Consumes capacity and throws `RATE_LIMITED` when denied. |
|
|
579
|
+
| `consumeBatch` | Resolves and consumes a set of entries, grouping compatible entries for the same state into one atomic cost. |
|
|
580
|
+
|
|
581
|
+
A decision includes `ruleId`, `applied`, `allowed`, `blockedUntilMs`, and `revision`. A rule whose limit resolver returns `null` produces `applied: false`.
|
|
582
|
+
|
|
583
|
+
Choose the operation from the behavior the caller needs:
|
|
584
|
+
|
|
585
|
+
- use `inspect()` for diagnostics, status pages, or a non-binding preview—not to reserve quota;
|
|
586
|
+
- use `consume()` when the caller wants to handle an allowed or denied decision itself;
|
|
587
|
+
- use `enforce()` when denial should immediately become a `RATE_LIMITED` error;
|
|
588
|
+
- use `consumeBatch()` when one input contains several independently addressable work items.
|
|
589
|
+
|
|
590
|
+
Consumption happens before the protected business operation. If the business operation later fails, the limiter does not refund the cost automatically. Automatic refunds would permit retry races and ambiguous double spending. Design cost around accepted attempts, or implement a separate, explicitly idempotent compensation workflow if the domain genuinely requires refunds.
|
|
591
|
+
|
|
592
|
+
#### Administrative operations
|
|
593
|
+
|
|
594
|
+
The limiter also exposes non-consuming helpers on `app.rateLimit.limiter` for support interventions and diagnostics:
|
|
595
|
+
|
|
596
|
+
| Operation | Behavior |
|
|
597
|
+
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
598
|
+
| `reset(rule, expectedRevision)` | Clears one subject's persisted state when the stored revision still matches. Returns an outcome of `reset`, `not-found`, or `preserved-newer-state` when a concurrent consumption already created newer state. |
|
|
599
|
+
| `delete(rules)` | Removes persisted state for the given resolved rules across their stores. |
|
|
600
|
+
| `inspectAll(rules)` | Reads decisions for several rules without consuming capacity, composing them in `phase`/`order` order. |
|
|
601
|
+
| `subjectIdentity(rule)` | Returns the stable internal identity string derived from the subject and bucket; useful when correlating logs with store state. |
|
|
602
|
+
|
|
603
|
+
These operations follow the same compare-and-swap guarantees as consumption. `reset()` never overwrites state created after the caller read it, so it cannot accidentally clear a block that a newer policy version established.
|
|
604
|
+
|
|
605
|
+
### Batch consumption
|
|
606
|
+
|
|
607
|
+
`consumeBatch()` runs one registered rule for many input entries:
|
|
608
|
+
|
|
609
|
+
```js
|
|
610
|
+
const decisions = await app.rateLimiter.consumeBatch(
|
|
611
|
+
'report-generation-by-api-key',
|
|
612
|
+
validatedJobs.map((job) => ({
|
|
613
|
+
entryId: job.id,
|
|
614
|
+
context: {
|
|
615
|
+
apiKeyId: job.apiKeyId,
|
|
616
|
+
reportCount: job.reports.length,
|
|
617
|
+
plan: job.plan,
|
|
618
|
+
},
|
|
619
|
+
})),
|
|
620
|
+
);
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
Each array element has two fields:
|
|
624
|
+
|
|
625
|
+
| Field | Purpose |
|
|
626
|
+
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
627
|
+
| `entryId` | Unique correlation ID from 1 to 256 characters. It appears in the returned decision so the caller can match output to input. It does **not** define the rate-limit subject and is not part of counter state. |
|
|
628
|
+
| `context` | Arbitrary application data passed to the rule's `subject`, `limit`, and `cost` resolvers. |
|
|
629
|
+
|
|
630
|
+
The limiter processes a batch in these steps:
|
|
631
|
+
|
|
632
|
+
1. It verifies that the input is an array, does not exceed `maxBatchEntries`, and contains unique valid `entryId` values.
|
|
633
|
+
2. It calls the rule's `limit` resolver for every entry. An entry returning `null` becomes `applied: false` and does not touch the store.
|
|
634
|
+
3. It resolves the subject and cost of every applicable entry.
|
|
635
|
+
4. It groups entries that address the same counter and use the same policy.
|
|
636
|
+
5. It adds the costs inside each group and performs one atomic consumption for that group.
|
|
637
|
+
6. It returns one decision per input entry, in the original input order.
|
|
638
|
+
|
|
639
|
+
#### Worked example
|
|
640
|
+
|
|
641
|
+
Assume the rule identifies its subject with `context.accountId`, uses `context.units` as cost, and allows 10 units:
|
|
642
|
+
|
|
643
|
+
```js
|
|
644
|
+
[
|
|
645
|
+
{ entryId: 'job-1', context: { accountId: 'A', units: 3 } },
|
|
646
|
+
{ entryId: 'job-2', context: { accountId: 'A', units: 2 } },
|
|
647
|
+
{ entryId: 'job-3', context: { accountId: 'B', units: 4 } },
|
|
648
|
+
];
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
The limiter creates two atomic groups:
|
|
652
|
+
|
|
653
|
+
```text
|
|
654
|
+
account A: job-1 + job-2 = cost 5
|
|
655
|
+
account B: job-3 = cost 4
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
If account A already consumed 7 of 10 units, its remaining capacity is 3. The grouped cost of 5 is denied in full; neither `job-1` nor `job-2` receives a partial allowance. If account B has enough capacity, `job-3` is still allowed.
|
|
659
|
+
|
|
660
|
+
The result remains aligned with the input:
|
|
661
|
+
|
|
662
|
+
```js
|
|
663
|
+
[
|
|
664
|
+
{ entryId: 'job-1', applied: true, allowed: false /* ... */ },
|
|
665
|
+
{ entryId: 'job-2', applied: true, allowed: false /* ... */ },
|
|
666
|
+
{ entryId: 'job-3', applied: true, allowed: true /* ... */ },
|
|
667
|
+
];
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Entries grouped into the same atomic consumption receive the same rate-limit decision. `consumeBatch()` returns denied decisions; it does not throw `RATE_LIMITED` merely because one group is denied. The caller must inspect every decision and decide which allowed work to continue.
|
|
671
|
+
|
|
672
|
+
#### Important batch guarantees and limits
|
|
673
|
+
|
|
674
|
+
- **Atomic per counter:** all cost grouped for one counter is accepted or denied together.
|
|
675
|
+
- **No transaction across counters:** different subjects are consumed independently. A denial or later business failure for one subject does not roll back another subject's quota.
|
|
676
|
+
- **Errors do not imply zero consumption:** groups are consumed independently and may run concurrently. If one store operation fails and the batch promise rejects, another group may already have consumed quota.
|
|
677
|
+
- **Stable output:** returned decisions have the same order as the input entries.
|
|
678
|
+
- **Policy consistency:** entries for the same counter must resolve the same policy fingerprint. Incompatible policies in one batch are rejected as a configuration error.
|
|
679
|
+
- **Grouped safety bound:** the summed cost of one group cannot exceed `maxCostPerConsume`.
|
|
680
|
+
- **No automatic refund:** a later failure while processing an allowed entry does not restore quota.
|
|
681
|
+
|
|
682
|
+
Validate and deduplicate the complete application payload **before** calling `consumeBatch()`. Derive cost from trusted, validated data rather than a count supplied by the client. For example, use `validatedRows.length`, not `request.body.declaredRowCount`.
|
|
683
|
+
|
|
684
|
+
## Remote-method policies
|
|
685
|
+
|
|
686
|
+
Remote methods can reference a named static policy:
|
|
687
|
+
|
|
688
|
+
```js
|
|
689
|
+
app.set('rateLimitPolicies', {
|
|
690
|
+
publicSearch: {
|
|
691
|
+
policyId: 'public-search',
|
|
692
|
+
policyVersion: '2',
|
|
693
|
+
algorithm: 'fixed-window',
|
|
694
|
+
maxAttempts: 30,
|
|
695
|
+
observationWindow: 60,
|
|
696
|
+
blockDuration: 120,
|
|
697
|
+
stateTtl: 300,
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
Unlike programmatic rules, durations in `rateLimitPolicies` are in **seconds**.
|
|
703
|
+
|
|
704
|
+
### Fixed-window policy
|
|
705
|
+
|
|
706
|
+
| Option | Default | Description |
|
|
707
|
+
| ------------------- | ----------: | --------------------------------------------------------------------------------------------- |
|
|
708
|
+
| `policyId` | Policy name | Stable state-policy identifier. |
|
|
709
|
+
| `policyVersion` | — | Required revision. Change it with any semantic policy change. |
|
|
710
|
+
| `algorithm` | — | `fixed-window`. |
|
|
711
|
+
| `maxAttempts` | — | Positive capacity per observation window. |
|
|
712
|
+
| `observationWindow` | — | Window length in seconds. |
|
|
713
|
+
| `blockDuration` | — | Block duration in seconds. |
|
|
714
|
+
| `stateTtl` | — | State TTL in seconds. It must cover the window and block duration and cannot exceed 365 days. |
|
|
715
|
+
|
|
716
|
+
### Failure-lockout policy
|
|
717
|
+
|
|
718
|
+
Failure lockout is available to custom remote resolvers and authentication-specific controls:
|
|
719
|
+
|
|
720
|
+
| Option | Default | Description |
|
|
721
|
+
| ------------------- | --------------: | ---------------------------------------------------------------------------- |
|
|
722
|
+
| `policyId` | Policy name | Stable state-policy identifier. |
|
|
723
|
+
| `policyVersion` | — | Required revision. |
|
|
724
|
+
| `algorithm` | — | `failure-lockout`. |
|
|
725
|
+
| `maxFailedAttempts` | — | Failures allowed in the observation window. |
|
|
726
|
+
| `observationWindow` | — | Failure observation window in seconds. |
|
|
727
|
+
| `blockDuration` | — | Initial block duration in seconds. |
|
|
728
|
+
| `strategy` | `fixed` | `fixed` or `exponential`. |
|
|
729
|
+
| `multiplier` | `2` | Backoff multiplier for the exponential strategy. |
|
|
730
|
+
| `maxBlockDuration` | `blockDuration` | Maximum block duration in seconds. |
|
|
731
|
+
| `backoffResetAfter` | — | Successful or quiet period after which the backoff level resets, in seconds. |
|
|
732
|
+
| `stateTtl` | — | State TTL in seconds. It must cover all relevant windows and maximum blocks. |
|
|
733
|
+
|
|
734
|
+
`multiplier` accepts values from 1 to 100. Escalation is computed as
|
|
735
|
+
`blockDuration × multiplier^(level − 1)`, capped at 30 doublings and at
|
|
736
|
+
`maxBlockDuration`, so extreme configurations cannot overflow block durations.
|
|
737
|
+
|
|
738
|
+
### Static remote-method metadata
|
|
739
|
+
|
|
740
|
+
Attach a policy in the model JSON or through `remoteMethod()` metadata:
|
|
741
|
+
|
|
742
|
+
```json
|
|
743
|
+
{
|
|
744
|
+
"rateLimit": {
|
|
745
|
+
"policy": "publicSearch",
|
|
746
|
+
"store": "primary",
|
|
747
|
+
"subjectKeyring": "default",
|
|
748
|
+
"bucket": "PublicSearch.find",
|
|
749
|
+
"cost": 1,
|
|
750
|
+
"onStoreError": "deny",
|
|
751
|
+
"phase": "resolved",
|
|
752
|
+
"order": 0,
|
|
753
|
+
"shortCircuitOnDeny": false
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
| Option | Default | Description |
|
|
759
|
+
| -------------------- | -------------: | -------------------------------------- |
|
|
760
|
+
| `policy` | — | Name in `rateLimitPolicies`. |
|
|
761
|
+
| `store` | — | Registered store name. |
|
|
762
|
+
| `subjectKeyring` | `default` | Subject keyring. |
|
|
763
|
+
| `bucket` | `Model.method` | Counter bucket. |
|
|
764
|
+
| `cost` | `1` | Positive consumption cost. |
|
|
765
|
+
| `onStoreError` | `deny` | `deny` or `allow`. |
|
|
766
|
+
| `phase` | `resolved` | Composition phase. |
|
|
767
|
+
| `order` | `0` | Integer ordering within the phase. |
|
|
768
|
+
| `shortCircuitOnDeny` | `false` | Whether this denial stops later rules. |
|
|
769
|
+
|
|
770
|
+
A static remote policy is **global per endpoint**. Its subject is the resolved `Model.method`, not the caller's IP address, user, tenant, or access token. This is appropriate for a global endpoint budget. Use a resolver when the policy must distinguish callers.
|
|
771
|
+
|
|
772
|
+
### Remote resolver
|
|
773
|
+
|
|
774
|
+
A remote resolver returns one or more fully resolved rules. Normalize the policy through the public helper so validation and fingerprinting stay consistent:
|
|
775
|
+
|
|
776
|
+
```js
|
|
777
|
+
const loopback = require('@vsaas/loopback');
|
|
778
|
+
|
|
779
|
+
const policy = loopback.rateLimit.normalizeFixedWindowPolicy({
|
|
780
|
+
policyId: 'public-search-source',
|
|
781
|
+
policyVersion: '1',
|
|
782
|
+
maxAttempts: 30,
|
|
783
|
+
observationWindowMs: 60_000,
|
|
784
|
+
blockDurationMs: 120_000,
|
|
785
|
+
stateTtlMs: 300_000,
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
app.registerRateLimitResolver('publicSearchBySource', async (context) => {
|
|
789
|
+
const source = context.source;
|
|
790
|
+
if (!source) return null;
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
ruleId: 'public-search-source',
|
|
794
|
+
store: 'primary',
|
|
795
|
+
subjectKeyring: 'default',
|
|
796
|
+
bucket: 'PublicSearch.find',
|
|
797
|
+
subject: {
|
|
798
|
+
namespace: 'example-api-production',
|
|
799
|
+
scope: 'remote-method',
|
|
800
|
+
subjectType: 'source',
|
|
801
|
+
canonicalValue: source,
|
|
802
|
+
},
|
|
803
|
+
policy,
|
|
804
|
+
cost: 1,
|
|
805
|
+
onStoreError: 'deny',
|
|
806
|
+
phase: 'early',
|
|
807
|
+
order: 0,
|
|
808
|
+
shortCircuitOnDeny: false,
|
|
809
|
+
};
|
|
810
|
+
});
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
Reference it from remote metadata with `"resolver": "publicSearchBySource"`. The resolver context contains `app`, `model`, `methodName`, `args`, `request`, `accessToken`, and `source`. It may return `null`, one resolved rule, or a list of resolved rules.
|
|
814
|
+
|
|
815
|
+
By default, `source` is the request IP address. A model can supply its own
|
|
816
|
+
signal by defining a static `getRateLimitSource(request)` hook, which receives
|
|
817
|
+
the request and returns any stable source identifier, or `null` to omit
|
|
818
|
+
source-based rules for that call. This mirrors how authentication rate limiting
|
|
819
|
+
resolves its source through `resolveAuthenticationRateLimitSource`.
|
|
820
|
+
|
|
821
|
+
Resolver policy helpers use **milliseconds**, because they produce the programmatic normalized policy contract. Named `rateLimitPolicies` remain in seconds.
|
|
822
|
+
|
|
823
|
+
All returned rules compose in stable `phase` and `order` order. Any denial denies the request; a later allowed decision cannot overwrite an earlier denial.
|
|
824
|
+
|
|
825
|
+
Static policy and resolver references are validated at startup. Static definitions are snapshotted, so mutating configuration after initialization has no effect.
|
|
826
|
+
|
|
827
|
+
## Errors and HTTP behavior
|
|
828
|
+
|
|
829
|
+
| Code | HTTP status | Meaning |
|
|
830
|
+
| ---------------------------------- | ----------: | --------------------------------------------------------------------------------------------------------------------- |
|
|
831
|
+
| `RATE_LIMITED` | `429` | The applicable policy denied the operation. Remote responses include a rounded-up `Retry-After` value when available. |
|
|
832
|
+
| `RATE_LIMIT_UNAVAILABLE` | `503` | Required rate-limit state could not be read or updated safely. |
|
|
833
|
+
| `RATE_LIMIT_CONFIGURATION_INVALID` | `500` | Invalid configuration or policy mismatch. This should normally fail startup. |
|
|
834
|
+
| `RATE_LIMIT_OPERATION_AMBIGUOUS` | `503` | The store cannot prove whether a mutation completed. |
|
|
835
|
+
|
|
836
|
+
`RATE_LIMITED` and `RATE_LIMIT_UNAVAILABLE` remote error responses set
|
|
837
|
+
`Cache-Control: no-store`, and `RATE_LIMITED` responses include a rounded-up
|
|
838
|
+
`Retry-After` value when a block is active. The configuration and
|
|
839
|
+
ambiguous-operation codes follow normal framework error handling; do not
|
|
840
|
+
convert unavailable or ambiguous security-state errors into ordinary
|
|
841
|
+
application errors.
|
|
842
|
+
|
|
843
|
+
## State cleanup
|
|
844
|
+
|
|
845
|
+
Expired state can be removed with:
|
|
846
|
+
|
|
847
|
+
```js
|
|
848
|
+
const result = await app.cleanupRateLimitStates({
|
|
849
|
+
model: 'RateLimitState',
|
|
850
|
+
now: new Date(),
|
|
851
|
+
});
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
The helper deletes records whose `expiresAt` is older than `now`. Schedule it as a recurring maintenance job. Cleanup does not replace datasource TTL indexes where the connector supports them, but it provides a connector-independent fallback.
|
|
855
|
+
|
|
856
|
+
## Key rotation
|
|
857
|
+
|
|
858
|
+
Rotate a subject keyring without resetting or duplicating live counters:
|
|
859
|
+
|
|
860
|
+
1. Add the new key while retaining the old key.
|
|
861
|
+
2. Change `currentKeyId` to the new key.
|
|
862
|
+
3. Deploy the configuration everywhere.
|
|
863
|
+
4. Keep the old key for at least the longest `stateTtl`, plus cleanup and deployment margin.
|
|
864
|
+
5. Run cleanup and verify that no unexpired state references the old key.
|
|
865
|
+
6. Remove the old key.
|
|
866
|
+
|
|
867
|
+
The limiter checks live candidates across retained keys. If multiple live states make the active counter ambiguous, it fails closed. Removing an old key too early can bypass an existing block by making its state unreachable.
|
|
868
|
+
|
|
869
|
+
## Production checklist
|
|
870
|
+
|
|
871
|
+
- Use a private, durable `RateLimitState` model shared by every instance.
|
|
872
|
+
- Give each deployment a stable, unique namespace.
|
|
873
|
+
- Keep HMAC keys outside source control and use at least 32 random bytes.
|
|
874
|
+
- Initialize before listening and treat initialization failure as a deployment failure.
|
|
875
|
+
- Use `onStoreError: 'deny'` for abuse prevention, authentication, authorization, and resource protection.
|
|
876
|
+
- Derive batch cost from validated, deduplicated input.
|
|
877
|
+
- Increment policy revisions whenever semantics change.
|
|
878
|
+
- Monitor `429`, unavailable-state errors, ambiguous operations, store latency, and cleanup lag.
|
|
879
|
+
- Test capacity boundaries, block expiry, concurrent consumption, store failure, policy changes, key rotation, and multi-instance behavior.
|