halt-rate 0.2.0 → 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 +34 -921
- package/dist/adapters/express.d.mts +1 -1
- package/dist/adapters/express.d.ts +1 -1
- package/dist/adapters/fastify.d.mts +29 -0
- package/dist/adapters/fastify.d.ts +29 -0
- package/dist/adapters/fastify.js +38 -0
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/adapters/fastify.mjs +35 -0
- package/dist/adapters/fastify.mjs.map +1 -0
- package/dist/adapters/graphql.d.mts +22 -0
- package/dist/adapters/graphql.d.ts +22 -0
- package/dist/adapters/graphql.js +51 -0
- package/dist/adapters/graphql.js.map +1 -0
- package/dist/adapters/graphql.mjs +49 -0
- package/dist/adapters/graphql.mjs.map +1 -0
- package/dist/adapters/hono.d.mts +30 -0
- package/dist/adapters/hono.d.ts +30 -0
- package/dist/adapters/hono.js +56 -0
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/hono.mjs +54 -0
- package/dist/adapters/hono.mjs.map +1 -0
- package/dist/adapters/next.d.mts +1 -1
- package/dist/adapters/next.d.ts +1 -1
- package/dist/adapters/next.js +26 -3
- package/dist/adapters/next.js.map +1 -1
- package/dist/adapters/next.mjs +26 -3
- package/dist/adapters/next.mjs.map +1 -1
- package/dist/index.d.mts +174 -3
- package/dist/index.d.ts +174 -3
- package/dist/index.js +631 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +614 -4
- package/dist/index.mjs.map +1 -1
- package/dist/limiter-DqVVE0Kl.d.mts +310 -0
- package/dist/limiter-DqVVE0Kl.d.ts +310 -0
- package/package.json +80 -13
- package/dist/limiter-HCt8ADZ2.d.mts +0 -177
- package/dist/limiter-HCt8ADZ2.d.ts +0 -177
package/dist/index.js
CHANGED
|
@@ -33,7 +33,8 @@ function normalizePolicy(policy) {
|
|
|
33
33
|
cost: policy.cost ?? 1,
|
|
34
34
|
blockDuration: policy.blockDuration ?? void 0,
|
|
35
35
|
keyExtractor: policy.keyExtractor ?? void 0,
|
|
36
|
-
exemptions: policy.exemptions ?? []
|
|
36
|
+
exemptions: policy.exemptions ?? [],
|
|
37
|
+
plan: policy.plan ?? void 0
|
|
37
38
|
};
|
|
38
39
|
if (normalized.limit <= 0) {
|
|
39
40
|
throw new Error("limit must be positive");
|
|
@@ -404,6 +405,25 @@ var RateLimiter = class {
|
|
|
404
405
|
this.algorithmCache = /* @__PURE__ */ new Map();
|
|
405
406
|
this.otelTracer = options.otelTracer;
|
|
406
407
|
this.metricsRecorder = options.metricsRecorder;
|
|
408
|
+
this.telemetry = options.telemetry;
|
|
409
|
+
}
|
|
410
|
+
/** Fan a finished decision out to the telemetry hooks with rich metadata. */
|
|
411
|
+
emitTelemetry(request, key, decision, policy, cost) {
|
|
412
|
+
if (!this.telemetry) return;
|
|
413
|
+
const metadata = {
|
|
414
|
+
policy: policy.name,
|
|
415
|
+
algorithm: policy.algorithm,
|
|
416
|
+
keyStrategy: policy.keyStrategy,
|
|
417
|
+
endpoint: extractPath(request) ?? void 0,
|
|
418
|
+
cost,
|
|
419
|
+
plan: policy.plan
|
|
420
|
+
};
|
|
421
|
+
this.telemetry.onCheck?.(key, decision, metadata);
|
|
422
|
+
if (decision.allowed) {
|
|
423
|
+
this.telemetry.onAllowed?.(key, decision, metadata);
|
|
424
|
+
} else {
|
|
425
|
+
this.telemetry.onBlocked?.(key, decision, metadata);
|
|
426
|
+
}
|
|
407
427
|
}
|
|
408
428
|
/**
|
|
409
429
|
* Check if request is allowed under rate limit.
|
|
@@ -462,10 +482,12 @@ var RateLimiter = class {
|
|
|
462
482
|
1
|
|
463
483
|
);
|
|
464
484
|
span2?.end?.();
|
|
485
|
+
this.emitTelemetry(request, key, decision2, policy, requestCost);
|
|
465
486
|
return decision2;
|
|
466
487
|
}
|
|
467
488
|
const store = this.store;
|
|
468
|
-
|
|
489
|
+
const cacheKey = `${policy.name}|${policy.algorithm}|${policy.limit}|${policy.window}|${policy.burst}`;
|
|
490
|
+
let algorithm = this.algorithmCache.get(cacheKey);
|
|
469
491
|
if (!algorithm) {
|
|
470
492
|
if (policy.algorithm === "token_bucket" /* TOKEN_BUCKET */) {
|
|
471
493
|
algorithm = new TokenBucket(policy.burst, policy.limit, policy.window);
|
|
@@ -479,7 +501,7 @@ var RateLimiter = class {
|
|
|
479
501
|
} else {
|
|
480
502
|
throw new Error(`Algorithm ${policy.algorithm} not implemented`);
|
|
481
503
|
}
|
|
482
|
-
this.algorithmCache.set(
|
|
504
|
+
this.algorithmCache.set(cacheKey, algorithm);
|
|
483
505
|
}
|
|
484
506
|
const span = this.otelTracer?.startSpan?.("halt.check", { attributes: { policy: policy.name, key } });
|
|
485
507
|
const state = store.get(storageKey);
|
|
@@ -545,6 +567,7 @@ var RateLimiter = class {
|
|
|
545
567
|
this.metricsRecorder?.("halt.request.blocked", { policy: policy.name }, 1);
|
|
546
568
|
}
|
|
547
569
|
span?.end?.();
|
|
570
|
+
this.emitTelemetry(request, key, decision, policy, requestCost);
|
|
548
571
|
return decision;
|
|
549
572
|
}
|
|
550
573
|
/**
|
|
@@ -1008,6 +1031,7 @@ var GENEROUS_API = {
|
|
|
1008
1031
|
};
|
|
1009
1032
|
var PLAN_FREE = {
|
|
1010
1033
|
name: "free_plan",
|
|
1034
|
+
plan: "free",
|
|
1011
1035
|
limit: 100,
|
|
1012
1036
|
window: 3600,
|
|
1013
1037
|
// 100 requests per hour
|
|
@@ -1017,6 +1041,7 @@ var PLAN_FREE = {
|
|
|
1017
1041
|
};
|
|
1018
1042
|
var PLAN_STARTER = {
|
|
1019
1043
|
name: "starter_plan",
|
|
1044
|
+
plan: "starter",
|
|
1020
1045
|
limit: 500,
|
|
1021
1046
|
window: 3600,
|
|
1022
1047
|
// 500 requests per hour
|
|
@@ -1026,6 +1051,7 @@ var PLAN_STARTER = {
|
|
|
1026
1051
|
};
|
|
1027
1052
|
var PLAN_PRO = {
|
|
1028
1053
|
name: "pro_plan",
|
|
1054
|
+
plan: "pro",
|
|
1029
1055
|
limit: 2e3,
|
|
1030
1056
|
window: 3600,
|
|
1031
1057
|
// 2000 requests per hour
|
|
@@ -1035,6 +1061,7 @@ var PLAN_PRO = {
|
|
|
1035
1061
|
};
|
|
1036
1062
|
var PLAN_BUSINESS = {
|
|
1037
1063
|
name: "business_plan",
|
|
1064
|
+
plan: "business",
|
|
1038
1065
|
limit: 5e3,
|
|
1039
1066
|
window: 3600,
|
|
1040
1067
|
// 5000 requests per hour
|
|
@@ -1044,6 +1071,7 @@ var PLAN_BUSINESS = {
|
|
|
1044
1071
|
};
|
|
1045
1072
|
var PLAN_ENTERPRISE = {
|
|
1046
1073
|
name: "enterprise_plan",
|
|
1074
|
+
plan: "enterprise",
|
|
1047
1075
|
limit: 2e4,
|
|
1048
1076
|
window: 3600,
|
|
1049
1077
|
// 20000 requests per hour
|
|
@@ -1068,11 +1096,611 @@ function getPlanPolicy(planName) {
|
|
|
1068
1096
|
return PLAN_TIERS[normalized];
|
|
1069
1097
|
}
|
|
1070
1098
|
|
|
1099
|
+
// src/core/registry.ts
|
|
1100
|
+
var PolicyRegistry = class {
|
|
1101
|
+
constructor(initial = []) {
|
|
1102
|
+
this.policies = /* @__PURE__ */ new Map();
|
|
1103
|
+
for (const p of initial) this.register(p);
|
|
1104
|
+
}
|
|
1105
|
+
/** Add or replace a policy (keyed by `policy.name`). */
|
|
1106
|
+
register(policy) {
|
|
1107
|
+
this.policies.set(policy.name, policy);
|
|
1108
|
+
return this;
|
|
1109
|
+
}
|
|
1110
|
+
get(name) {
|
|
1111
|
+
return this.policies.get(name);
|
|
1112
|
+
}
|
|
1113
|
+
has(name) {
|
|
1114
|
+
return this.policies.has(name);
|
|
1115
|
+
}
|
|
1116
|
+
/** Mutate fields of an existing policy at runtime (e.g. `{ limit: 500 }`). */
|
|
1117
|
+
update(name, patch) {
|
|
1118
|
+
const existing = this.policies.get(name);
|
|
1119
|
+
if (!existing) throw new Error(`Unknown policy: ${name}`);
|
|
1120
|
+
const updated = { ...existing, ...patch, name: existing.name };
|
|
1121
|
+
if (patch.limit !== void 0 && patch.burst === void 0) {
|
|
1122
|
+
delete updated.burst;
|
|
1123
|
+
}
|
|
1124
|
+
this.policies.set(name, updated);
|
|
1125
|
+
return updated;
|
|
1126
|
+
}
|
|
1127
|
+
remove(name) {
|
|
1128
|
+
return this.policies.delete(name);
|
|
1129
|
+
}
|
|
1130
|
+
list() {
|
|
1131
|
+
return [...this.policies.values()];
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Build a resolver for the limiter's `policy` option.
|
|
1135
|
+
* @param selector maps a request to a registered policy name.
|
|
1136
|
+
*/
|
|
1137
|
+
resolver(selector) {
|
|
1138
|
+
return (request) => {
|
|
1139
|
+
const name = selector(request);
|
|
1140
|
+
const policy = this.policies.get(name);
|
|
1141
|
+
if (!policy) throw new Error(`Unknown policy: ${name}`);
|
|
1142
|
+
return policy;
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
};
|
|
1146
|
+
function cachedPolicyResolver(loader, options = {}) {
|
|
1147
|
+
const ttlMs = options.ttlMs ?? 5e3;
|
|
1148
|
+
const keyFn = options.key ?? (() => "__default__");
|
|
1149
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1150
|
+
return async (request) => {
|
|
1151
|
+
const key = keyFn(request);
|
|
1152
|
+
const now = Date.now();
|
|
1153
|
+
const hit = cache.get(key);
|
|
1154
|
+
if (hit && hit.expires > now) {
|
|
1155
|
+
return hit.policy;
|
|
1156
|
+
}
|
|
1157
|
+
const policy = await loader(request);
|
|
1158
|
+
cache.set(key, { policy, expires: now + ttlMs });
|
|
1159
|
+
return policy;
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/core/quota.ts
|
|
1164
|
+
var QuotaPeriod = /* @__PURE__ */ ((QuotaPeriod2) => {
|
|
1165
|
+
QuotaPeriod2["HOURLY"] = "hourly";
|
|
1166
|
+
QuotaPeriod2["DAILY"] = "daily";
|
|
1167
|
+
QuotaPeriod2["MONTHLY"] = "monthly";
|
|
1168
|
+
QuotaPeriod2["YEARLY"] = "yearly";
|
|
1169
|
+
return QuotaPeriod2;
|
|
1170
|
+
})(QuotaPeriod || {});
|
|
1171
|
+
var QuotaManager = class {
|
|
1172
|
+
constructor(store, telemetry) {
|
|
1173
|
+
this.store = store;
|
|
1174
|
+
this.telemetry = telemetry;
|
|
1175
|
+
}
|
|
1176
|
+
getQuotaKey(identifier, quotaName) {
|
|
1177
|
+
return `halt:quota:${quotaName}:${identifier}`;
|
|
1178
|
+
}
|
|
1179
|
+
calculateResetTime(period) {
|
|
1180
|
+
const now = /* @__PURE__ */ new Date();
|
|
1181
|
+
switch (period) {
|
|
1182
|
+
case "hourly" /* HOURLY */:
|
|
1183
|
+
const nextHour = new Date(now);
|
|
1184
|
+
nextHour.setHours(now.getHours() + 1, 0, 0, 0);
|
|
1185
|
+
return Math.floor(nextHour.getTime() / 1e3);
|
|
1186
|
+
case "daily" /* DAILY */:
|
|
1187
|
+
const nextDay = new Date(now);
|
|
1188
|
+
nextDay.setDate(now.getDate() + 1);
|
|
1189
|
+
nextDay.setHours(0, 0, 0, 0);
|
|
1190
|
+
return Math.floor(nextDay.getTime() / 1e3);
|
|
1191
|
+
case "monthly" /* MONTHLY */:
|
|
1192
|
+
const nextMonth = new Date(now);
|
|
1193
|
+
if (now.getMonth() === 11) {
|
|
1194
|
+
nextMonth.setFullYear(now.getFullYear() + 1, 0, 1);
|
|
1195
|
+
} else {
|
|
1196
|
+
nextMonth.setMonth(now.getMonth() + 1, 1);
|
|
1197
|
+
}
|
|
1198
|
+
nextMonth.setHours(0, 0, 0, 0);
|
|
1199
|
+
return Math.floor(nextMonth.getTime() / 1e3);
|
|
1200
|
+
case "yearly" /* YEARLY */:
|
|
1201
|
+
const nextYear = new Date(now);
|
|
1202
|
+
nextYear.setFullYear(now.getFullYear() + 1, 0, 1);
|
|
1203
|
+
nextYear.setHours(0, 0, 0, 0);
|
|
1204
|
+
return Math.floor(nextYear.getTime() / 1e3);
|
|
1205
|
+
default:
|
|
1206
|
+
throw new Error(`Invalid quota period: ${period}`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
async getQuota(identifier, quota) {
|
|
1210
|
+
const key = this.getQuotaKey(identifier, quota.name);
|
|
1211
|
+
const stored = await this.store.get(key);
|
|
1212
|
+
if (!stored) {
|
|
1213
|
+
return {
|
|
1214
|
+
name: quota.name,
|
|
1215
|
+
limit: quota.limit,
|
|
1216
|
+
period: quota.period,
|
|
1217
|
+
currentUsage: 0,
|
|
1218
|
+
resetAt: this.calculateResetTime(quota.period)
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
const currentQuota = {
|
|
1222
|
+
name: stored.name || quota.name,
|
|
1223
|
+
limit: stored.limit || quota.limit,
|
|
1224
|
+
period: stored.period || quota.period,
|
|
1225
|
+
currentUsage: stored.currentUsage || 0,
|
|
1226
|
+
resetAt: stored.resetAt
|
|
1227
|
+
};
|
|
1228
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1229
|
+
if (currentQuota.resetAt && now >= currentQuota.resetAt) {
|
|
1230
|
+
currentQuota.currentUsage = 0;
|
|
1231
|
+
currentQuota.resetAt = this.calculateResetTime(currentQuota.period);
|
|
1232
|
+
}
|
|
1233
|
+
return currentQuota;
|
|
1234
|
+
}
|
|
1235
|
+
async checkQuota(identifier, quota, cost = 1) {
|
|
1236
|
+
const currentQuota = await this.getQuota(identifier, quota);
|
|
1237
|
+
const allowed = (currentQuota.currentUsage || 0) + cost <= currentQuota.limit;
|
|
1238
|
+
this.telemetry?.onQuotaCheck?.(identifier, currentQuota, allowed);
|
|
1239
|
+
if (!allowed) {
|
|
1240
|
+
this.telemetry?.onQuotaExceeded?.(identifier, currentQuota);
|
|
1241
|
+
}
|
|
1242
|
+
return { allowed, quota: currentQuota };
|
|
1243
|
+
}
|
|
1244
|
+
async consumeQuota(identifier, quota, cost = 1) {
|
|
1245
|
+
const currentQuota = await this.getQuota(identifier, quota);
|
|
1246
|
+
currentQuota.currentUsage = (currentQuota.currentUsage || 0) + cost;
|
|
1247
|
+
const key = this.getQuotaKey(identifier, quota.name);
|
|
1248
|
+
const ttl = (currentQuota.resetAt || 0) - Math.floor(Date.now() / 1e3) + 3600;
|
|
1249
|
+
await this.store.set(key, currentQuota, ttl);
|
|
1250
|
+
return currentQuota;
|
|
1251
|
+
}
|
|
1252
|
+
async resetQuota(identifier, quota) {
|
|
1253
|
+
const key = this.getQuotaKey(identifier, quota.name);
|
|
1254
|
+
await this.store.delete(key);
|
|
1255
|
+
}
|
|
1256
|
+
remaining(quota) {
|
|
1257
|
+
return Math.max(0, quota.limit - (quota.currentUsage || 0));
|
|
1258
|
+
}
|
|
1259
|
+
isExceeded(quota) {
|
|
1260
|
+
return (quota.currentUsage || 0) >= quota.limit;
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
var QUOTA_FREE_MONTHLY = {
|
|
1264
|
+
name: "free_monthly_requests",
|
|
1265
|
+
limit: 1e4,
|
|
1266
|
+
period: "monthly" /* MONTHLY */
|
|
1267
|
+
};
|
|
1268
|
+
var QUOTA_PRO_MONTHLY = {
|
|
1269
|
+
name: "pro_monthly_requests",
|
|
1270
|
+
limit: 1e5,
|
|
1271
|
+
period: "monthly" /* MONTHLY */
|
|
1272
|
+
};
|
|
1273
|
+
var QUOTA_ENTERPRISE_MONTHLY = {
|
|
1274
|
+
name: "enterprise_monthly_requests",
|
|
1275
|
+
limit: 1e6,
|
|
1276
|
+
period: "monthly" /* MONTHLY */
|
|
1277
|
+
};
|
|
1278
|
+
var QUOTA_FREE_DAILY = {
|
|
1279
|
+
name: "free_daily_requests",
|
|
1280
|
+
limit: 500,
|
|
1281
|
+
period: "daily" /* DAILY */
|
|
1282
|
+
};
|
|
1283
|
+
var QUOTA_PRO_DAILY = {
|
|
1284
|
+
name: "pro_daily_requests",
|
|
1285
|
+
limit: 5e3,
|
|
1286
|
+
period: "daily" /* DAILY */
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
// src/core/penalty.ts
|
|
1290
|
+
var PenaltyManager = class {
|
|
1291
|
+
constructor(store, config, telemetry) {
|
|
1292
|
+
this.store = store;
|
|
1293
|
+
this.telemetry = telemetry;
|
|
1294
|
+
this.config = {
|
|
1295
|
+
threshold: config?.threshold ?? 10,
|
|
1296
|
+
duration: config?.duration ?? 3600,
|
|
1297
|
+
multiplier: config?.multiplier ?? 0.5,
|
|
1298
|
+
decayRate: config?.decayRate ?? 1
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
getPenaltyKey(identifier) {
|
|
1302
|
+
return `halt:penalty:${identifier}`;
|
|
1303
|
+
}
|
|
1304
|
+
async getPenalty(identifier) {
|
|
1305
|
+
const key = this.getPenaltyKey(identifier);
|
|
1306
|
+
const stored = await this.store.get(key);
|
|
1307
|
+
if (!stored) {
|
|
1308
|
+
return {
|
|
1309
|
+
abuseScore: 0,
|
|
1310
|
+
penaltyUntil: null,
|
|
1311
|
+
violations: 0,
|
|
1312
|
+
lastViolation: null
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
const penalty = {
|
|
1316
|
+
abuseScore: stored.abuseScore || 0,
|
|
1317
|
+
penaltyUntil: stored.penaltyUntil || null,
|
|
1318
|
+
violations: stored.violations || 0,
|
|
1319
|
+
lastViolation: stored.lastViolation || null
|
|
1320
|
+
};
|
|
1321
|
+
if (penalty.lastViolation) {
|
|
1322
|
+
const hoursElapsed = (Date.now() / 1e3 - penalty.lastViolation) / 3600;
|
|
1323
|
+
const decay = hoursElapsed * this.config.decayRate;
|
|
1324
|
+
penalty.abuseScore = Math.max(0, penalty.abuseScore - decay);
|
|
1325
|
+
}
|
|
1326
|
+
return penalty;
|
|
1327
|
+
}
|
|
1328
|
+
async recordViolation(identifier, severity = 1) {
|
|
1329
|
+
const penalty = await this.getPenalty(identifier);
|
|
1330
|
+
penalty.abuseScore += severity;
|
|
1331
|
+
penalty.violations += 1;
|
|
1332
|
+
penalty.lastViolation = Math.floor(Date.now() / 1e3);
|
|
1333
|
+
let penaltyTriggered = false;
|
|
1334
|
+
if (penalty.abuseScore >= this.config.threshold && !this.isActive(penalty)) {
|
|
1335
|
+
penalty.penaltyUntil = Math.floor(Date.now() / 1e3) + this.config.duration;
|
|
1336
|
+
penaltyTriggered = true;
|
|
1337
|
+
}
|
|
1338
|
+
await this.savePenalty(identifier, penalty);
|
|
1339
|
+
this.telemetry?.onViolation?.(identifier, penalty, severity);
|
|
1340
|
+
if (penaltyTriggered) {
|
|
1341
|
+
this.telemetry?.onPenaltyApplied?.(identifier, penalty);
|
|
1342
|
+
}
|
|
1343
|
+
return penalty;
|
|
1344
|
+
}
|
|
1345
|
+
async applyPenalty(identifier, duration) {
|
|
1346
|
+
const penalty = await this.getPenalty(identifier);
|
|
1347
|
+
penalty.penaltyUntil = Math.floor(Date.now() / 1e3) + (duration ?? this.config.duration);
|
|
1348
|
+
await this.savePenalty(identifier, penalty);
|
|
1349
|
+
this.telemetry?.onPenaltyApplied?.(identifier, penalty);
|
|
1350
|
+
return penalty;
|
|
1351
|
+
}
|
|
1352
|
+
async clearPenalty(identifier) {
|
|
1353
|
+
const key = this.getPenaltyKey(identifier);
|
|
1354
|
+
await this.store.delete(key);
|
|
1355
|
+
}
|
|
1356
|
+
getRateLimitMultiplier(penalty) {
|
|
1357
|
+
if (this.isActive(penalty)) {
|
|
1358
|
+
return this.config.multiplier;
|
|
1359
|
+
}
|
|
1360
|
+
return 1;
|
|
1361
|
+
}
|
|
1362
|
+
isActive(penalty) {
|
|
1363
|
+
if (!penalty.penaltyUntil) {
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
return Math.floor(Date.now() / 1e3) < penalty.penaltyUntil;
|
|
1367
|
+
}
|
|
1368
|
+
timeRemaining(penalty) {
|
|
1369
|
+
if (!this.isActive(penalty)) {
|
|
1370
|
+
return 0;
|
|
1371
|
+
}
|
|
1372
|
+
return (penalty.penaltyUntil || 0) - Math.floor(Date.now() / 1e3);
|
|
1373
|
+
}
|
|
1374
|
+
async savePenalty(identifier, penalty) {
|
|
1375
|
+
const key = this.getPenaltyKey(identifier);
|
|
1376
|
+
const ttl = 7 * 24 * 3600;
|
|
1377
|
+
await this.store.set(key, penalty, ttl);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
var PENALTY_LENIENT = {
|
|
1381
|
+
threshold: 20,
|
|
1382
|
+
duration: 1800,
|
|
1383
|
+
// 30 minutes
|
|
1384
|
+
multiplier: 0.75,
|
|
1385
|
+
decayRate: 2
|
|
1386
|
+
};
|
|
1387
|
+
var PENALTY_MODERATE = {
|
|
1388
|
+
threshold: 10,
|
|
1389
|
+
duration: 3600,
|
|
1390
|
+
// 1 hour
|
|
1391
|
+
multiplier: 0.5,
|
|
1392
|
+
decayRate: 1
|
|
1393
|
+
};
|
|
1394
|
+
var PENALTY_STRICT = {
|
|
1395
|
+
threshold: 5,
|
|
1396
|
+
duration: 7200,
|
|
1397
|
+
// 2 hours
|
|
1398
|
+
multiplier: 0.25,
|
|
1399
|
+
decayRate: 0.5
|
|
1400
|
+
};
|
|
1401
|
+
|
|
1402
|
+
// src/core/telemetry.ts
|
|
1403
|
+
var LoggingTelemetry = class {
|
|
1404
|
+
constructor(logger = console) {
|
|
1405
|
+
this.logger = logger;
|
|
1406
|
+
}
|
|
1407
|
+
onCheck(key, decision, metadata) {
|
|
1408
|
+
this.logger.debug(
|
|
1409
|
+
`Rate limit check: key=${key}, allowed=${decision.allowed}, remaining=${decision.remaining}, metadata=${JSON.stringify(metadata)}`
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
onAllowed(key, decision, _metadata) {
|
|
1413
|
+
this.logger.info(`Request allowed: key=${key}, remaining=${decision.remaining}`);
|
|
1414
|
+
}
|
|
1415
|
+
onBlocked(key, decision, metadata) {
|
|
1416
|
+
this.logger.warn(
|
|
1417
|
+
`Request blocked: key=${key}, retry_after=${decision.retryAfter}s, metadata=${JSON.stringify(metadata)}`
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
onQuotaCheck(identifier, quota, allowed) {
|
|
1421
|
+
this.logger.debug(
|
|
1422
|
+
`Quota check: identifier=${identifier}, quota=${quota.name}, allowed=${allowed}, remaining=${quota.limit - (quota.currentUsage || 0)}`
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
onQuotaExceeded(identifier, quota) {
|
|
1426
|
+
this.logger.warn(
|
|
1427
|
+
`Quota exceeded: identifier=${identifier}, quota=${quota.name}, limit=${quota.limit}, reset_at=${quota.resetAt}`
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
onPenaltyApplied(identifier, penalty) {
|
|
1431
|
+
this.logger.warn(
|
|
1432
|
+
`Penalty applied: identifier=${identifier}, abuse_score=${penalty.abuseScore}, penalty_until=${penalty.penaltyUntil}`
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
onViolation(identifier, penalty, severity) {
|
|
1436
|
+
this.logger.info(
|
|
1437
|
+
`Violation recorded: identifier=${identifier}, severity=${severity}, abuse_score=${penalty.abuseScore}, violations=${penalty.violations}`
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
var MetricsTelemetry = class {
|
|
1442
|
+
constructor(metricsClient) {
|
|
1443
|
+
this.metricsClient = metricsClient;
|
|
1444
|
+
}
|
|
1445
|
+
onCheck(_key, _decision, metadata) {
|
|
1446
|
+
this.metricsClient.increment("halt.checks.total", this.getTags(metadata));
|
|
1447
|
+
}
|
|
1448
|
+
onAllowed(_key, decision, metadata) {
|
|
1449
|
+
this.metricsClient.increment("halt.requests.allowed", this.getTags(metadata));
|
|
1450
|
+
this.metricsClient.gauge("halt.remaining", decision.remaining, this.getTags(metadata));
|
|
1451
|
+
}
|
|
1452
|
+
onBlocked(_key, _decision, metadata) {
|
|
1453
|
+
this.metricsClient.increment("halt.requests.blocked", this.getTags(metadata));
|
|
1454
|
+
}
|
|
1455
|
+
onQuotaCheck(_identifier, quota, _allowed) {
|
|
1456
|
+
const tags = { quota: quota.name, period: quota.period };
|
|
1457
|
+
this.metricsClient.increment("halt.quota.checks", tags);
|
|
1458
|
+
this.metricsClient.gauge(
|
|
1459
|
+
"halt.quota.remaining",
|
|
1460
|
+
quota.limit - (quota.currentUsage || 0),
|
|
1461
|
+
tags
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
onQuotaExceeded(_identifier, quota) {
|
|
1465
|
+
const tags = { quota: quota.name, period: quota.period };
|
|
1466
|
+
this.metricsClient.increment("halt.quota.exceeded", tags);
|
|
1467
|
+
}
|
|
1468
|
+
onPenaltyApplied(_identifier, penalty) {
|
|
1469
|
+
this.metricsClient.increment("halt.penalties.applied");
|
|
1470
|
+
this.metricsClient.gauge("halt.penalties.abuse_score", penalty.abuseScore);
|
|
1471
|
+
}
|
|
1472
|
+
onViolation(_identifier, _penalty, severity) {
|
|
1473
|
+
this.metricsClient.increment("halt.violations.total");
|
|
1474
|
+
this.metricsClient.histogram("halt.violations.severity", severity);
|
|
1475
|
+
}
|
|
1476
|
+
getTags(metadata) {
|
|
1477
|
+
if (!metadata) {
|
|
1478
|
+
return {};
|
|
1479
|
+
}
|
|
1480
|
+
return {
|
|
1481
|
+
policy: metadata.policy,
|
|
1482
|
+
algorithm: metadata.algorithm
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
var CompositeTelemetry = class {
|
|
1487
|
+
constructor(hooks) {
|
|
1488
|
+
this.hooks = hooks;
|
|
1489
|
+
}
|
|
1490
|
+
onCheck(key, decision, metadata) {
|
|
1491
|
+
this.hooks.forEach((hook) => hook.onCheck?.(key, decision, metadata));
|
|
1492
|
+
}
|
|
1493
|
+
onAllowed(key, decision, metadata) {
|
|
1494
|
+
this.hooks.forEach((hook) => hook.onAllowed?.(key, decision, metadata));
|
|
1495
|
+
}
|
|
1496
|
+
onBlocked(key, decision, metadata) {
|
|
1497
|
+
this.hooks.forEach((hook) => hook.onBlocked?.(key, decision, metadata));
|
|
1498
|
+
}
|
|
1499
|
+
onQuotaCheck(identifier, quota, allowed) {
|
|
1500
|
+
this.hooks.forEach((hook) => hook.onQuotaCheck?.(identifier, quota, allowed));
|
|
1501
|
+
}
|
|
1502
|
+
onQuotaExceeded(identifier, quota) {
|
|
1503
|
+
this.hooks.forEach((hook) => hook.onQuotaExceeded?.(identifier, quota));
|
|
1504
|
+
}
|
|
1505
|
+
onPenaltyApplied(identifier, penalty) {
|
|
1506
|
+
this.hooks.forEach((hook) => hook.onPenaltyApplied?.(identifier, penalty));
|
|
1507
|
+
}
|
|
1508
|
+
onViolation(identifier, penalty, severity) {
|
|
1509
|
+
this.hooks.forEach((hook) => hook.onViolation?.(identifier, penalty, severity));
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// src/core/stats.ts
|
|
1514
|
+
var StatsCollector = class {
|
|
1515
|
+
constructor(options = {}) {
|
|
1516
|
+
this.allowedTotal = 0;
|
|
1517
|
+
this.blockedTotal = 0;
|
|
1518
|
+
this.byPolicy = /* @__PURE__ */ new Map();
|
|
1519
|
+
this.byEndpoint = /* @__PURE__ */ new Map();
|
|
1520
|
+
this.limitedKeys = /* @__PURE__ */ new Map();
|
|
1521
|
+
this.costByPlan = /* @__PURE__ */ new Map();
|
|
1522
|
+
this.quotaExceededCount = 0;
|
|
1523
|
+
this.penaltiesAppliedCount = 0;
|
|
1524
|
+
this.violationsCount = 0;
|
|
1525
|
+
this.topN = options.topN ?? 20;
|
|
1526
|
+
this.maxTrackedKeys = options.maxTrackedKeys ?? 1e4;
|
|
1527
|
+
}
|
|
1528
|
+
onAllowed(_key, _decision, metadata) {
|
|
1529
|
+
this.allowedTotal++;
|
|
1530
|
+
const cost = Number(metadata?.cost ?? 1);
|
|
1531
|
+
this.policyTally(metadata).allowed++;
|
|
1532
|
+
const endpoint = metadata?.endpoint;
|
|
1533
|
+
if (endpoint) {
|
|
1534
|
+
const e = this.endpointTally(endpoint);
|
|
1535
|
+
e.allowed++;
|
|
1536
|
+
e.cost += cost;
|
|
1537
|
+
}
|
|
1538
|
+
const plan = metadata?.plan ?? metadata?.policy ?? "unknown";
|
|
1539
|
+
this.costByPlan.set(plan, (this.costByPlan.get(plan) ?? 0) + cost);
|
|
1540
|
+
}
|
|
1541
|
+
onBlocked(key, _decision, metadata) {
|
|
1542
|
+
this.blockedTotal++;
|
|
1543
|
+
this.policyTally(metadata).blocked++;
|
|
1544
|
+
const endpoint = metadata?.endpoint;
|
|
1545
|
+
if (endpoint) {
|
|
1546
|
+
this.endpointTally(endpoint).blocked++;
|
|
1547
|
+
}
|
|
1548
|
+
if (key) this.trackLimitedKey(key);
|
|
1549
|
+
}
|
|
1550
|
+
onQuotaExceeded(_identifier, _quota) {
|
|
1551
|
+
this.quotaExceededCount++;
|
|
1552
|
+
}
|
|
1553
|
+
onPenaltyApplied(_identifier, _penalty) {
|
|
1554
|
+
this.penaltiesAppliedCount++;
|
|
1555
|
+
}
|
|
1556
|
+
onViolation(_identifier, _penalty, _severity) {
|
|
1557
|
+
this.violationsCount++;
|
|
1558
|
+
}
|
|
1559
|
+
/** Point-in-time view of the aggregated stats. */
|
|
1560
|
+
snapshot() {
|
|
1561
|
+
const topLimitedKeys = [...this.limitedKeys.entries()].sort((a, b) => b[1] - a[1]).slice(0, this.topN).map(([key, blocked]) => ({ key, blocked }));
|
|
1562
|
+
return {
|
|
1563
|
+
allowedTotal: this.allowedTotal,
|
|
1564
|
+
blockedTotal: this.blockedTotal,
|
|
1565
|
+
byPolicy: mapToObject(this.byPolicy),
|
|
1566
|
+
byEndpoint: mapToObject(this.byEndpoint),
|
|
1567
|
+
topLimitedKeys,
|
|
1568
|
+
costByPlan: mapToObject(this.costByPlan),
|
|
1569
|
+
quotaExceeded: this.quotaExceededCount,
|
|
1570
|
+
penaltiesApplied: this.penaltiesAppliedCount,
|
|
1571
|
+
violations: this.violationsCount,
|
|
1572
|
+
trackedKeys: this.limitedKeys.size
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
/** Clear all counters (e.g. after exporting). */
|
|
1576
|
+
reset() {
|
|
1577
|
+
this.allowedTotal = 0;
|
|
1578
|
+
this.blockedTotal = 0;
|
|
1579
|
+
this.byPolicy.clear();
|
|
1580
|
+
this.byEndpoint.clear();
|
|
1581
|
+
this.limitedKeys.clear();
|
|
1582
|
+
this.costByPlan.clear();
|
|
1583
|
+
this.quotaExceededCount = 0;
|
|
1584
|
+
this.penaltiesAppliedCount = 0;
|
|
1585
|
+
this.violationsCount = 0;
|
|
1586
|
+
}
|
|
1587
|
+
policyTally(metadata) {
|
|
1588
|
+
const policy = metadata?.policy ?? "unknown";
|
|
1589
|
+
let t = this.byPolicy.get(policy);
|
|
1590
|
+
if (!t) {
|
|
1591
|
+
t = { allowed: 0, blocked: 0 };
|
|
1592
|
+
this.byPolicy.set(policy, t);
|
|
1593
|
+
}
|
|
1594
|
+
return t;
|
|
1595
|
+
}
|
|
1596
|
+
endpointTally(endpoint) {
|
|
1597
|
+
let e = this.byEndpoint.get(endpoint);
|
|
1598
|
+
if (!e) {
|
|
1599
|
+
e = { allowed: 0, blocked: 0, cost: 0 };
|
|
1600
|
+
this.byEndpoint.set(endpoint, e);
|
|
1601
|
+
}
|
|
1602
|
+
return e;
|
|
1603
|
+
}
|
|
1604
|
+
trackLimitedKey(key) {
|
|
1605
|
+
const current = this.limitedKeys.get(key);
|
|
1606
|
+
if (current !== void 0) {
|
|
1607
|
+
this.limitedKeys.set(key, current + 1);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
if (this.limitedKeys.size >= this.maxTrackedKeys) {
|
|
1611
|
+
this.evictSmallest();
|
|
1612
|
+
}
|
|
1613
|
+
this.limitedKeys.set(key, 1);
|
|
1614
|
+
}
|
|
1615
|
+
/** Evict the lowest-count tracked key to keep memory bounded. */
|
|
1616
|
+
evictSmallest() {
|
|
1617
|
+
let minKey;
|
|
1618
|
+
let minVal = Infinity;
|
|
1619
|
+
for (const [k, v] of this.limitedKeys) {
|
|
1620
|
+
if (v < minVal) {
|
|
1621
|
+
minVal = v;
|
|
1622
|
+
minKey = k;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
if (minKey !== void 0) this.limitedKeys.delete(minKey);
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
function mapToObject(map) {
|
|
1629
|
+
const out = {};
|
|
1630
|
+
for (const [k, v] of map) out[k] = v;
|
|
1631
|
+
return out;
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// src/observability/otel.ts
|
|
1635
|
+
var OpenTelemetryMetrics = class {
|
|
1636
|
+
constructor(meter) {
|
|
1637
|
+
this.requests = meter.createCounter("halt.requests", {
|
|
1638
|
+
description: "Total rate-limit checks"
|
|
1639
|
+
});
|
|
1640
|
+
this.blocked = meter.createCounter("halt.blocked", {
|
|
1641
|
+
description: "Rate-limited (blocked) requests"
|
|
1642
|
+
});
|
|
1643
|
+
this.cost = meter.createCounter("halt.cost", {
|
|
1644
|
+
description: "Consumed request cost (weighted endpoints)"
|
|
1645
|
+
});
|
|
1646
|
+
this.quotaExceededCounter = meter.createCounter("halt.quota.exceeded", {
|
|
1647
|
+
description: "Quota-exceeded events"
|
|
1648
|
+
});
|
|
1649
|
+
this.penaltyAppliedCounter = meter.createCounter("halt.penalty.applied", {
|
|
1650
|
+
description: "Penalties applied (abuse controls)"
|
|
1651
|
+
});
|
|
1652
|
+
this.violationsCounter = meter.createCounter("halt.violations", {
|
|
1653
|
+
description: "Recorded abuse violations"
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
onAllowed(_key, _decision, metadata) {
|
|
1657
|
+
const policy = String(metadata?.policy ?? "unknown");
|
|
1658
|
+
this.requests.add(1, { policy, allowed: "true" });
|
|
1659
|
+
const endpoint = metadata?.endpoint;
|
|
1660
|
+
const plan = String(metadata?.plan ?? metadata?.policy ?? "unknown");
|
|
1661
|
+
const cost = Number(metadata?.cost ?? 1);
|
|
1662
|
+
this.cost.add(cost, endpoint ? { endpoint: String(endpoint), plan } : { plan });
|
|
1663
|
+
}
|
|
1664
|
+
onBlocked(_key, _decision, metadata) {
|
|
1665
|
+
const policy = String(metadata?.policy ?? "unknown");
|
|
1666
|
+
this.requests.add(1, { policy, allowed: "false" });
|
|
1667
|
+
const endpoint = metadata?.endpoint;
|
|
1668
|
+
this.blocked.add(1, endpoint ? { policy, endpoint: String(endpoint) } : { policy });
|
|
1669
|
+
}
|
|
1670
|
+
onQuotaExceeded(_identifier, quota) {
|
|
1671
|
+
this.quotaExceededCounter.add(1, { quota: quota.name });
|
|
1672
|
+
}
|
|
1673
|
+
onPenaltyApplied(_identifier, _penalty) {
|
|
1674
|
+
this.penaltyAppliedCounter.add(1);
|
|
1675
|
+
}
|
|
1676
|
+
onViolation(_identifier, _penalty, severity) {
|
|
1677
|
+
this.violationsCounter.add(1, { severity });
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1071
1681
|
exports.Algorithm = Algorithm;
|
|
1682
|
+
exports.CompositeTelemetry = CompositeTelemetry;
|
|
1072
1683
|
exports.InMemoryStore = InMemoryStore;
|
|
1073
1684
|
exports.KeyStrategy = KeyStrategy;
|
|
1685
|
+
exports.LoggingTelemetry = LoggingTelemetry;
|
|
1686
|
+
exports.MetricsTelemetry = MetricsTelemetry;
|
|
1687
|
+
exports.OpenTelemetryMetrics = OpenTelemetryMetrics;
|
|
1688
|
+
exports.PENALTY_LENIENT = PENALTY_LENIENT;
|
|
1689
|
+
exports.PENALTY_MODERATE = PENALTY_MODERATE;
|
|
1690
|
+
exports.PENALTY_STRICT = PENALTY_STRICT;
|
|
1691
|
+
exports.PenaltyManager = PenaltyManager;
|
|
1692
|
+
exports.PolicyRegistry = PolicyRegistry;
|
|
1693
|
+
exports.QUOTA_ENTERPRISE_MONTHLY = QUOTA_ENTERPRISE_MONTHLY;
|
|
1694
|
+
exports.QUOTA_FREE_DAILY = QUOTA_FREE_DAILY;
|
|
1695
|
+
exports.QUOTA_FREE_MONTHLY = QUOTA_FREE_MONTHLY;
|
|
1696
|
+
exports.QUOTA_PRO_DAILY = QUOTA_PRO_DAILY;
|
|
1697
|
+
exports.QUOTA_PRO_MONTHLY = QUOTA_PRO_MONTHLY;
|
|
1698
|
+
exports.QuotaManager = QuotaManager;
|
|
1699
|
+
exports.QuotaPeriod = QuotaPeriod;
|
|
1074
1700
|
exports.RateLimiter = RateLimiter;
|
|
1075
1701
|
exports.RedisStore = RedisStore;
|
|
1702
|
+
exports.StatsCollector = StatsCollector;
|
|
1703
|
+
exports.cachedPolicyResolver = cachedPolicyResolver;
|
|
1076
1704
|
exports.extractors = extractors_exports;
|
|
1077
1705
|
exports.isAtomicStore = isAtomicStore;
|
|
1078
1706
|
exports.normalizePolicy = normalizePolicy;
|