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.
@@ -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 { LambderRateLimitPolicy, LambderDdbRateLimiter } from "../stores/LambderDdbRateLimiter.js";
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 the standard 422 validation
9
- * shape) and the handler receives it typed. Referencing the policy from an
10
- * API whose input schema does not carry those fields is a compile error, so
11
- * the API's schema stays the single owner of the field. Build with
12
- * lambderRateLimitKey() so the handler's payload type follows `apiInput`.
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
- /** A named rate-limit policy: fixed windows plus the key one counter tracks. */
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
- * What one counter spans. "api" (default): each API referencing the
46
- * policy gets its own counter, so the windows are a per-API budget.
47
- * "policy": every API referencing the policy shares one counter, so the
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?: string | readonly string[]): void;
87
- /** Check the API's policies in declared order; the first exceeded one refuses with a 429 envelope. */
88
- run(apiName: string, ctx: LambderRenderContext, resolver: LambderResolver, rateLimitOption?: string | readonly string[]): Promise<void>;
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 = ["perMin", "per10Min", "perHour", "perDay", "perWeek", "perMonth"];
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
- const toList = (value) => value === undefined ? [] : typeof value === "string" ? [value] : value;
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: enableApiRateLimits() was already called.");
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 toList(rateLimitOption)) {
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 via enableApiRateLimits() before registering the API.`);
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
- /** Check the API's policies in declared order; the first exceeded one refuses with a 429 envelope. */
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 toList(rateLimitOption)) {
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
- // scope "policy" shares one counter across every API referencing
50
- // the policy; the default gives each API its own budget.
51
- const trackerKey = policy.scope === "policy"
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 limited = await this.limiter.isRateLimited(trackerKey, policy);
55
- if (limited) {
56
- throw new LambderApiError(`Rate limited: "${apiName}" exceeded policy "${name}".`, {
57
- errorMessage: policy.errorMessage ?? "Too many requests. Please try again later.",
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 enableDdbSession) and
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 enableDdbSession) and
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 to enableDdbSession(...) to enable.");
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
- export interface LambderRateLimitPolicy {
3
- perMin?: number;
4
- per10Min?: number;
5
- perHour?: number;
6
- perDay?: number;
7
- perWeek?: number;
8
- perMonth?: number;
9
- }
10
- export type LambderRateLimitExceededMap = Partial<Record<keyof LambderRateLimitPolicy, number>>;
11
- /** `false` when allowed, otherwise the window(s) whose limit was hit. */
12
- export type LambderRateLimitResult = false | LambderRateLimitExceededMap;
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 avoids inflating the
31
- * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
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
- const WINDOW_CONFIG = [
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 avoids inflating the
17
- * larger counters. Items carry an `expiresAt` attribute for DynamoDB TTL.
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
- for (const { key, seconds } of WINDOW_CONFIG) {
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 exceeded = await this.incrementWindow(trackerKey, key, seconds, limit);
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 { [key]: limit };
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lambder",
3
- "version": "4.0.1",
3
+ "version": "4.2.1",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",