lambder 12.0.1 → 14.0.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +119 -0
  2. package/README.md +7 -2
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiEnvelope.js +4 -1
  5. package/dist/api/LambderApiRefusals.d.ts +22 -0
  6. package/dist/api/LambderApiRefusals.js +13 -0
  7. package/dist/build/generateApiFiles.d.ts +60 -0
  8. package/dist/build/generateApiFiles.js +85 -0
  9. package/dist/build/generatedTables.d.ts +1 -1
  10. package/dist/build/writeApiGuardParams.d.ts +11 -10
  11. package/dist/build/writeApiGuardParams.js +135 -31
  12. package/dist/build.d.ts +4 -1
  13. package/dist/build.js +3 -1
  14. package/dist/client.d.ts +2 -2
  15. package/dist/client.js +1 -1
  16. package/dist/core/Lambder.d.ts +32 -8
  17. package/dist/core/Lambder.js +135 -17
  18. package/dist/core/LambderCallSummary.d.ts +43 -0
  19. package/dist/core/LambderCallSummary.js +10 -0
  20. package/dist/core/LambderContext.d.ts +14 -1
  21. package/dist/core/LambderContext.js +19 -4
  22. package/dist/core/LambderCreateOptions.d.ts +50 -25
  23. package/dist/core/LambderCreateOptions.js +37 -18
  24. package/dist/core/LambderInvocationScope.d.ts +25 -0
  25. package/dist/core/LambderInvocationScope.js +21 -0
  26. package/dist/core/LambderResponse.d.ts +11 -0
  27. package/dist/core/LambderResponse.js +12 -0
  28. package/dist/core/LambderResponseBuilder.js +5 -1
  29. package/dist/index.d.ts +8 -6
  30. package/dist/index.js +2 -2
  31. package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
  32. package/dist/invoke/LambderLambdaEvent.js +8 -2
  33. package/dist/mock/LambderMockApp.d.ts +8 -8
  34. package/dist/mock/LambderMockApp.js +14 -10
  35. package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
  36. package/dist/session/LambderSessionManager.d.ts +46 -3
  37. package/dist/session/LambderSessionManager.js +62 -11
  38. package/dist/shared/LambderI18n.js +49 -4
  39. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  40. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  41. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  42. package/dist/shared/util/LambderInvocationScope.js +5 -0
  43. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  44. package/dist/shared/util/LambderNamedMaps.js +19 -0
  45. package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
  46. package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
  47. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  48. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  49. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  50. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  51. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  52. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  53. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  54. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  55. package/dist/testing/LambderTestApp.d.ts +13 -4
  56. package/dist/testing/LambderTestApp.js +21 -5
  57. package/package.json +1 -1
@@ -112,6 +112,38 @@ const interpolate = (text, params) => {
112
112
  return text;
113
113
  return text.replace(/\{([^{}]+)\}/g, (token, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : token);
114
114
  };
115
+ /** The `{name}` tokens a text carries, as interpolate reads them. */
116
+ const placeholdersOf = (text) => new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]));
117
+ /** A set of placeholders as a message shows it: `{count}, {name}`, or "none". */
118
+ const describePlaceholders = (placeholders) => placeholders.size ? [...placeholders].sort().map((name) => `{${name}}`).join(", ") : "none";
119
+ /** How many mismatched translations an error lists before it only counts the rest. */
120
+ const PLACEHOLDER_MISMATCH_LIMIT = 20;
121
+ /**
122
+ * Refuses translations that do not carry the placeholders of the text they
123
+ * translate. The default language's text is the contract the translator's
124
+ * type takes its parameters from, so a translation that drops one loses the
125
+ * value a caller passed, and one that adds one shows its `{token}` as it
126
+ * is. `lookupDefault` answers the default language's text for a key, and a
127
+ * key it has none for is left to the other checks.
128
+ */
129
+ const assertPlaceholdersKept = (lookupDefault, lang, dict, label) => {
130
+ const mismatches = [];
131
+ for (const [key, text] of Object.entries(dict)) {
132
+ const original = lookupDefault(key);
133
+ if (original === undefined || typeof text !== "string")
134
+ continue;
135
+ const expected = placeholdersOf(original);
136
+ const found = placeholdersOf(text);
137
+ if (expected.size === found.size && [...expected].every((name) => found.has(name)))
138
+ continue;
139
+ mismatches.push(`"${key}" has ${describePlaceholders(found)} where the default language has ${describePlaceholders(expected)}`);
140
+ }
141
+ if (mismatches.length === 0)
142
+ return;
143
+ const listed = mismatches.slice(0, PLACEHOLDER_MISMATCH_LIMIT).map((line) => ` ${line}`);
144
+ const more = mismatches.length > PLACEHOLDER_MISMATCH_LIMIT ? [` ... and ${mismatches.length - PLACEHOLDER_MISMATCH_LIMIT} more`] : [];
145
+ throw new Error([`LambderI18n: in ${label}, these "${lang}" translations carry other placeholders than the default language's text:`, ...listed, ...more].join("\n"));
146
+ };
115
147
  const layerLookup = (layer, lang, key) => {
116
148
  for (let node = layer; node; node = node.parent) {
117
149
  const value = node.dicts[lang]?.[key];
@@ -133,7 +165,12 @@ const assertInlineDefaultBlock = (dict, defaultLanguage, label) => {
133
165
  throw new Error(`LambderI18n: ${label} must give the default language "${defaultLanguage}" inline, not as a loader.`);
134
166
  }
135
167
  };
136
- const createLayer = (core, sources, parent) => {
168
+ /**
169
+ * A layer over its parent, from a dictionary set as configured. The inline
170
+ * blocks' placeholders are checked here, against the default language's
171
+ * texts the layer reaches; a loader's are checked when it has run.
172
+ */
173
+ const createLayer = (core, sources, parent, label) => {
137
174
  const layer = { dicts: {}, loaders: new Map(), inFlight: new Map(), parent };
138
175
  for (const [lang, source] of Object.entries(sources)) {
139
176
  if (typeof source === "function")
@@ -141,6 +178,10 @@ const createLayer = (core, sources, parent) => {
141
178
  else if (source)
142
179
  layer.dicts[lang] = source;
143
180
  }
181
+ for (const [lang, dict] of Object.entries(layer.dicts)) {
182
+ if (lang !== core.defaultLanguage && dict)
183
+ assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, label);
184
+ }
144
185
  if (layer.loaders.size > 0)
145
186
  core.lazyLayers.add(layer);
146
187
  return layer;
@@ -172,6 +213,7 @@ const startLayerLoad = (core, layer, lang, loader) => {
172
213
  const dict = dictionaryFromLoaded(loaded, lang);
173
214
  if (layer.parent)
174
215
  assertNoRedeclaredKeys(layer.parent, core.defaultLanguage, dict, `the "${lang}" loader`);
216
+ assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), lang, dict, `the "${lang}" loader`);
175
217
  // Translations registered while the loader ran override what it brought.
176
218
  layer.dicts[lang] = { ...dict, ...layer.dicts[lang] };
177
219
  })
@@ -254,11 +296,11 @@ const buildInstance = (core, layer) => {
254
296
  },
255
297
  extend(dict) {
256
298
  validateExtension(dict, core.languageList, "extend() dictionary");
257
- return buildInstance(core, createLayer(core, dict, layer));
299
+ return buildInstance(core, createLayer(core, dict, layer, "extend() dictionary"));
258
300
  },
259
301
  extendPartial(dict) {
260
302
  validateExtension(dict, core.enforced, "extendPartial() dictionary");
261
- return buildInstance(core, createLayer(core, dict, layer));
303
+ return buildInstance(core, createLayer(core, dict, layer, "extendPartial() dictionary"));
262
304
  },
263
305
  loadLanguage(code) {
264
306
  const lang = code ?? core.state.resolve();
@@ -269,6 +311,9 @@ const buildInstance = (core, layer) => {
269
311
  registerDictionary(code, dict) {
270
312
  if (!core.isCode(code))
271
313
  throw new Error(`LambderI18n: unsupported language code "${code}".`);
314
+ if (code !== core.defaultLanguage) {
315
+ assertPlaceholdersKept((key) => layerLookup(layer, core.defaultLanguage, key), code, dict, `registerDictionary("${code}")`);
316
+ }
272
317
  layer.dicts[code] = { ...layer.dicts[code], ...dict };
273
318
  core.state.emitChange();
274
319
  },
@@ -350,5 +395,5 @@ export const createLambderI18n = (config) => {
350
395
  isCode,
351
396
  lazyLayers: new Set(),
352
397
  };
353
- return buildInstance(core, createLayer(core, config.base, null));
398
+ return buildInstance(core, createLayer(core, config.base, null, "base dictionary"));
354
399
  };
@@ -9,8 +9,8 @@
9
9
  * dependency-free, so the mock runtime and the browser entry can resolve it.
10
10
  */
11
11
  /**
12
- * The fixed windows a policy may cap, smallest first (the evaluation order),
13
- * with their length. The policy type derives from this table, so the two can
12
+ * The fixed windows a policy may cap, smallest first (the order a refusal
13
+ * names the smallest window by), with their length. The policy type derives from this table, so the two can
14
14
  * never drift.
15
15
  */
16
16
  export declare const RATE_LIMIT_WINDOWS: readonly [{
@@ -9,8 +9,8 @@
9
9
  * dependency-free, so the mock runtime and the browser entry can resolve it.
10
10
  */
11
11
  /**
12
- * The fixed windows a policy may cap, smallest first (the evaluation order),
13
- * with their length. The policy type derives from this table, so the two can
12
+ * The fixed windows a policy may cap, smallest first (the order a refusal
13
+ * names the smallest window by), with their length. The policy type derives from this table, so the two can
14
14
  * never drift.
15
15
  */
16
16
  export const RATE_LIMIT_WINDOWS = [
@@ -0,0 +1,10 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ /** What any layer may ask of the invocation it runs under. */
3
+ export type LambderInvocation = {
4
+ /** The invocation's own request id (the Lambda context's awsRequestId); null when the context has none. */
5
+ readonly requestId: string | null;
6
+ };
7
+ /** The async context the server's handler opens once per invocation. */
8
+ export declare const invocationScope: AsyncLocalStorage<LambderInvocation>;
9
+ /** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
10
+ export declare const currentRequestId: () => string | null;
@@ -0,0 +1,5 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ /** The async context the server's handler opens once per invocation. */
3
+ export const invocationScope = new AsyncLocalStorage();
4
+ /** The request id of the invocation the calling code runs under, or null outside one (a script, a test calling a library directly). */
5
+ export const currentRequestId = () => invocationScope.getStore()?.requestId ?? null;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * One map of named declarations, or a list of them. An app made of parts,
3
+ * each declaring its own guards, rate-limit policies or refusal codes beside
4
+ * its APIs, hands the list over, and every name in it is declared. A name two
5
+ * maps declare is refused rather than one quietly replacing the other.
6
+ */
7
+ export type LambderNamedMapsOption<TMap> = TMap | readonly TMap[];
8
+ /** The one map a named-maps option declares: a list's members intersected, a lone map as it is. */
9
+ export type LambderMergedNamedMaps<T> = T extends readonly [] ? {} : T extends readonly [infer TFirst, ...infer TRest] ? TFirst & LambderMergedNamedMaps<TRest> : T extends readonly (infer TMember)[] ? TMember : T;
10
+ /** The names a list declares in more than one of its maps: never for a lone map, or for a list whose members are not known one by one. */
11
+ type LambderRepeatedNames<T, TSeen extends PropertyKey = never> = T extends readonly [infer TFirst, ...infer TRest] ? (keyof TFirst & TSeen) | LambderRepeatedNames<TRest, TSeen | keyof TFirst> : never;
12
+ /** Intersected onto a list: a name declared in two of its maps is a compile error, and the property name is the message. */
13
+ export type LambderNoRepeatedNames<T> = [LambderRepeatedNames<T>] extends [never] ? unknown : {
14
+ readonly "lambder: a name is declared in more than one of these maps. Declare each name once.": LambderRepeatedNames<T>;
15
+ };
16
+ /**
17
+ * A named-maps option as one map: a lone map as it is, a list merged in
18
+ * order. A name declared in two maps of the list is refused; `what` names a
19
+ * declaration and `where` the call that took the list, for that error.
20
+ */
21
+ export declare const mergeNamedMaps: <TMap extends Record<string, unknown>>(option: LambderNamedMapsOption<TMap>, what: "guard" | "rate-limit policy" | "refusal code", where?: string) => TMap;
22
+ export {};
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A named-maps option as one map: a lone map as it is, a list merged in
3
+ * order. A name declared in two maps of the list is refused; `what` names a
4
+ * declaration and `where` the call that took the list, for that error.
5
+ */
6
+ export const mergeNamedMaps = (option, what, where = "create()") => {
7
+ if (!Array.isArray(option))
8
+ return option;
9
+ const merged = {};
10
+ for (const map of option) {
11
+ for (const [name, declaration] of Object.entries(map)) {
12
+ if (Object.prototype.hasOwnProperty.call(merged, name)) {
13
+ throw new Error(`Lambder: the ${what} "${name}" is declared in two of the maps given to ${where}. Declare each name once.`);
14
+ }
15
+ merged[name] = declaration;
16
+ }
17
+ }
18
+ return merged;
19
+ };
@@ -153,3 +153,27 @@ export type LambderGuardParamOf<TEntry, N extends string> = TEntry extends {
153
153
  * reads instead of the source.
154
154
  */
155
155
  export declare const apiGuardParam: <TOptions extends Record<string, LambderApiOptionEntry>, K extends keyof TOptions & string, N extends string>(options: TOptions, name: K, guard: N) => LambderGuardParamOf<TOptions[K], N>;
156
+ /**
157
+ * The identifier writeApiGuardParams exports an API's parameter under: its
158
+ * group, its action capitalized, then `GuardParam` (`orders.list` exports
159
+ * `ordersListGuardParam`). What code that reads a generated module by API
160
+ * name (a test comparing it with the server) looks each export up by.
161
+ */
162
+ export declare const apiGuardParamExportName: (apiName: string) => string;
163
+ /** The key a generated guard parameter's tag sits under: declared and never created, so it exists in types alone. */
164
+ declare const API_GUARD_PARAM_TAG: unique symbol;
165
+ /**
166
+ * One API's parameter for one guard, as writeApiGuardParams exports it: the
167
+ * value TParam as declared (`true` for a guard named without one), tagged
168
+ * with the API's name and the guard's. The tag exists in types alone, so the
169
+ * names reach no bundle. A function typed to a guard's tagged parameters
170
+ * takes only generated ones: a hand-written literal, which would restate the
171
+ * server's declaration and could drift from it, does not compile.
172
+ */
173
+ export type LambderApiGuardParam<TApi extends string, TGuard extends string, TParam> = TParam & {
174
+ readonly [API_GUARD_PARAM_TAG]: {
175
+ readonly api: TApi;
176
+ readonly guard: TGuard;
177
+ };
178
+ };
179
+ export {};
@@ -15,6 +15,7 @@
15
15
  * (when a guard runs, what a budget spans, when a custom key is charged) are
16
16
  * declared here too and re-exported by the engines that read them.
17
17
  */
18
+ import { splitApiName } from "./LambderApiNames.js";
18
19
  /**
19
20
  * The parameter an API's guards option gives guard N, read off a generated
20
21
  * `apiOptions` table with the type the table pins: the literal a permission
@@ -33,3 +34,15 @@ export const apiGuardParam = (options, name, guard) => {
33
34
  param = guards[guard];
34
35
  return param;
35
36
  };
37
+ /**
38
+ * The identifier writeApiGuardParams exports an API's parameter under: its
39
+ * group, its action capitalized, then `GuardParam` (`orders.list` exports
40
+ * `ordersListGuardParam`). What code that reads a generated module by API
41
+ * name (a test comparing it with the server) looks each export up by.
42
+ */
43
+ export const apiGuardParamExportName = (apiName) => {
44
+ const parts = splitApiName(apiName);
45
+ if (!parts)
46
+ throw new Error(`Lambder: "${apiName}" is not an endpoint name, so its guard parameter has no export name.`);
47
+ return `${parts.group}${parts.action[0].toUpperCase()}${parts.action.slice(1)}GuardParam`;
48
+ };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What an API call ended in, as the server names it in the call's summary
3
+ * line: the reasons a caller reads the same answer by (resolveApiOutcome),
4
+ * with the server's side of a 5xx, a crash, and `other` for an answer that
5
+ * was not written as an API answer (a hook answering an API call with a page
6
+ * of its own).
7
+ */
8
+ export type LambderCallOutcome = "success" | "refusal" | "notAuthorized" | "sessionExpired" | "versionExpired" | "validation" | "crash" | "other";
9
+ /** An answer's outcome and refusal code, as the code that wrote the answer knows them. */
10
+ export type LambderCallOutcomeHint = {
11
+ outcome: LambderCallOutcome;
12
+ /** The refusal's code, a framework code (`lambder/rate-limited`) or an app's; null when the answer carries none. */
13
+ code: string | null;
14
+ };
15
+ /**
16
+ * An envelope's outcome, read in the order a caller honours it: a 5xx is a
17
+ * crash whatever the envelope says, then the three flags, then a refusal,
18
+ * and anything else is the handler's answer.
19
+ */
20
+ export declare const outcomeOfEnvelope: (written: object, statusCode: number) => LambderCallOutcomeHint;
21
+ /**
22
+ * The outcome of an answer written as text, for an answer that arrived with
23
+ * no hint (a replayed one, which its store keeps as text): a 422 is a
24
+ * validation refusal, an envelope is read as outcomeOfEnvelope reads it, and
25
+ * anything else is known by its status alone.
26
+ */
27
+ export declare const outcomeOfAnswerText: (statusCode: number, body: string) => LambderCallOutcomeHint;
@@ -0,0 +1,47 @@
1
+ const codeOf = (refusal) => {
2
+ if (refusal === null || typeof refusal !== "object")
3
+ return null;
4
+ const code = refusal.code;
5
+ return typeof code === "string" ? code : null;
6
+ };
7
+ /**
8
+ * An envelope's outcome, read in the order a caller honours it: a 5xx is a
9
+ * crash whatever the envelope says, then the three flags, then a refusal,
10
+ * and anything else is the handler's answer.
11
+ */
12
+ export const outcomeOfEnvelope = (written, statusCode) => {
13
+ const envelope = written;
14
+ if (statusCode >= 500)
15
+ return { outcome: "crash", code: null };
16
+ if (envelope.versionExpired)
17
+ return { outcome: "versionExpired", code: null };
18
+ if (envelope.sessionExpired)
19
+ return { outcome: "sessionExpired", code: null };
20
+ if (envelope.notAuthorized)
21
+ return { outcome: "notAuthorized", code: codeOf(envelope.refusal) };
22
+ if (envelope.refusal !== undefined)
23
+ return { outcome: "refusal", code: codeOf(envelope.refusal) };
24
+ return { outcome: "success", code: null };
25
+ };
26
+ /**
27
+ * The outcome of an answer written as text, for an answer that arrived with
28
+ * no hint (a replayed one, which its store keeps as text): a 422 is a
29
+ * validation refusal, an envelope is read as outcomeOfEnvelope reads it, and
30
+ * anything else is known by its status alone.
31
+ */
32
+ export const outcomeOfAnswerText = (statusCode, body) => {
33
+ if (statusCode >= 500)
34
+ return { outcome: "crash", code: null };
35
+ if (statusCode === 422)
36
+ return { outcome: "validation", code: null };
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(body);
40
+ }
41
+ catch {
42
+ return { outcome: "other", code: null };
43
+ }
44
+ if (parsed === null || typeof parsed !== "object" || !("payload" in parsed))
45
+ return { outcome: "other", code: null };
46
+ return outcomeOfEnvelope(parsed, statusCode);
47
+ };
@@ -25,3 +25,13 @@ export declare const LAMBDER_INVOKE_API_ID = "lambder-invoke";
25
25
  * it.
26
26
  */
27
27
  export declare const LAMBDER_LOCAL_API_ID = "lambder-local";
28
+ /**
29
+ * The request id of the invocation that made an invoke, when it runs under a
30
+ * Lambder handler: the invoke event builder writes it, and the callee's call
31
+ * summary records it as its parentRequestId, so a query over both functions'
32
+ * logs joins a call to the calls it made. The server reads it only from an
33
+ * invoke (LAMBDER_INVOKE_API_ID), whose event no gateway lets a client write.
34
+ * Declared here, beside the ids, because the builder writes it and the
35
+ * server reads it.
36
+ */
37
+ export declare const LAMBDER_PARENT_REQUEST_HEADER = "x-lambder-parent-request-id";
@@ -25,3 +25,13 @@ export const LAMBDER_INVOKE_API_ID = "lambder-invoke";
25
25
  * it.
26
26
  */
27
27
  export const LAMBDER_LOCAL_API_ID = "lambder-local";
28
+ /**
29
+ * The request id of the invocation that made an invoke, when it runs under a
30
+ * Lambder handler: the invoke event builder writes it, and the callee's call
31
+ * summary records it as its parentRequestId, so a query over both functions'
32
+ * logs joins a call to the calls it made. The server reads it only from an
33
+ * invoke (LAMBDER_INVOKE_API_ID), whose event no gateway lets a client write.
34
+ * Declared here, beside the ids, because the builder writes it and the
35
+ * server reads it.
36
+ */
37
+ export const LAMBDER_PARENT_REQUEST_HEADER = "x-lambder-parent-request-id";
@@ -20,12 +20,21 @@ export interface LambderDdbRateLimiterOptions {
20
20
  * Fixed-window rate limiter backed by DynamoDB.
21
21
  *
22
22
  * Each window is one item counted with a conditional `ADD`, so the increment
23
- * and the limit check are one atomic request. Windows are evaluated smallest
24
- * first and evaluation stops at the first exceeded one, which keeps blocked
25
- * requests cheap and spares the larger counters. Attempts count, not
26
- * successes: a counter checked before the refusing one keeps its increment,
27
- * since a compensating decrement would give up the conditional-ADD
28
- * atomicity. Items carry an `expiresAt` attribute for DynamoDB TTL.
23
+ * and the limit check are one atomic request, and every window's request is
24
+ * sent at once: a policy capping three windows costs one round trip on the
25
+ * request's critical path, not three. Attempts count, not successes: a window
26
+ * under its limit counts an attempt another window refuses, since a
27
+ * compensating decrement would give up the conditional-ADD atomicity. The
28
+ * refusal names the smallest window that refused. Items carry an `expiresAt`
29
+ * attribute for DynamoDB TTL.
30
+ *
31
+ * A window the table refused an attempt in stays at its limit until it
32
+ * resets, since a count only rises within its window. So the process
33
+ * remembers it until then, and refuses the key's next attempts from memory
34
+ * without touching the table: a flood costs each process one round of
35
+ * writes per window it fills, and the counters of its other windows stop
36
+ * rising once it is refused. Another process asks the table once and then
37
+ * remembers too.
29
38
  *
30
39
  * The tracker key is caller data (an address, a session key, whatever a
31
40
  * policy handler returned), so a key whose partition key would pass
@@ -47,9 +56,9 @@ export interface LambderDdbRateLimiterOptions {
47
56
  * means the partition holding this counter is flooded. A partition holds a
48
57
  * range of keys, though, not one: a flood on one address, or a session or
49
58
  * cache spike on a shared table, throttles every counter on the same
50
- * partition. The key's own counts tell the flood from its neighbours: the
51
- * throttled window's and every capped window's after it, the ones this
52
- * attempt has not been counted against yet, each read with a consistent
59
+ * partition. The key's own counts tell the flood from its neighbours: those
60
+ * of the windows whose write was throttled, the ones this attempt has not
61
+ * been counted against, each read with a consistent
53
62
  * GetItem, in parallel (reads have their own throughput, which the throttled
54
63
  * writes leave alone). A key at or over the limit of any of them is the
55
64
  * flood: it is refused, since passed on as a failure `failOpen` would wave
@@ -87,8 +96,8 @@ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
87
96
  private readonly ready;
88
97
  private readonly ttlWindowMultiplier;
89
98
  private readonly now;
90
- /** The windows seen during key-range throttles, by (partition key, window, window start); see the class doc. */
91
- private readonly throttledWindows;
99
+ /** The windows found at their limit, and those whose read a throttle refused, by (partition key, window, window start); see the class doc. */
100
+ private readonly rememberedWindows;
92
101
  constructor(options: LambderDdbRateLimiterOptions);
93
102
  /** The clock the windows are computed against, for the engine's Retry-After. */
94
103
  clockMilliseconds(): number;
@@ -107,11 +116,10 @@ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
107
116
  */
108
117
  private incrementWindow;
109
118
  /**
110
- * The answer to a key-range throttle on the first of `uncounted`: that
111
- * window and every capped one after it, the windows this attempt has not
112
- * been counted against. The windows before it counted this attempt and
113
- * allowed it, so reading them could only turn the attempt that filled
114
- * one into a refusal.
119
+ * The answer to a key-range throttle on `uncounted`, the windows whose
120
+ * write the throttle refused and which have not counted this attempt.
121
+ * The others counted it and allowed it, so reading them could only turn
122
+ * the attempt that filled one into a refusal.
115
123
  *
116
124
  * Each count is read strongly consistent: the count that decides is the
117
125
  * one the throttled writes were racing to raise, and a replica lagging
@@ -6,12 +6,14 @@ import { RATE_LIMIT_WINDOWS, } from "../shared/contracts/LambderRateLimiter.js";
6
6
  /** How long a key refused during a throttle is told to wait: long enough to let the partition recover, short enough for a legitimate caller. */
7
7
  const THROTTLED_RETRY_SECONDS = 5;
8
8
  /**
9
- * Most (tracker key, window) pairs the limiter remembers from throttles at
10
- * once. Only a key seen during a key-range throttle is remembered, for
11
- * THROTTLED_RETRY_SECONDS, so this is reached only by a flood spread over
12
- * that many keys at once, where the ones closest to expiring go first.
9
+ * Most (tracker key, window) pairs the limiter remembers at once. Only a
10
+ * window found at its limit is remembered (until it resets, or for
11
+ * THROTTLED_RETRY_SECONDS when a throttle's read found it there), so this is
12
+ * reached only by refusals spread over that many keys at once, where the
13
+ * ones closest to expiring go first; a key forgotten early is asked of the
14
+ * table again, which answers the same.
13
15
  */
14
- const THROTTLED_WINDOW_MEMORY_ENTRIES = 10_000;
16
+ const REMEMBERED_WINDOW_ENTRIES = 10_000;
15
17
  /** The table key of one window's counter. */
16
18
  const counterKeyOf = (partitionKey, window) => ({
17
19
  pk: { S: partitionKey },
@@ -23,12 +25,21 @@ const rememberedWindowKeyOf = (partitionKey, window) => joinKeyFields(partitionK
23
25
  * Fixed-window rate limiter backed by DynamoDB.
24
26
  *
25
27
  * Each window is one item counted with a conditional `ADD`, so the increment
26
- * and the limit check are one atomic request. Windows are evaluated smallest
27
- * first and evaluation stops at the first exceeded one, which keeps blocked
28
- * requests cheap and spares the larger counters. Attempts count, not
29
- * successes: a counter checked before the refusing one keeps its increment,
30
- * since a compensating decrement would give up the conditional-ADD
31
- * atomicity. Items carry an `expiresAt` attribute for DynamoDB TTL.
28
+ * and the limit check are one atomic request, and every window's request is
29
+ * sent at once: a policy capping three windows costs one round trip on the
30
+ * request's critical path, not three. Attempts count, not successes: a window
31
+ * under its limit counts an attempt another window refuses, since a
32
+ * compensating decrement would give up the conditional-ADD atomicity. The
33
+ * refusal names the smallest window that refused. Items carry an `expiresAt`
34
+ * attribute for DynamoDB TTL.
35
+ *
36
+ * A window the table refused an attempt in stays at its limit until it
37
+ * resets, since a count only rises within its window. So the process
38
+ * remembers it until then, and refuses the key's next attempts from memory
39
+ * without touching the table: a flood costs each process one round of
40
+ * writes per window it fills, and the counters of its other windows stop
41
+ * rising once it is refused. Another process asks the table once and then
42
+ * remembers too.
32
43
  *
33
44
  * The tracker key is caller data (an address, a session key, whatever a
34
45
  * policy handler returned), so a key whose partition key would pass
@@ -50,9 +61,9 @@ const rememberedWindowKeyOf = (partitionKey, window) => joinKeyFields(partitionK
50
61
  * means the partition holding this counter is flooded. A partition holds a
51
62
  * range of keys, though, not one: a flood on one address, or a session or
52
63
  * cache spike on a shared table, throttles every counter on the same
53
- * partition. The key's own counts tell the flood from its neighbours: the
54
- * throttled window's and every capped window's after it, the ones this
55
- * attempt has not been counted against yet, each read with a consistent
64
+ * partition. The key's own counts tell the flood from its neighbours: those
65
+ * of the windows whose write was throttled, the ones this attempt has not
66
+ * been counted against, each read with a consistent
56
67
  * GetItem, in parallel (reads have their own throughput, which the throttled
57
68
  * writes leave alone). A key at or over the limit of any of them is the
58
69
  * flood: it is refused, since passed on as a failure `failOpen` would wave
@@ -90,8 +101,8 @@ export class LambderDdbRateLimiter {
90
101
  ready;
91
102
  ttlWindowMultiplier;
92
103
  now;
93
- /** The windows seen during key-range throttles, by (partition key, window, window start); see the class doc. */
94
- throttledWindows;
104
+ /** The windows found at their limit, and those whose read a throttle refused, by (partition key, window, window start); see the class doc. */
105
+ rememberedWindows;
95
106
  constructor(options) {
96
107
  if (!options.tableName.trim())
97
108
  throw new Error("tableName is required");
@@ -99,7 +110,7 @@ export class LambderDdbRateLimiter {
99
110
  this.keyPrefix = options.keyPrefix ?? "RL";
100
111
  this.ttlWindowMultiplier = assertNumberAtLeast(options.ttlWindowMultiplier ?? 2, 1, "ttlWindowMultiplier");
101
112
  this.now = options.now ?? (() => Date.now());
102
- this.throttledWindows = new LambderExpiringMap({ now: this.now, maxEntries: THROTTLED_WINDOW_MEMORY_ENTRIES });
113
+ this.rememberedWindows = new LambderExpiringMap({ now: this.now, maxEntries: REMEMBERED_WINDOW_ENTRIES });
103
114
  this.ready = createDynamoClientLoader({ user: "LambderDdbRateLimiter", region: options.region, client: options.client });
104
115
  }
105
116
  /** The clock the windows are computed against, for the engine's Retry-After. */
@@ -121,28 +132,37 @@ export class LambderDdbRateLimiter {
121
132
  const windows = RATE_LIMIT_WINDOWS
122
133
  .filter(({ key }) => policy[key])
123
134
  .map(({ key, seconds }) => ({ key, seconds, limit: policy[key], start: Math.floor(nowSeconds / seconds) * seconds }));
124
- // A window this process read at its limit during a throttle refuses
125
- // without touching the table (see the class doc).
126
- const rememberedAtLimit = windows.find((window) => {
127
- const remembered = this.throttledWindows.get(rememberedWindowKeyOf(partitionKey, window));
128
- return remembered !== undefined && "count" in remembered && remembered.count >= window.limit;
129
- });
130
- if (rememberedAtLimit)
131
- return { window: rememberedAtLimit.key, limit: rememberedAtLimit.limit, resetAt: nowSeconds + THROTTLED_RETRY_SECONDS };
132
- for (const [index, window] of windows.entries()) {
133
- try {
134
- if (await this.incrementWindow(partitionKey, window, nowSeconds))
135
- return { window: window.key, limit: window.limit, resetAt: window.start + window.seconds };
136
- }
137
- catch (error) {
138
- // A key-range throttle may be the limiter's own answer (see
139
- // the class doc); anything else is the table failing, which
140
- // only the caller can decide what to do about.
141
- if (!isKeyRangeThrottle(error))
142
- throw error;
143
- return await this.answerKeyRangeThrottle(partitionKey, windows.slice(index), nowSeconds, error);
135
+ // A window this process has found at its limit refuses without
136
+ // touching the table (see the class doc).
137
+ for (const window of windows) {
138
+ const remembered = this.rememberedWindows.get(rememberedWindowKeyOf(partitionKey, window));
139
+ if (remembered !== undefined && "count" in remembered && remembered.count >= window.limit) {
140
+ return { window: window.key, limit: window.limit, resetAt: remembered.resetAt ?? nowSeconds + THROTTLED_RETRY_SECONDS };
144
141
  }
145
142
  }
143
+ const attempts = await Promise.allSettled(windows.map((window) => this.incrementWindow(partitionKey, window, nowSeconds)));
144
+ // A refusal is the answer whatever the other windows did: a window at
145
+ // its limit refuses the attempt whether the others counted it, failed
146
+ // or were throttled.
147
+ const refusedAt = attempts.findIndex((attempt) => attempt.status === "fulfilled" && attempt.value);
148
+ if (refusedAt !== -1) {
149
+ const refusing = windows[refusedAt];
150
+ const resetAt = refusing.start + refusing.seconds;
151
+ this.rememberedWindows.set(rememberedWindowKeyOf(partitionKey, refusing), { count: refusing.limit, resetAt }, resetAt);
152
+ return { window: refusing.key, limit: refusing.limit, resetAt };
153
+ }
154
+ // A failure that is not the key's range being throttled is the table
155
+ // failing, which only the caller can decide what to do about.
156
+ const failure = attempts.find((attempt) => attempt.status === "rejected" && !isKeyRangeThrottle(attempt.reason));
157
+ if (failure)
158
+ throw failure.reason;
159
+ // A key-range throttle may be the limiter's own answer (see the class
160
+ // doc), read from the windows the throttle kept the attempt from.
161
+ const throttled = windows.filter((_, index) => attempts[index].status === "rejected");
162
+ if (throttled.length) {
163
+ const throttle = attempts.find((attempt) => attempt.status === "rejected").reason;
164
+ return await this.answerKeyRangeThrottle(partitionKey, throttled, nowSeconds, throttle);
165
+ }
146
166
  return false;
147
167
  }
148
168
  /** The item's partition key, refused when the tracker key makes it one DynamoDB will not take. */
@@ -185,11 +205,10 @@ export class LambderDdbRateLimiter {
185
205
  }
186
206
  }
187
207
  /**
188
- * The answer to a key-range throttle on the first of `uncounted`: that
189
- * window and every capped one after it, the windows this attempt has not
190
- * been counted against. The windows before it counted this attempt and
191
- * allowed it, so reading them could only turn the attempt that filled
192
- * one into a refusal.
208
+ * The answer to a key-range throttle on `uncounted`, the windows whose
209
+ * write the throttle refused and which have not counted this attempt.
210
+ * The others counted it and allowed it, so reading them could only turn
211
+ * the attempt that filled one into a refusal.
193
212
  *
194
213
  * Each count is read strongly consistent: the count that decides is the
195
214
  * one the throttled writes were racing to raise, and a replica lagging
@@ -201,7 +220,7 @@ export class LambderDdbRateLimiter {
201
220
  */
202
221
  async answerKeyRangeThrottle(partitionKey, uncounted, nowSeconds, throttle) {
203
222
  const throttledWindowKey = rememberedWindowKeyOf(partitionKey, uncounted[0]);
204
- const remembered = this.throttledWindows.get(throttledWindowKey);
223
+ const remembered = this.rememberedWindows.get(throttledWindowKey);
205
224
  if (remembered !== undefined && "unreadable" in remembered)
206
225
  throw remembered.unreadable;
207
226
  const { client, sdk } = await this.ready();
@@ -214,14 +233,14 @@ export class LambderDdbRateLimiter {
214
233
  for (const [index, read] of reads.entries()) {
215
234
  const window = uncounted[index];
216
235
  if (read.status === "fulfilled" && read.value >= window.limit) {
217
- this.throttledWindows.set(rememberedWindowKeyOf(partitionKey, window), { count: read.value }, rememberUntil);
236
+ this.rememberedWindows.set(rememberedWindowKeyOf(partitionKey, window), { count: read.value }, rememberUntil);
218
237
  refusing ??= window;
219
238
  }
220
239
  }
221
240
  if (refusing)
222
241
  return { window: refusing.key, limit: refusing.limit, resetAt: rememberUntil };
223
242
  if (reads.some((read) => read.status === "rejected" && isKeyRangeThrottle(read.reason))) {
224
- this.throttledWindows.set(throttledWindowKey, { unreadable: throttle }, rememberUntil);
243
+ this.rememberedWindows.set(throttledWindowKey, { unreadable: throttle }, rememberUntil);
225
244
  }
226
245
  throw throttle;
227
246
  }
@@ -1,10 +1,15 @@
1
1
  import { type LambderRateLimiter, type LambderRateLimitPolicy, type LambderRateLimitResult, type LambderRateLimitWindow } from "../shared/contracts/LambderRateLimiter.js";
2
2
  /**
3
3
  * Fixed-window rate limiter held in memory: the same windows, the same
4
- * attempts-count semantics and the same evaluation order as
5
- * LambderDdbRateLimiter, with a Map in place of the table. For tests and
6
- * for the mock runtime; a single-process server could use it too, with the
7
- * caveat that its counters are per process and vanish with it.
4
+ * attempts-count semantics and the same refusals as LambderDdbRateLimiter,
5
+ * with a Map in place of the table. For tests and for the mock runtime; a
6
+ * single-process server could use it too, with the caveat that its counters
7
+ * are per process and vanish with it.
8
+ *
9
+ * Every counter is in hand, so an attempt a window refuses is counted in no
10
+ * window: what the DynamoDB limiter reaches once it remembers the refusing
11
+ * window, which the first refused attempt in each of its processes still
12
+ * counts in the others.
8
13
  *
9
14
  * `now` is injectable so a test can move time forward and watch a window
10
15
  * reset without waiting for it.