lambder 4.0.1 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Readme.md +99 -55
- package/dist/client/LambderCaller.d.ts +2 -0
- package/dist/client/LambderCaller.js +8 -4
- package/dist/core/Lambder.d.ts +100 -86
- package/dist/core/Lambder.js +100 -87
- package/dist/core/LambderCors.d.ts +6 -0
- package/dist/core/LambderCors.js +5 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -0
- package/dist/policies/LambderApiGuards.d.ts +17 -6
- package/dist/policies/LambderApiGuards.js +13 -9
- package/dist/policies/LambderApiIdempotency.d.ts +1 -1
- package/dist/policies/LambderApiIdempotency.js +8 -4
- package/dist/policies/LambderApiPolicies.d.ts +6 -4
- package/dist/policies/LambderApiPolicies.js +9 -4
- package/dist/policies/LambderApiRateLimits.d.ts +63 -20
- package/dist/policies/LambderApiRateLimits.js +54 -15
- package/dist/session/LambderSessionController.d.ts +1 -1
- package/dist/session/LambderSessionController.js +1 -1
- package/dist/session/LambderSessionManager.js +1 -1
- package/dist/shared/LambderApiError.d.ts +5 -0
- package/dist/shared/LambderApiError.js +3 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +45 -14
- package/dist/stores/LambderDdbRateLimiter.js +19 -10
- package/package.json +1 -1
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
2
|
import type { LambderRenderContext } from "../core/LambderContext.js";
|
|
3
3
|
import type LambderResolver from "../core/LambderResolver.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type LambderRateLimitPolicy, type LambderDdbRateLimiter } from "../stores/LambderDdbRateLimiter.js";
|
|
5
|
+
import { type LambderRefusalMessage } from "../shared/LambderApiError.js";
|
|
6
|
+
import { type LambderInputValidationRefusal } from "./LambderApiGuards.js";
|
|
5
7
|
/**
|
|
6
8
|
* A custom rate-limit key. `apiInput` names the fields of the API's OWN
|
|
7
9
|
* payload the key derives from: the slice is validated against the raw
|
|
8
|
-
* payload before `handler` runs (failures answer
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* payload before `handler` runs (failures answer like regular input
|
|
11
|
+
* validation, through setApiInputValidationErrorHandler when set) and the
|
|
12
|
+
* handler receives it typed. Referencing the policy from an API whose input
|
|
13
|
+
* schema does not carry those fields is a compile error, so the API's schema
|
|
14
|
+
* stays the single owner of the field. Build with lambderRateLimitKey() so
|
|
15
|
+
* the handler's payload type follows `apiInput`.
|
|
13
16
|
*/
|
|
14
17
|
export type LambderRateLimitKeyFn<TInput extends z.ZodTypeAny = z.ZodTypeAny> = {
|
|
15
18
|
apiInput: TInput;
|
|
@@ -38,19 +41,27 @@ export declare function lambderRateLimitKey(key: {
|
|
|
38
41
|
};
|
|
39
42
|
/** What one rate-limit counter tracks: the client IP, the session identity, or a custom payload-derived key. */
|
|
40
43
|
export type LambderRateLimitPer = "ip" | "session" | LambderRateLimitKeyFn<any>;
|
|
41
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* What one budget spans. Required on every policy, so a declaration always
|
|
46
|
+
* says what its numbers mean:
|
|
47
|
+
*
|
|
48
|
+
* - "perApi": every API referencing the policy gets its own counter, so the
|
|
49
|
+
* windows are a per-API ceiling (three APIs referencing a 60/min policy
|
|
50
|
+
* allow one subject 180/min in total). An API may tune the windows in its
|
|
51
|
+
* declaration: `rateLimit: { name: { perMin: 20 } }`.
|
|
52
|
+
* - "perPolicy": every API referencing the policy shares ONE counter, so the
|
|
53
|
+
* windows are one combined budget (e.g. one per-email allowance across
|
|
54
|
+
* send, register, and reset). The policy IS the group: to give user APIs
|
|
55
|
+
* and report APIs separate shared budgets, declare two policies.
|
|
56
|
+
*/
|
|
57
|
+
export type LambderRateLimitBudget = "perApi" | "perPolicy";
|
|
58
|
+
/** A named rate-limit policy: fixed windows, the key one counter tracks, and what one budget spans. */
|
|
42
59
|
export type LambderApiRateLimitPolicyConfig = LambderRateLimitPolicy & {
|
|
43
60
|
per: LambderRateLimitPer;
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
* windows are one combined budget (e.g. one per-email allowance across
|
|
49
|
-
* send, register, and reset endpoints).
|
|
50
|
-
*/
|
|
51
|
-
scope?: "api" | "policy";
|
|
52
|
-
/** Envelope errorMessage for refused requests. Default: a generic too-many-requests message. */
|
|
53
|
-
errorMessage?: any;
|
|
61
|
+
/** Whether the windows are a per-API ceiling or one budget shared by every referencing API. See LambderRateLimitBudget. */
|
|
62
|
+
budget: LambderRateLimitBudget;
|
|
63
|
+
/** Envelope errorMessage for refused requests. Default: a warning saying too many requests. */
|
|
64
|
+
errorMessage?: LambderRefusalMessage;
|
|
54
65
|
};
|
|
55
66
|
export type LambderApiRateLimitsConfig<TPolicies extends Record<string, LambderApiRateLimitPolicyConfig>> = {
|
|
56
67
|
/** Your limiter instance; its table, keyPrefix and failOpen apply as configured on it. */
|
|
@@ -72,6 +83,29 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
|
|
|
72
83
|
};
|
|
73
84
|
} ? (TPayload extends z.output<S> ? K : never) : K;
|
|
74
85
|
}[keyof TPolicies] & string;
|
|
86
|
+
/**
|
|
87
|
+
* What an API may override on a policy it references, in the map form of the
|
|
88
|
+
* rateLimit option. Windows merge over the policy's own (a tighter burst keeps
|
|
89
|
+
* the policy's daily cap) and are only overridable on "perApi" budgets: a
|
|
90
|
+
* shared counter has one set of numbers. errorMessage is per-API text, so it
|
|
91
|
+
* is overridable on either budget.
|
|
92
|
+
*/
|
|
93
|
+
export type LambderRateLimitOverride = LambderRateLimitPolicy & {
|
|
94
|
+
errorMessage?: LambderRefusalMessage;
|
|
95
|
+
};
|
|
96
|
+
type LambderRateLimitOverrideFor<TPolicy> = TPolicy extends {
|
|
97
|
+
budget: "perApi";
|
|
98
|
+
} ? LambderRateLimitOverride : Pick<LambderRateLimitOverride, "errorMessage">;
|
|
99
|
+
/**
|
|
100
|
+
* The per-API `rateLimit` option: one policy name, an ordered list of names,
|
|
101
|
+
* or an object map that can carry each policy's override (`true` applies the
|
|
102
|
+
* policy as declared). Map entries are checked in insertion order.
|
|
103
|
+
*/
|
|
104
|
+
export type LambderRateLimitOption<TPolicies, TPayload, TIncludeSession extends boolean> = LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> | readonly LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession>[] | {
|
|
105
|
+
readonly [K in LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession> & keyof TPolicies]?: true | LambderRateLimitOverrideFor<TPolicies[K]>;
|
|
106
|
+
};
|
|
107
|
+
/** The rateLimit option's runtime shape: a name, ordered names, or a name-to-override map (LambderRateLimitOption narrows the names and overrides per policy). */
|
|
108
|
+
export type LambderRateLimitOptionValue = string | readonly string[] | Readonly<Record<string, true | LambderRateLimitOverride | undefined>>;
|
|
75
109
|
/**
|
|
76
110
|
* Runtime side of the rate-limit subsystem: holds the limiter and its named
|
|
77
111
|
* policies, asserts API registrations against them at startup, and checks an
|
|
@@ -79,12 +113,21 @@ export type LambderAllowedPolicyNames<TPolicies, TPayload, TIncludeSession exten
|
|
|
79
113
|
* LambderApiPolicyEngine.
|
|
80
114
|
*/
|
|
81
115
|
export declare class LambderApiRateLimitsEngine {
|
|
116
|
+
private readonly onInvalidInput;
|
|
82
117
|
private limiter;
|
|
83
118
|
private policies;
|
|
119
|
+
constructor(onInvalidInput: LambderInputValidationRefusal);
|
|
84
120
|
configure(config: LambderApiRateLimitsConfig<Record<string, LambderApiRateLimitPolicyConfig>>): void;
|
|
85
121
|
/** Startup validation of one API registration's rateLimit option. */
|
|
86
|
-
assertRegistration(apiName: string, mode: "public" | "session", rateLimitOption?:
|
|
87
|
-
/**
|
|
88
|
-
|
|
122
|
+
assertRegistration(apiName: string, mode: "public" | "session", rateLimitOption?: LambderRateLimitOptionValue): void;
|
|
123
|
+
/**
|
|
124
|
+
* Check the API's policies in declared order; the first exceeded one
|
|
125
|
+
* refuses with a 429 envelope and a Retry-After header. Attempts count,
|
|
126
|
+
* not successes: every counter checked before the refusing one (and every
|
|
127
|
+
* counter, when a later guard or validation refuses) keeps its increment,
|
|
128
|
+
* so list first the policy you want charged on refusals.
|
|
129
|
+
*/
|
|
130
|
+
run(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, rateLimitOption?: LambderRateLimitOptionValue): Promise<void>;
|
|
89
131
|
private resolveKey;
|
|
90
132
|
}
|
|
133
|
+
export {};
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import { RATE_LIMIT_WINDOWS } from "../stores/LambderDdbRateLimiter.js";
|
|
1
2
|
import { LambderApiError } from "../shared/LambderApiError.js";
|
|
2
3
|
import { parsePreflightSlice } from "./LambderApiGuards.js";
|
|
3
|
-
const RATE_LIMIT_WINDOW_KEYS =
|
|
4
|
+
const RATE_LIMIT_WINDOW_KEYS = RATE_LIMIT_WINDOWS.map((window) => window.key);
|
|
5
|
+
/** Refusal a rate-limited request answers unless the policy or the API's override names its own. */
|
|
6
|
+
const DEFAULT_RATE_LIMIT_REFUSAL = { type: "warning", content: "Too many requests. Please try again later." };
|
|
4
7
|
export function lambderRateLimitKey(key) { return key; }
|
|
5
|
-
|
|
8
|
+
/** Normalize the three rateLimit-option forms into ordered entries; an explicit `undefined` map value declares nothing. */
|
|
9
|
+
const toRateLimitEntries = (value) => {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return [];
|
|
12
|
+
if (typeof value === "string")
|
|
13
|
+
return [{ name: value }];
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return value.map((name) => ({ name }));
|
|
16
|
+
return Object.entries(value).flatMap(([name, override]) => override === undefined ? [] : override === true ? [{ name }] : [{ name, override }]);
|
|
17
|
+
};
|
|
18
|
+
const hasWindowOverride = (override) => RATE_LIMIT_WINDOW_KEYS.some((key) => override[key] !== undefined);
|
|
6
19
|
/**
|
|
7
20
|
* Runtime side of the rate-limit subsystem: holds the limiter and its named
|
|
8
21
|
* policies, asserts API registrations against them at startup, and checks an
|
|
@@ -10,11 +23,15 @@ const toList = (value) => value === undefined ? [] : typeof value === "string" ?
|
|
|
10
23
|
* LambderApiPolicyEngine.
|
|
11
24
|
*/
|
|
12
25
|
export class LambderApiRateLimitsEngine {
|
|
26
|
+
onInvalidInput;
|
|
13
27
|
limiter = null;
|
|
14
28
|
policies = {};
|
|
29
|
+
constructor(onInvalidInput) {
|
|
30
|
+
this.onInvalidInput = onInvalidInput;
|
|
31
|
+
}
|
|
15
32
|
configure(config) {
|
|
16
33
|
if (this.limiter)
|
|
17
|
-
throw new Error("Lambder:
|
|
34
|
+
throw new Error("Lambder: rateLimits were already configured.");
|
|
18
35
|
for (const [name, policy] of Object.entries(config.policies)) {
|
|
19
36
|
const per = policy.per;
|
|
20
37
|
if (!per || (per !== "ip" && per !== "session" && typeof per.handler !== "function")) {
|
|
@@ -23,39 +40,61 @@ export class LambderApiRateLimitsEngine {
|
|
|
23
40
|
if (!RATE_LIMIT_WINDOW_KEYS.some((key) => policy[key])) {
|
|
24
41
|
throw new Error(`Lambder: rate-limit policy "${name}" declares no window (${RATE_LIMIT_WINDOW_KEYS.join("/")}).`);
|
|
25
42
|
}
|
|
43
|
+
const budget = policy.budget;
|
|
44
|
+
if (budget !== "perApi" && budget !== "perPolicy") {
|
|
45
|
+
throw new Error(`Lambder: rate-limit policy "${name}" needs budget: "perApi" (each referencing API counts separately) or "perPolicy" (one counter shared by every referencing API).`);
|
|
46
|
+
}
|
|
26
47
|
}
|
|
27
48
|
this.limiter = config.limiter;
|
|
28
49
|
this.policies = { ...config.policies };
|
|
29
50
|
}
|
|
30
51
|
/** Startup validation of one API registration's rateLimit option. */
|
|
31
52
|
assertRegistration(apiName, mode, rateLimitOption) {
|
|
32
|
-
for (const name of
|
|
53
|
+
for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
|
|
33
54
|
const policy = this.policies[name];
|
|
34
55
|
if (!policy) {
|
|
35
|
-
throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it
|
|
56
|
+
throw new Error(`Lambder: API "${apiName}" references unknown rate-limit policy "${name}". Declare it in the rateLimits option at creation.`);
|
|
36
57
|
}
|
|
37
58
|
if (policy.per === "session" && mode !== "session") {
|
|
38
59
|
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which requires addSessionApi.`);
|
|
39
60
|
}
|
|
61
|
+
if (override && policy.budget === "perPolicy" && hasWindowOverride(override)) {
|
|
62
|
+
throw new Error(`Lambder: API "${apiName}" overrides the windows of rate-limit policy "${name}", whose budget is "perPolicy": one counter shared by every referencing API has one set of limits. Declare a separate policy instead.`);
|
|
63
|
+
}
|
|
40
64
|
}
|
|
41
65
|
}
|
|
42
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Check the API's policies in declared order; the first exceeded one
|
|
68
|
+
* refuses with a 429 envelope and a Retry-After header. Attempts count,
|
|
69
|
+
* not successes: every counter checked before the refusing one (and every
|
|
70
|
+
* counter, when a later guard or validation refuses) keeps its increment,
|
|
71
|
+
* so list first the policy you want charged on refusals.
|
|
72
|
+
*/
|
|
43
73
|
async run(apiName, ctx, resolver, rateLimitOption) {
|
|
44
|
-
for (const name of
|
|
74
|
+
for (const { name, override } of toRateLimitEntries(rateLimitOption)) {
|
|
45
75
|
const policy = this.policies[name];
|
|
46
76
|
if (!policy || !this.limiter)
|
|
47
77
|
throw new Error(`Lambder: rate-limit policy "${name}" is not configured.`);
|
|
48
78
|
const key = await this.resolveKey(ctx, resolver, policy.per);
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
79
|
+
// "perPolicy" shares one counter across every API referencing the
|
|
80
|
+
// policy; "perApi" keys each API separately, which is also what
|
|
81
|
+
// lets an API override the windows without colliding.
|
|
82
|
+
const trackerKey = policy.budget === "perPolicy"
|
|
52
83
|
? `policy|${name}|${key}`
|
|
53
84
|
: `api|${apiName}|${name}|${key}`;
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
const limits = {};
|
|
86
|
+
for (const windowKey of RATE_LIMIT_WINDOW_KEYS) {
|
|
87
|
+
const limit = override?.[windowKey] ?? policy[windowKey];
|
|
88
|
+
if (limit !== undefined)
|
|
89
|
+
limits[windowKey] = limit;
|
|
90
|
+
}
|
|
91
|
+
const exceeded = await this.limiter.isRateLimited(trackerKey, limits);
|
|
92
|
+
if (exceeded) {
|
|
93
|
+
const retryAfterSeconds = Math.max(1, exceeded.resetAt - Math.floor(Date.now() / 1000));
|
|
94
|
+
throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}" (${exceeded.window}: ${exceeded.limit}).`, {
|
|
95
|
+
errorMessage: override?.errorMessage ?? policy.errorMessage ?? DEFAULT_RATE_LIMIT_REFUSAL,
|
|
58
96
|
statusCode: 429,
|
|
97
|
+
headers: { "Retry-After": String(retryAfterSeconds) },
|
|
59
98
|
});
|
|
60
99
|
}
|
|
61
100
|
}
|
|
@@ -70,7 +109,7 @@ export class LambderApiRateLimitsEngine {
|
|
|
70
109
|
return `session:${sessionKey}`;
|
|
71
110
|
}
|
|
72
111
|
const payload = per.apiInput
|
|
73
|
-
? parsePreflightSlice(per.apiInput, ctx.post?.payload, resolver)
|
|
112
|
+
? await parsePreflightSlice(per.apiInput, ctx.post?.payload, ctx, resolver, this.onInvalidInput)
|
|
74
113
|
: undefined;
|
|
75
114
|
return `custom:${await per.handler(ctx, payload)}`;
|
|
76
115
|
}
|
|
@@ -37,7 +37,7 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
37
37
|
isSessionValid(session: any): boolean;
|
|
38
38
|
updateSessionData(newData: any): Promise<LambderSessionContext>;
|
|
39
39
|
/**
|
|
40
|
-
* Force-runs the dataRefresh callback now (see
|
|
40
|
+
* Force-runs the dataRefresh callback now (see the session option of create) and
|
|
41
41
|
* persists the result onto the current session. Returns the updated
|
|
42
42
|
* session, or null when the callback ended it: the record is deleted and
|
|
43
43
|
* the session cookies are cleared.
|
|
@@ -124,7 +124,7 @@ export default class LambderSessionController {
|
|
|
124
124
|
}
|
|
125
125
|
;
|
|
126
126
|
/**
|
|
127
|
-
* Force-runs the dataRefresh callback now (see
|
|
127
|
+
* Force-runs the dataRefresh callback now (see the session option of create) and
|
|
128
128
|
* persists the result onto the current session. Returns the updated
|
|
129
129
|
* session, or null when the callback ended it: the record is deleted and
|
|
130
130
|
* the session cookies are cleared.
|
|
@@ -230,7 +230,7 @@ export default class LambderSessionManager {
|
|
|
230
230
|
*/
|
|
231
231
|
async refreshSessionData(session) {
|
|
232
232
|
if (!this.dataRefresh)
|
|
233
|
-
throw new Error("dataRefresh is not configured. Pass dataRefresh
|
|
233
|
+
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
234
234
|
if (!session)
|
|
235
235
|
throw new Error("Invalid session");
|
|
236
236
|
let newData;
|
|
@@ -18,6 +18,8 @@ export type LambderApiErrorOptions = {
|
|
|
18
18
|
* 422 (reserved for input validation).
|
|
19
19
|
*/
|
|
20
20
|
statusCode?: HttpStatusCode;
|
|
21
|
+
/** Extra response headers on the refusal (e.g. Retry-After on a rate limit). */
|
|
22
|
+
headers?: Record<string, string>;
|
|
21
23
|
/** Underlying cause, preserved on the standard Error `cause` property. */
|
|
22
24
|
cause?: unknown;
|
|
23
25
|
};
|
|
@@ -48,6 +50,7 @@ export declare class LambderApiError extends Error {
|
|
|
48
50
|
readonly notAuthorized?: boolean;
|
|
49
51
|
readonly sessionExpired?: boolean;
|
|
50
52
|
readonly statusCode?: HttpStatusCode;
|
|
53
|
+
readonly headers?: Record<string, string>;
|
|
51
54
|
constructor(message: string, options?: LambderApiErrorOptions);
|
|
52
55
|
}
|
|
53
56
|
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
@@ -73,6 +76,8 @@ export type LambderRefuseOptions = {
|
|
|
73
76
|
sessionExpired?: boolean;
|
|
74
77
|
/** HTTP status of the refusal. Default 200; avoid 5xx (caller treats as crash) and 422 (reserved for validation). */
|
|
75
78
|
statusCode?: HttpStatusCode;
|
|
79
|
+
/** Extra response headers on the refusal (e.g. Retry-After). */
|
|
80
|
+
headers?: Record<string, string>;
|
|
76
81
|
/** Underlying cause, preserved on the Error cause property. */
|
|
77
82
|
cause?: unknown;
|
|
78
83
|
};
|
|
@@ -25,6 +25,7 @@ export class LambderApiError extends Error {
|
|
|
25
25
|
notAuthorized;
|
|
26
26
|
sessionExpired;
|
|
27
27
|
statusCode;
|
|
28
|
+
headers;
|
|
28
29
|
constructor(message, options = {}) {
|
|
29
30
|
super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
|
|
30
31
|
this.name = "LambderApiError";
|
|
@@ -32,6 +33,7 @@ export class LambderApiError extends Error {
|
|
|
32
33
|
this.notAuthorized = options.notAuthorized;
|
|
33
34
|
this.sessionExpired = options.sessionExpired;
|
|
34
35
|
this.statusCode = options.statusCode;
|
|
36
|
+
this.headers = options.headers;
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
/** Brand-based type guard (see LambderApiError.isLambderApiError). */
|
|
@@ -58,6 +60,7 @@ export const refuse = (content, options = {}) => {
|
|
|
58
60
|
notAuthorized: options.notAuthorized,
|
|
59
61
|
sessionExpired: options.sessionExpired,
|
|
60
62
|
statusCode: options.statusCode,
|
|
63
|
+
headers: options.headers,
|
|
61
64
|
cause: options.cause,
|
|
62
65
|
});
|
|
63
66
|
};
|
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The fixed windows a policy may cap, smallest first (the evaluation order),
|
|
4
|
+
* with their length. The policy type derives from this table, so the two can
|
|
5
|
+
* never drift.
|
|
6
|
+
*/
|
|
7
|
+
export declare const RATE_LIMIT_WINDOWS: readonly [{
|
|
8
|
+
readonly key: "perMin";
|
|
9
|
+
readonly seconds: 60;
|
|
10
|
+
}, {
|
|
11
|
+
readonly key: "per10Min";
|
|
12
|
+
readonly seconds: number;
|
|
13
|
+
}, {
|
|
14
|
+
readonly key: "perHour";
|
|
15
|
+
readonly seconds: number;
|
|
16
|
+
}, {
|
|
17
|
+
readonly key: "perDay";
|
|
18
|
+
readonly seconds: number;
|
|
19
|
+
}, {
|
|
20
|
+
readonly key: "perWeek";
|
|
21
|
+
readonly seconds: number;
|
|
22
|
+
}, {
|
|
23
|
+
readonly key: "perMonth";
|
|
24
|
+
readonly seconds: number;
|
|
25
|
+
}];
|
|
26
|
+
export type LambderRateLimitWindow = (typeof RATE_LIMIT_WINDOWS)[number]["key"];
|
|
27
|
+
/** Per-window caps. A window that is absent or 0 is not enforced. */
|
|
28
|
+
export type LambderRateLimitPolicy = Partial<Record<LambderRateLimitWindow, number>>;
|
|
29
|
+
/**
|
|
30
|
+
* The window that refused: which one, its limit, and the epoch second at
|
|
31
|
+
* which that fixed window resets (Retry-After derives from it).
|
|
32
|
+
*/
|
|
33
|
+
export type LambderRateLimitExceeded = {
|
|
34
|
+
window: LambderRateLimitWindow;
|
|
35
|
+
limit: number;
|
|
36
|
+
resetAt: number;
|
|
37
|
+
};
|
|
38
|
+
/** `false` when allowed, otherwise the window whose limit was hit. */
|
|
39
|
+
export type LambderRateLimitResult = false | LambderRateLimitExceeded;
|
|
13
40
|
export interface LambderDdbRateLimiterOptions {
|
|
14
41
|
tableName: string;
|
|
15
42
|
region?: string;
|
|
@@ -27,8 +54,11 @@ export interface LambderDdbRateLimiterOptions {
|
|
|
27
54
|
* Each window is a single item counted with a conditional `ADD`, so the
|
|
28
55
|
* increment and the limit check happen atomically in one request. Windows are
|
|
29
56
|
* evaluated from smallest to largest and evaluation stops at the first
|
|
30
|
-
* exceeded window, which keeps blocked requests cheap and
|
|
31
|
-
*
|
|
57
|
+
* exceeded window, which keeps blocked requests cheap and spares the larger
|
|
58
|
+
* counters. Attempts count, not successes: a counter checked before the
|
|
59
|
+
* refusing one keeps its increment (there is no compensating decrement, which
|
|
60
|
+
* would give up the conditional-ADD atomicity). Items carry an `expiresAt`
|
|
61
|
+
* attribute for DynamoDB TTL.
|
|
32
62
|
*
|
|
33
63
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
|
|
34
64
|
* Items are prefixed `RL#` by default, so the table can be shared with
|
|
@@ -44,7 +74,8 @@ export declare class LambderDdbRateLimiter {
|
|
|
44
74
|
constructor(options: LambderDdbRateLimiterOptions);
|
|
45
75
|
/**
|
|
46
76
|
* Increment every configured window for `trackerKey` (IP, session, user id, ...)
|
|
47
|
-
* and report whether any of them is over its limit
|
|
77
|
+
* and report whether any of them is over its limit, with the window's
|
|
78
|
+
* reset time when so.
|
|
48
79
|
*/
|
|
49
80
|
isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
|
|
50
81
|
/** Increments one window counter. Returns true when the limit was already reached. */
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { DynamoDBClient, UpdateItemCommand, } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The fixed windows a policy may cap, smallest first (the evaluation order),
|
|
4
|
+
* with their length. The policy type derives from this table, so the two can
|
|
5
|
+
* never drift.
|
|
6
|
+
*/
|
|
7
|
+
export const RATE_LIMIT_WINDOWS = [
|
|
3
8
|
{ key: "perMin", seconds: 60 },
|
|
4
9
|
{ key: "per10Min", seconds: 10 * 60 },
|
|
5
10
|
{ key: "perHour", seconds: 60 * 60 },
|
|
@@ -13,8 +18,11 @@ const WINDOW_CONFIG = [
|
|
|
13
18
|
* Each window is a single item counted with a conditional `ADD`, so the
|
|
14
19
|
* increment and the limit check happen atomically in one request. Windows are
|
|
15
20
|
* evaluated from smallest to largest and evaluation stops at the first
|
|
16
|
-
* exceeded window, which keeps blocked requests cheap and
|
|
17
|
-
*
|
|
21
|
+
* exceeded window, which keeps blocked requests cheap and spares the larger
|
|
22
|
+
* counters. Attempts count, not successes: a counter checked before the
|
|
23
|
+
* refusing one keeps its increment (there is no compensating decrement, which
|
|
24
|
+
* would give up the conditional-ADD atomicity). Items carry an `expiresAt`
|
|
25
|
+
* attribute for DynamoDB TTL.
|
|
18
26
|
*
|
|
19
27
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
|
|
20
28
|
* Items are prefixed `RL#` by default, so the table can be shared with
|
|
@@ -41,23 +49,24 @@ export class LambderDdbRateLimiter {
|
|
|
41
49
|
}
|
|
42
50
|
/**
|
|
43
51
|
* Increment every configured window for `trackerKey` (IP, session, user id, ...)
|
|
44
|
-
* and report whether any of them is over its limit
|
|
52
|
+
* and report whether any of them is over its limit, with the window's
|
|
53
|
+
* reset time when so.
|
|
45
54
|
*/
|
|
46
55
|
async isRateLimited(trackerKey, policy) {
|
|
47
|
-
|
|
56
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
57
|
+
for (const { key, seconds } of RATE_LIMIT_WINDOWS) {
|
|
48
58
|
const limit = policy[key];
|
|
49
59
|
if (!limit)
|
|
50
60
|
continue;
|
|
51
|
-
const
|
|
61
|
+
const windowStart = Math.floor(nowSeconds / seconds) * seconds;
|
|
62
|
+
const exceeded = await this.incrementWindow(trackerKey, key, windowStart, seconds, limit, nowSeconds);
|
|
52
63
|
if (exceeded)
|
|
53
|
-
return {
|
|
64
|
+
return { window: key, limit, resetAt: windowStart + seconds };
|
|
54
65
|
}
|
|
55
66
|
return false;
|
|
56
67
|
}
|
|
57
68
|
/** Increments one window counter. Returns true when the limit was already reached. */
|
|
58
|
-
async incrementWindow(trackerKey, sortKeyPrefix, windowSeconds, limit) {
|
|
59
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
60
|
-
const windowStart = Math.floor(nowSeconds / windowSeconds) * windowSeconds;
|
|
69
|
+
async incrementWindow(trackerKey, sortKeyPrefix, windowStart, windowSeconds, limit, nowSeconds) {
|
|
61
70
|
const expiresAt = nowSeconds + Math.ceil(windowSeconds * this.ttlWindowMultiplier);
|
|
62
71
|
const input = {
|
|
63
72
|
TableName: this.tableName,
|