lambder 7.2.5 → 8.0.2

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 (209) hide show
  1. package/CHANGELOG.md +1021 -3
  2. package/README.md +43 -21
  3. package/dist/api/LambderApiAnswer.d.ts +18 -22
  4. package/dist/api/LambderApiAnswer.js +6 -7
  5. package/dist/api/LambderApiCallContext.d.ts +21 -8
  6. package/dist/api/LambderApiCallContext.js +22 -4
  7. package/dist/api/LambderApiDefinition.d.ts +4 -3
  8. package/dist/api/LambderApiEnvelope.d.ts +14 -9
  9. package/dist/api/LambderApiEnvelope.js +33 -34
  10. package/dist/api/LambderApiGuards.d.ts +78 -51
  11. package/dist/api/LambderApiGuards.js +34 -36
  12. package/dist/api/LambderApiIdempotency.d.ts +74 -61
  13. package/dist/api/LambderApiIdempotency.js +226 -151
  14. package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
  15. package/dist/api/LambderApiOutputValidationError.js +50 -0
  16. package/dist/api/LambderApiPipeline.d.ts +77 -39
  17. package/dist/api/LambderApiPipeline.js +135 -62
  18. package/dist/api/LambderApiRateLimits.d.ts +208 -54
  19. package/dist/api/LambderApiRateLimits.js +197 -108
  20. package/dist/api/LambderApiRequest.d.ts +27 -21
  21. package/dist/api/LambderApiRequest.js +26 -19
  22. package/dist/api/LambderApiSignature.d.ts +12 -15
  23. package/dist/api/LambderApiSignature.js +28 -51
  24. package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
  25. package/dist/api/LambderApiValidationRefusal.js +10 -10
  26. package/dist/build/freshProcessVerifier.d.ts +13 -0
  27. package/dist/build/freshProcessVerifier.js +19 -0
  28. package/dist/build/writeApiSignatures.d.ts +109 -0
  29. package/dist/build/writeApiSignatures.js +222 -0
  30. package/dist/build.d.ts +9 -0
  31. package/dist/build.js +8 -0
  32. package/dist/client/LambderCaller.d.ts +13 -44
  33. package/dist/client/LambderCaller.js +77 -84
  34. package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
  35. package/dist/client/LambderReloadLoopBreaker.js +90 -46
  36. package/dist/client/lambderFetchTransport.d.ts +4 -1
  37. package/dist/client/lambderFetchTransport.js +52 -28
  38. package/dist/client.d.ts +5 -3
  39. package/dist/client.js +2 -1
  40. package/dist/core/Lambder.d.ts +161 -69
  41. package/dist/core/Lambder.js +370 -226
  42. package/dist/core/LambderContext.d.ts +82 -15
  43. package/dist/core/LambderContext.js +107 -20
  44. package/dist/core/LambderCors.d.ts +21 -3
  45. package/dist/core/LambderCors.js +35 -16
  46. package/dist/core/LambderCrashHandling.d.ts +40 -0
  47. package/dist/core/LambderCrashHandling.js +97 -0
  48. package/dist/core/LambderCreateOptions.d.ts +151 -75
  49. package/dist/core/LambderCreateOptions.js +16 -23
  50. package/dist/core/LambderFiles.d.ts +28 -7
  51. package/dist/core/LambderFiles.js +73 -33
  52. package/dist/core/LambderIndexHtml.js +12 -11
  53. package/dist/core/LambderPolicyBuilders.d.ts +17 -5
  54. package/dist/core/LambderPolicyBuilders.js +17 -5
  55. package/dist/core/LambderPublicFiles.d.ts +11 -5
  56. package/dist/core/LambderPublicFiles.js +32 -4
  57. package/dist/core/LambderRequestPath.d.ts +43 -0
  58. package/dist/core/LambderRequestPath.js +63 -0
  59. package/dist/core/LambderResponse.d.ts +26 -5
  60. package/dist/core/LambderResponse.js +157 -70
  61. package/dist/core/LambderResponseBuilder.d.ts +49 -4
  62. package/dist/core/LambderResponseBuilder.js +64 -3
  63. package/dist/core/LambderRouting.d.ts +2 -3
  64. package/dist/core/LambderRouting.js +22 -7
  65. package/dist/core/LambderTemplatingEngine.js +211 -32
  66. package/dist/index.d.ts +15 -8
  67. package/dist/index.js +5 -4
  68. package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
  69. package/dist/invoke/LambderInvokeCaller.js +76 -66
  70. package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
  71. package/dist/invoke/LambderInvokeOutcome.js +9 -22
  72. package/dist/invoke/LambderLambdaEvent.d.ts +44 -10
  73. package/dist/invoke/LambderLambdaEvent.js +80 -37
  74. package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
  75. package/dist/invoke/lambderHandlerTransport.js +16 -19
  76. package/dist/mock/LambderMockApp.d.ts +67 -83
  77. package/dist/mock/LambderMockApp.js +167 -153
  78. package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
  79. package/dist/mock/LambderMockBrowserCookies.js +24 -28
  80. package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
  81. package/dist/mock/LambderMockCallRecorder.js +19 -28
  82. package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
  83. package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
  84. package/dist/mock/LambderMockEntryRegistry.js +24 -29
  85. package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
  86. package/dist/mock/LambderMockFailureInjector.js +3 -6
  87. package/dist/mock/LambderMockTypes.d.ts +78 -108
  88. package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
  89. package/dist/mock/lambderMockInvokeTransport.js +11 -10
  90. package/dist/mock/lambderMockMswHandler.d.ts +33 -29
  91. package/dist/mock/lambderMockMswHandler.js +50 -39
  92. package/dist/mock.d.ts +3 -1
  93. package/dist/mock.js +5 -3
  94. package/dist/session/LambderSessionController.d.ts +108 -89
  95. package/dist/session/LambderSessionController.js +187 -168
  96. package/dist/session/LambderSessionCrypto.d.ts +16 -7
  97. package/dist/session/LambderSessionCrypto.js +26 -12
  98. package/dist/session/LambderSessionManager.d.ts +136 -47
  99. package/dist/session/LambderSessionManager.js +280 -139
  100. package/dist/shared/LambderHtml.d.ts +42 -3
  101. package/dist/shared/LambderHtml.js +127 -7
  102. package/dist/shared/LambderHtmlPositions.d.ts +173 -0
  103. package/dist/shared/LambderHtmlPositions.js +652 -0
  104. package/dist/shared/LambderI18n.d.ts +10 -11
  105. package/dist/shared/LambderI18n.js +33 -21
  106. package/dist/shared/contracts/LambderCache.d.ts +66 -0
  107. package/dist/shared/contracts/LambderCache.js +11 -0
  108. package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
  109. package/dist/shared/contracts/LambderFileSource.js +5 -8
  110. package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
  111. package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
  112. package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
  113. package/dist/shared/contracts/LambderRateLimiter.js +4 -5
  114. package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
  115. package/dist/shared/contracts/LambderSessionStore.js +5 -6
  116. package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
  117. package/dist/shared/transport/LambderApiTransport.js +7 -7
  118. package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
  119. package/dist/shared/transport/LambderCookieJar.js +54 -66
  120. package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
  121. package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
  122. package/dist/shared/util/LambderCallAbort.d.ts +5 -5
  123. package/dist/shared/util/LambderCallAbort.js +5 -5
  124. package/dist/shared/util/LambderClientIp.d.ts +27 -11
  125. package/dist/shared/util/LambderClientIp.js +96 -13
  126. package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
  127. package/dist/shared/util/LambderExpiringMap.js +41 -57
  128. package/dist/shared/util/LambderNodeModules.js +6 -7
  129. package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
  130. package/dist/shared/util/LambderOptionChecks.js +4 -4
  131. package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
  132. package/dist/shared/util/LambderResponseBrand.js +5 -5
  133. package/dist/shared/util/LambderTestingDoors.d.ts +29 -0
  134. package/dist/shared/util/LambderTestingDoors.js +29 -0
  135. package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
  136. package/dist/shared/util/LambderTypeUtilities.js +3 -3
  137. package/dist/shared/util/boundKeyField.d.ts +20 -0
  138. package/dist/shared/util/boundKeyField.js +34 -0
  139. package/dist/shared/util/canonicalJson.d.ts +11 -0
  140. package/dist/shared/util/canonicalJson.js +28 -0
  141. package/dist/shared/util/joinKeyFields.d.ts +20 -0
  142. package/dist/shared/util/joinKeyFields.js +22 -0
  143. package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
  144. package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
  145. package/dist/shared/wire/LambderApiContract.d.ts +107 -32
  146. package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
  147. package/dist/shared/wire/LambderApiOutcome.js +48 -23
  148. package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
  149. package/dist/shared/wire/LambderApiRefusal.js +36 -7
  150. package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
  151. package/dist/shared/wire/LambderApiSignature.js +16 -19
  152. package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
  153. package/dist/shared/wire/LambderCallOptions.js +9 -11
  154. package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
  155. package/dist/shared/wire/LambderCompressionCodec.js +31 -36
  156. package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
  157. package/dist/shared/wire/LambderCompressionOption.js +9 -9
  158. package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
  159. package/dist/shared/wire/LambderCrashDetail.js +12 -15
  160. package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
  161. package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
  162. package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
  163. package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
  164. package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
  165. package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
  166. package/dist/shared/wire/LambderInvokeApiId.js +27 -0
  167. package/dist/shared/wire/LambderOutcomeAssertions.d.ts +79 -0
  168. package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
  169. package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
  170. package/dist/shared/wire/LambderRequestPayload.js +4 -6
  171. package/dist/stores/LambderCacheFiller.d.ts +48 -0
  172. package/dist/stores/LambderCacheFiller.js +119 -0
  173. package/dist/stores/LambderCacheKeys.d.ts +26 -0
  174. package/dist/stores/LambderCacheKeys.js +54 -0
  175. package/dist/stores/LambderCacheValues.d.ts +45 -0
  176. package/dist/stores/LambderCacheValues.js +74 -0
  177. package/dist/stores/LambderDdbCache.d.ts +121 -56
  178. package/dist/stores/LambderDdbCache.js +528 -225
  179. package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
  180. package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
  181. package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
  182. package/dist/stores/LambderDdbRateLimiter.js +151 -39
  183. package/dist/stores/LambderDdbSdk.d.ts +43 -31
  184. package/dist/stores/LambderDdbSdk.js +79 -33
  185. package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
  186. package/dist/stores/LambderDdbSessionStore.js +119 -47
  187. package/dist/stores/LambderHttpFileSource.d.ts +15 -6
  188. package/dist/stores/LambderHttpFileSource.js +15 -13
  189. package/dist/stores/LambderMemoryCache.d.ts +49 -0
  190. package/dist/stores/LambderMemoryCache.js +113 -0
  191. package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
  192. package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
  193. package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
  194. package/dist/stores/LambderMemoryRateLimiter.js +14 -13
  195. package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
  196. package/dist/stores/LambderMemorySessionStore.js +38 -19
  197. package/dist/stores/LambderS3FileSource.d.ts +21 -6
  198. package/dist/stores/LambderS3FileSource.js +12 -7
  199. package/dist/testing/LambderTestApp.d.ts +176 -0
  200. package/dist/testing/LambderTestApp.js +204 -0
  201. package/dist/testing/LambderTestVisitor.d.ts +153 -0
  202. package/dist/testing/LambderTestVisitor.js +154 -0
  203. package/dist/testing.d.ts +27 -0
  204. package/dist/testing.js +24 -0
  205. package/package.json +20 -3
  206. package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
  207. package/dist/api/LambderApiPolicyEngine.js +0 -77
  208. package/dist/shared/util/LambderKeyFields.d.ts +0 -32
  209. package/dist/shared/util/LambderKeyFields.js +0 -34
@@ -32,23 +32,23 @@ export interface LambderDdbIdempotencyStoreOptions {
32
32
  *
33
33
  * Every claim carries a random ownerToken, and complete()/abandon() are
34
34
  * conditional on still holding it: an original that outlives its pending TTL
35
- * and loses the scope to a retry can no longer overwrite or delete the
36
- * retry's claim (both settle calls become silent no-ops instead). complete()
37
- * also requires the claim to be unexpired, so an owner whose claim ran out
38
- * reports "lost" whether or not TTL deletion has caught up with it, which is
39
- * what the memory store has always reported.
35
+ * and loses the scope to a retry cannot overwrite or delete the retry's claim
36
+ * (both settle calls become silent no-ops). complete() also requires the
37
+ * claim to be unexpired, so an owner whose claim ran out reports "lost"
38
+ * whether or not TTL deletion has caught up with it, as the memory store
39
+ * does. abandon() also requires the claim to be pending, so it never deletes
40
+ * a stored answer.
40
41
  *
41
- * Stored bodies are Brotli-compressed from 1KB by default (same scheme as
42
- * LambderDdbCache, see the `compression` option): the bodies are JSON
43
- * envelopes that typically shrink 5-10x, which cuts DynamoDB write units
44
- * and lets large responses fit the item budget instead of skipping replay
45
- * storage.
42
+ * Stored bodies are Brotli-compressed from 1KB by default (the scheme
43
+ * LambderDdbCache uses, see the `compression` option): JSON envelopes
44
+ * typically shrink 5-10x, which cuts write units and lets large responses
45
+ * fit the item budget instead of skipping replay storage.
46
46
  *
47
47
  * The scope key carries caller data (the client's idempotency key, and an
48
- * identity when one is configured), so a scope whose partition key would pass
49
- * DynamoDB's 2048-byte limit is refused here with an error that names the
50
- * limit, rather than reaching the table and coming back as a
51
- * ValidationException that reads as "the table is broken".
48
+ * identity when one is configured), so a partition key past DynamoDB's
49
+ * 2048-byte limit is refused here with an error naming the limit, rather
50
+ * than coming back from the table as a ValidationException that reads as
51
+ * "the table is broken".
52
52
  *
53
53
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
54
54
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
@@ -81,6 +81,8 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
81
81
  private static readItemBody;
82
82
  /** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
83
83
  private static answerOf;
84
+ /** The request fingerprint an item keeps; see UNKNOWN_REQUEST_FINGERPRINT for one that keeps none. */
85
+ private static fingerprintOf;
84
86
  /**
85
87
  * Read the scope without claiming it: the stored response when a
86
88
  * completed, unexpired record exists, null otherwise (absent, pending, or
@@ -93,17 +95,22 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
93
95
  * returned ownerToken) and must call complete() or abandon(); "pending"
94
96
  * means another request owns it right now; "done" carries the stored
95
97
  * response to replay.
98
+ *
99
+ * One write either way: a refused claim hands back the item that refused
100
+ * it (ALL_OLD), so there is no read after it. That item is also how a
101
+ * claim the SDK retried after it had already landed recognizes itself:
102
+ * the item carries this call's own ownerToken, so the scope is ours
103
+ * rather than somebody else's in-flight original.
96
104
  */
97
- begin(scopeKey: string, { pendingTtlSeconds }: {
105
+ begin(scopeKey: string, { pendingTtlSeconds, fingerprint }: {
98
106
  pendingTtlSeconds: number;
107
+ fingerprint: string;
99
108
  }): Promise<LambderIdempotencyBeginResult>;
100
109
  /**
101
110
  * Store the response for replays, overwriting the pending claim. Bodies
102
- * from the compression option's minBytes are stored Brotli-compressed
103
- * (they are JSON envelopes, which typically shrink 5-10x), cutting
104
- * DynamoDB write units and letting large responses fit the item budget;
105
- * smaller bodies, or all of them with compression off, stay plain.
106
- * Returns:
111
+ * from the compression option's minBytes up are stored Brotli-compressed
112
+ * (see the class comment); smaller bodies, or all of them with
113
+ * compression off, stay plain. Returns:
107
114
  *
108
115
  * - "stored": the record is in place and will replay.
109
116
  * - "too-large": even compressed, the body exceeds the item budget;
@@ -111,13 +118,17 @@ export declare class LambderDdbIdempotencyStore implements LambderIdempotencySto
111
118
  * - "lost": the ownerToken no longer matches, i.e. the claim expired and
112
119
  * a retry took the scope over; nothing was written.
113
120
  */
114
- complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: LambderIdempotencyDoneRecord & {
121
+ complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, fingerprint, ttlSeconds }: LambderIdempotencyDoneRecord & {
115
122
  ttlSeconds: number;
116
123
  }): Promise<"stored" | "too-large" | "lost">;
117
124
  /**
118
125
  * Release the claim without storing a response (crash, uncacheable
119
126
  * response), so a retry can execute. Conditional on still holding the
120
- * claim; a lost claim makes this a silent no-op.
127
+ * claim AND on its still being pending: a lost claim makes this a silent
128
+ * no-op, and so does a settled record, whose owner token is still the
129
+ * caller's. The engine abandons after a complete() that threw, and one
130
+ * whose response was lost may have landed; deleting its record would
131
+ * hand the retry a free scope, and the operation would run twice.
121
132
  */
122
133
  abandon(scopeKey: string, ownerToken: string): Promise<void>;
123
134
  }
@@ -11,15 +11,24 @@ const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
11
11
  */
12
12
  const MAX_STORED_BODY_BYTES = 350_000;
13
13
  /**
14
- * Ceiling on a stored body's declared length, which is the budget the restore
14
+ * Ceiling on a stored body's declared length, the budget the restore
15
15
  * decompresses under. The store's own writes stay far inside it (a response
16
- * that reaches a client at all is a few megabytes at most, and the compressed
17
- * bytes have to fit MAX_STORED_BODY_BYTES), so a record declaring more than
18
- * this is one this store did not write, and taking its word for it would let
19
- * a few hundred kilobytes of Brotli expand until the function dies. The
20
- * cache bounds the same number the same way, against its maxValueBytes.
16
+ * that reaches a client is a few megabytes at most, and the compressed bytes
17
+ * must fit MAX_STORED_BODY_BYTES), so a record declaring more is not this
18
+ * store's, and trusting it would let a few hundred kilobytes of Brotli expand
19
+ * until the function dies. The cache bounds the same number against its
20
+ * maxValueBytes.
21
21
  */
22
22
  const MAX_REPLAY_BODY_BYTES = 32 * 1024 * 1024;
23
+ /**
24
+ * What an item that keeps no fingerprint reports: one no request matches,
25
+ * since the engine's fingerprints are never empty. Such an item was not
26
+ * written by this store (every claim and record it writes keeps one), so the
27
+ * engine refuses the key as reused, a 409 a key scope moves past, rather than
28
+ * replaying an answer it cannot tie to the request or reading the scope as
29
+ * free and running the request over it.
30
+ */
31
+ const UNKNOWN_REQUEST_FINGERPRINT = "";
23
32
  /**
24
33
  * A number attribute as stored, or the fallback when it is missing or not a
25
34
  * number. `Number(undefined)` and `Number("nope")` are both NaN, which every
@@ -47,23 +56,23 @@ const newOwnerToken = async () => {
47
56
  *
48
57
  * Every claim carries a random ownerToken, and complete()/abandon() are
49
58
  * conditional on still holding it: an original that outlives its pending TTL
50
- * and loses the scope to a retry can no longer overwrite or delete the
51
- * retry's claim (both settle calls become silent no-ops instead). complete()
52
- * also requires the claim to be unexpired, so an owner whose claim ran out
53
- * reports "lost" whether or not TTL deletion has caught up with it, which is
54
- * what the memory store has always reported.
59
+ * and loses the scope to a retry cannot overwrite or delete the retry's claim
60
+ * (both settle calls become silent no-ops). complete() also requires the
61
+ * claim to be unexpired, so an owner whose claim ran out reports "lost"
62
+ * whether or not TTL deletion has caught up with it, as the memory store
63
+ * does. abandon() also requires the claim to be pending, so it never deletes
64
+ * a stored answer.
55
65
  *
56
- * Stored bodies are Brotli-compressed from 1KB by default (same scheme as
57
- * LambderDdbCache, see the `compression` option): the bodies are JSON
58
- * envelopes that typically shrink 5-10x, which cuts DynamoDB write units
59
- * and lets large responses fit the item budget instead of skipping replay
60
- * storage.
66
+ * Stored bodies are Brotli-compressed from 1KB by default (the scheme
67
+ * LambderDdbCache uses, see the `compression` option): JSON envelopes
68
+ * typically shrink 5-10x, which cuts write units and lets large responses
69
+ * fit the item budget instead of skipping replay storage.
61
70
  *
62
71
  * The scope key carries caller data (the client's idempotency key, and an
63
- * identity when one is configured), so a scope whose partition key would pass
64
- * DynamoDB's 2048-byte limit is refused here with an error that names the
65
- * limit, rather than reaching the table and coming back as a
66
- * ValidationException that reads as "the table is broken".
72
+ * identity when one is configured), so a partition key past DynamoDB's
73
+ * 2048-byte limit is refused here with an error naming the limit, rather
74
+ * than coming back from the table as a ValidationException that reads as
75
+ * "the table is broken".
67
76
  *
68
77
  * Table shape: string hash key `pk`, string range key `sk`, TTL on
69
78
  * `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
@@ -148,8 +157,13 @@ export class LambderDdbIdempotencyStore {
148
157
  statusCode: storedNumber(item.statusCode?.N, 200),
149
158
  headers: LambderDdbIdempotencyStore.readItemHeaders(item),
150
159
  body: await LambderDdbIdempotencyStore.readItemBody(item),
160
+ fingerprint: LambderDdbIdempotencyStore.fingerprintOf(item),
151
161
  };
152
162
  }
163
+ /** The request fingerprint an item keeps; see UNKNOWN_REQUEST_FINGERPRINT for one that keeps none. */
164
+ static fingerprintOf(item) {
165
+ return item.fingerprint?.S ?? UNKNOWN_REQUEST_FINGERPRINT;
166
+ }
153
167
  /**
154
168
  * Read the scope without claiming it: the stored response when a
155
169
  * completed, unexpired record exists, null otherwise (absent, pending, or
@@ -174,10 +188,17 @@ export class LambderDdbIdempotencyStore {
174
188
  * returned ownerToken) and must call complete() or abandon(); "pending"
175
189
  * means another request owns it right now; "done" carries the stored
176
190
  * response to replay.
191
+ *
192
+ * One write either way: a refused claim hands back the item that refused
193
+ * it (ALL_OLD), so there is no read after it. That item is also how a
194
+ * claim the SDK retried after it had already landed recognizes itself:
195
+ * the item carries this call's own ownerToken, so the scope is ours
196
+ * rather than somebody else's in-flight original.
177
197
  */
178
- async begin(scopeKey, { pendingTtlSeconds }) {
198
+ async begin(scopeKey, { pendingTtlSeconds, fingerprint }) {
179
199
  const nowSeconds = this.nowSeconds();
180
200
  const ownerToken = await newOwnerToken();
201
+ let item;
181
202
  try {
182
203
  const { client, sdk } = await this.ready();
183
204
  await client.send(new sdk.PutItemCommand({
@@ -186,33 +207,33 @@ export class LambderDdbIdempotencyStore {
186
207
  ...this.itemKey(scopeKey),
187
208
  state: { S: "pending" },
188
209
  ownerToken: { S: ownerToken },
210
+ fingerprint: { S: fingerprint },
189
211
  expiresAt: { N: String(nowSeconds + pendingTtlSeconds) },
190
212
  },
191
213
  // Every clause is one a missing attribute can satisfy rather
192
214
  // than block: DynamoDB reads a comparison whose operand path
193
- // is absent as FALSE, so a condition that only asked
194
- // `expiresAt <= :now` refused an item carrying no expiry for
195
- // ever, and a pending one of those deadlocked its scope with
196
- // no TTL able to retire it.
215
+ // is absent as FALSE, so `expiresAt <= :now` alone would
216
+ // refuse an item carrying no expiry for ever, and a pending
217
+ // one would deadlock its scope with no TTL able to retire it.
197
218
  ConditionExpression: "attribute_not_exists(pk) OR attribute_not_exists(expiresAt) OR expiresAt <= :now",
198
219
  ExpressionAttributeValues: { ":now": { N: String(nowSeconds) } },
220
+ ReturnValuesOnConditionCheckFailure: "ALL_OLD",
199
221
  }));
200
222
  return { state: "new", ownerToken };
201
223
  }
202
224
  catch (error) {
203
225
  if (!isConditionalCheckFailure(error))
204
226
  throw error;
227
+ item = error.Item;
205
228
  }
206
- const { client, sdk } = await this.ready();
207
- const existing = await client.send(new sdk.GetItemCommand({
208
- TableName: this.tableName,
209
- Key: this.itemKey(scopeKey),
210
- ConsistentRead: true,
211
- }));
212
- const item = existing.Item;
213
- // Deleted between the put and the read: treat as in-flight, the retry resolves it.
229
+ // Refused with no item to show for it: gone again by the time the
230
+ // condition was read, which the next retry resolves. The caller's own
231
+ // fingerprint keeps it the in-flight 409, which a client retries
232
+ // under the same key.
214
233
  if (!item)
215
- return { state: "pending" };
234
+ return { state: "pending", fingerprint };
235
+ if (item.ownerToken?.S === ownerToken)
236
+ return { state: "new", ownerToken };
216
237
  // The same expiry test peek runs, because the condition above cannot
217
238
  // make it: an item whose expiresAt is present but unreadable (a
218
239
  // partial write, another writer on a shared table) refuses the claim
@@ -222,15 +243,13 @@ export class LambderDdbIdempotencyStore {
222
243
  const live = storedNumber(item.expiresAt?.N, 0) > nowSeconds;
223
244
  if (live && item.state?.S === "done")
224
245
  return { state: "done", ...await LambderDdbIdempotencyStore.answerOf(item) };
225
- return { state: "pending" };
246
+ return { state: "pending", fingerprint: LambderDdbIdempotencyStore.fingerprintOf(item) };
226
247
  }
227
248
  /**
228
249
  * Store the response for replays, overwriting the pending claim. Bodies
229
- * from the compression option's minBytes are stored Brotli-compressed
230
- * (they are JSON envelopes, which typically shrink 5-10x), cutting
231
- * DynamoDB write units and letting large responses fit the item budget;
232
- * smaller bodies, or all of them with compression off, stay plain.
233
- * Returns:
250
+ * from the compression option's minBytes up are stored Brotli-compressed
251
+ * (see the class comment); smaller bodies, or all of them with
252
+ * compression off, stay plain. Returns:
234
253
  *
235
254
  * - "stored": the record is in place and will replay.
236
255
  * - "too-large": even compressed, the body exceeds the item budget;
@@ -238,7 +257,7 @@ export class LambderDdbIdempotencyStore {
238
257
  * - "lost": the ownerToken no longer matches, i.e. the claim expired and
239
258
  * a retry took the scope over; nothing was written.
240
259
  */
241
- async complete(scopeKey, ownerToken, { statusCode, headers, body, ttlSeconds }) {
260
+ async complete(scopeKey, ownerToken, { statusCode, headers, body, fingerprint, ttlSeconds }) {
242
261
  const nowSeconds = this.nowSeconds();
243
262
  const rawBody = Buffer.from(body, "utf8");
244
263
  let bodyAttributes;
@@ -275,15 +294,15 @@ export class LambderDdbIdempotencyStore {
275
294
  statusCode: { N: String(statusCode) },
276
295
  headersJson: { S: JSON.stringify(headers) },
277
296
  ...bodyAttributes,
297
+ fingerprint: { S: fingerprint },
278
298
  expiresAt: { N: String(nowSeconds + ttlSeconds) },
279
299
  },
280
300
  // The claim has to be BOTH still owned and still live. Owner
281
- // alone let an owner whose claim had already expired store
282
- // over it, because DynamoDB's TTL deletion is lazy and the
283
- // expired item is usually still sitting there. The memory
284
- // store drops an expired entry on read and answered "lost"
285
- // for the same call, so the two disagreed, and DynamoDB's
286
- // answer depended on whether AWS had got round to the sweep.
301
+ // alone would let an owner whose claim has expired store over
302
+ // it, since DynamoDB's TTL deletion is lazy and the expired
303
+ // item is usually still there; the memory store answers "lost"
304
+ // for the same call, and DynamoDB's answer would depend on
305
+ // whether AWS had run the sweep yet.
287
306
  ConditionExpression: "ownerToken = :owner AND expiresAt > :now",
288
307
  ExpressionAttributeValues: { ":owner": { S: ownerToken }, ":now": { N: String(nowSeconds) } },
289
308
  }));
@@ -298,7 +317,11 @@ export class LambderDdbIdempotencyStore {
298
317
  /**
299
318
  * Release the claim without storing a response (crash, uncacheable
300
319
  * response), so a retry can execute. Conditional on still holding the
301
- * claim; a lost claim makes this a silent no-op.
320
+ * claim AND on its still being pending: a lost claim makes this a silent
321
+ * no-op, and so does a settled record, whose owner token is still the
322
+ * caller's. The engine abandons after a complete() that threw, and one
323
+ * whose response was lost may have landed; deleting its record would
324
+ * hand the retry a free scope, and the operation would run twice.
302
325
  */
303
326
  async abandon(scopeKey, ownerToken) {
304
327
  try {
@@ -306,8 +329,10 @@ export class LambderDdbIdempotencyStore {
306
329
  await client.send(new sdk.DeleteItemCommand({
307
330
  TableName: this.tableName,
308
331
  Key: this.itemKey(scopeKey),
309
- ConditionExpression: "ownerToken = :owner",
310
- ExpressionAttributeValues: { ":owner": { S: ownerToken } },
332
+ // `state` is a DynamoDB reserved word, hence the name placeholder.
333
+ ConditionExpression: "ownerToken = :owner AND #state = :pending",
334
+ ExpressionAttributeNames: { "#state": "state" },
335
+ ExpressionAttributeValues: { ":owner": { S: ownerToken }, ":pending": { S: "pending" } },
311
336
  }));
312
337
  }
313
338
  catch (error) {
@@ -19,29 +19,61 @@ export interface LambderDdbRateLimiterOptions {
19
19
  /**
20
20
  * Fixed-window rate limiter backed by DynamoDB.
21
21
  *
22
- * Each window is a single item counted with a conditional `ADD`, so the
23
- * increment and the limit check happen atomically in one request. Windows are
24
- * evaluated from smallest to largest and evaluation stops at the first
25
- * exceeded window, which keeps blocked requests cheap and spares the larger
26
- * counters. Attempts count, not successes: a counter checked before the
27
- * refusing one keeps its increment (there is no compensating decrement, which
28
- * would give up the conditional-ADD atomicity). Items carry an `expiresAt`
29
- * attribute for DynamoDB TTL.
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.
30
29
  *
31
30
  * The tracker key is caller data (an address, a session key, whatever a
32
31
  * policy handler returned), so a key whose partition key would pass
33
- * DynamoDB's 2048-byte limit is refused here, before any window is counted,
34
- * rather than reaching the table and coming back as a ValidationException:
35
- * that is not a conditional-check failure, so it escapes as a store error and
36
- * a caller failing open on it counts nothing at all, which is the limit
37
- * silently off. Lambder's own engine folds an over-long key into a digest
38
- * long before this, so a key that gets here came from a direct caller.
32
+ * DynamoDB's 2048-byte limit is refused here, before any window is counted.
33
+ * At the table it would come back as a ValidationException, which is not a
34
+ * conditional-check failure: it would escape as a store error, and a caller
35
+ * failing open on it would count nothing, leaving the limit silently off.
36
+ * Lambder's own engine folds an over-long key into a digest first, so a key
37
+ * that gets here came from a direct caller.
39
38
  *
40
- * A DynamoDB error propagates: a limiter says whether the caller is over its
41
- * limit, and it cannot answer that question when it cannot reach the table.
42
- * Whether an unanswerable limit lets the request through is the application's
43
- * call, not the storage's, so it is made once for every limiter at
44
- * `rateLimits.failOpen` and the engine there handles the throw.
39
+ * A DynamoDB error propagates: a limiter cannot say whether the caller is
40
+ * over its limit when it cannot reach the table, and whether an unanswerable
41
+ * limit lets the request through is the application's call, made once for
42
+ * every limiter at `rateLimits.failOpen`.
43
+ *
44
+ * A throttle on the key's range can be an answer instead. DynamoDB
45
+ * throttles a partition's writes at roughly a thousand a second, and the SDK
46
+ * retries first, so a throttle whose reason is KeyRangeThroughputExceeded
47
+ * means the partition holding this counter is flooded. A partition holds a
48
+ * range of keys, though, not one: a flood on one address, or a session or
49
+ * 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
53
+ * GetItem, in parallel (reads have their own throughput, which the throttled
54
+ * writes leave alone). A key at or over the limit of any of them is the
55
+ * flood: it is refused, since passed on as a failure `failOpen` would wave
56
+ * the flood through unmetered, and the Retry-After is a few seconds, the
57
+ * time the partition takes to recover, rather than the window's reset. A key
58
+ * over its daily cap whose per-minute counter has just started again is the
59
+ * flood as much as one over its per-minute cap. Under every limit, or when a
60
+ * read fails too, the key is a neighbour: the throttle propagates like any
61
+ * other store failure and `failOpen` decides, as it does for a throttle of
62
+ * the table or the account (its provisioned capacity, an on-demand maximum,
63
+ * the account's quota), so no caller under its limit is refused because the
64
+ * table is busy with somebody else.
65
+ *
66
+ * A flood repeats, and each repeat would cost the partition another
67
+ * throttled write and another consistent read, until the reads throttle too
68
+ * and the flood fails open. So the process remembers, for
69
+ * THROTTLED_RETRY_SECONDS, each window it read at its limit, and refuses the
70
+ * key's next attempts from memory without touching the table, which also
71
+ * lets the partition recover for the neighbours. A count only rises within
72
+ * its window, so the table would answer the same. A window whose read was
73
+ * throttled as well is remembered too, with the throttle it was answered
74
+ * with: a next attempt whose write is throttled there again skips the read
75
+ * and throws that same throttle, which `rateLimits.failOpen` logs once
76
+ * rather than once per request.
45
77
  *
46
78
  * Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
47
79
  * Items are prefixed `RL#` by default, so the table can be shared with
@@ -55,7 +87,11 @@ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
55
87
  private readonly ready;
56
88
  private readonly ttlWindowMultiplier;
57
89
  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;
58
92
  constructor(options: LambderDdbRateLimiterOptions);
93
+ /** The clock the windows are computed against, for the engine's Retry-After. */
94
+ clockMilliseconds(): number;
59
95
  /**
60
96
  * Increment every configured window for `trackerKey` (IP, session, user id, ...)
61
97
  * and report whether any of them is over its limit, with the window's
@@ -64,7 +100,27 @@ export declare class LambderDdbRateLimiter implements LambderRateLimiter {
64
100
  isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
65
101
  /** The item's partition key, refused when the tracker key makes it one DynamoDB will not take. */
66
102
  private partitionKeyFor;
67
- /** Increments one window counter. Returns true when the limit was already reached. */
103
+ /**
104
+ * Increments one window counter. True when the limit was already reached
105
+ * (the refused condition is the limiter's own answer); any other failure
106
+ * throws.
107
+ */
68
108
  private incrementWindow;
109
+ /**
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.
115
+ *
116
+ * Each count is read strongly consistent: the count that decides is the
117
+ * one the throttled writes were racing to raise, and a replica lagging
118
+ * behind them could read a key that has just reached its limit as under
119
+ * it. Any window at or over its limit refuses, and is remembered (see the
120
+ * class doc). Otherwise the throttle is thrown on, and when a read was
121
+ * throttled too, the throttled window is remembered with it, so the
122
+ * key's next attempts skip the read and throw that same throttle.
123
+ */
124
+ private answerKeyRangeThrottle;
69
125
  }
70
126
  export default LambderDdbRateLimiter;