lambder 7.3.1 → 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.
- package/CHANGELOG.md +933 -3
- package/README.md +41 -21
- package/dist/api/LambderApiAnswer.d.ts +18 -22
- package/dist/api/LambderApiAnswer.js +6 -7
- package/dist/api/LambderApiCallContext.d.ts +21 -8
- package/dist/api/LambderApiCallContext.js +22 -4
- package/dist/api/LambderApiDefinition.d.ts +4 -3
- package/dist/api/LambderApiEnvelope.d.ts +14 -9
- package/dist/api/LambderApiEnvelope.js +33 -34
- package/dist/api/LambderApiGuards.d.ts +78 -51
- package/dist/api/LambderApiGuards.js +34 -36
- package/dist/api/LambderApiIdempotency.d.ts +68 -62
- package/dist/api/LambderApiIdempotency.js +214 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +47 -38
- package/dist/api/LambderApiPipeline.js +122 -63
- package/dist/api/LambderApiRateLimits.d.ts +201 -54
- package/dist/api/LambderApiRateLimits.js +185 -108
- package/dist/api/LambderApiRequest.d.ts +27 -21
- package/dist/api/LambderApiRequest.js +26 -19
- package/dist/api/LambderApiSignature.d.ts +12 -15
- package/dist/api/LambderApiSignature.js +28 -51
- package/dist/api/LambderApiValidationRefusal.d.ts +9 -9
- package/dist/api/LambderApiValidationRefusal.js +10 -10
- package/dist/build/freshProcessVerifier.d.ts +13 -0
- package/dist/build/freshProcessVerifier.js +19 -0
- package/dist/build/writeApiSignatures.d.ts +109 -0
- package/dist/build/writeApiSignatures.js +222 -0
- package/dist/build.d.ts +9 -0
- package/dist/build.js +8 -0
- package/dist/client/LambderCaller.d.ts +13 -44
- package/dist/client/LambderCaller.js +77 -84
- package/dist/client/LambderReloadLoopBreaker.d.ts +56 -26
- package/dist/client/LambderReloadLoopBreaker.js +90 -46
- package/dist/client/lambderFetchTransport.d.ts +4 -1
- package/dist/client/lambderFetchTransport.js +52 -28
- package/dist/client.d.ts +5 -3
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +140 -75
- package/dist/core/Lambder.js +347 -227
- package/dist/core/LambderContext.d.ts +82 -15
- package/dist/core/LambderContext.js +107 -20
- package/dist/core/LambderCors.d.ts +21 -3
- package/dist/core/LambderCors.js +35 -16
- package/dist/core/LambderCrashHandling.d.ts +40 -0
- package/dist/core/LambderCrashHandling.js +97 -0
- package/dist/core/LambderCreateOptions.d.ts +151 -75
- package/dist/core/LambderCreateOptions.js +16 -23
- package/dist/core/LambderFiles.d.ts +21 -7
- package/dist/core/LambderFiles.js +62 -34
- package/dist/core/LambderIndexHtml.js +12 -11
- package/dist/core/LambderPolicyBuilders.d.ts +17 -5
- package/dist/core/LambderPolicyBuilders.js +17 -5
- package/dist/core/LambderPublicFiles.d.ts +11 -5
- package/dist/core/LambderPublicFiles.js +32 -4
- package/dist/core/LambderRequestPath.d.ts +43 -0
- package/dist/core/LambderRequestPath.js +63 -0
- package/dist/core/LambderResponse.d.ts +26 -5
- package/dist/core/LambderResponse.js +157 -70
- package/dist/core/LambderResponseBuilder.d.ts +49 -4
- package/dist/core/LambderResponseBuilder.js +64 -3
- package/dist/core/LambderRouting.d.ts +2 -3
- package/dist/core/LambderRouting.js +22 -7
- package/dist/core/LambderTemplatingEngine.js +211 -32
- package/dist/index.d.ts +15 -8
- package/dist/index.js +5 -4
- package/dist/invoke/LambderInvokeCaller.d.ts +37 -42
- package/dist/invoke/LambderInvokeCaller.js +76 -66
- package/dist/invoke/LambderInvokeOutcome.d.ts +27 -26
- package/dist/invoke/LambderInvokeOutcome.js +9 -22
- package/dist/invoke/LambderLambdaEvent.d.ts +29 -9
- package/dist/invoke/LambderLambdaEvent.js +40 -22
- package/dist/invoke/lambderHandlerTransport.d.ts +9 -10
- package/dist/invoke/lambderHandlerTransport.js +15 -18
- package/dist/mock/LambderMockApp.d.ts +67 -83
- package/dist/mock/LambderMockApp.js +167 -153
- package/dist/mock/LambderMockBrowserCookies.d.ts +24 -28
- package/dist/mock/LambderMockBrowserCookies.js +24 -28
- package/dist/mock/LambderMockCallRecorder.d.ts +15 -22
- package/dist/mock/LambderMockCallRecorder.js +19 -28
- package/dist/mock/LambderMockCreateOptions.d.ts +42 -24
- package/dist/mock/LambderMockEntryRegistry.d.ts +11 -12
- package/dist/mock/LambderMockEntryRegistry.js +24 -29
- package/dist/mock/LambderMockFailureInjector.d.ts +3 -6
- package/dist/mock/LambderMockFailureInjector.js +3 -6
- package/dist/mock/LambderMockTypes.d.ts +78 -108
- package/dist/mock/lambderMockInvokeTransport.d.ts +11 -13
- package/dist/mock/lambderMockInvokeTransport.js +11 -10
- package/dist/mock/lambderMockMswHandler.d.ts +33 -29
- package/dist/mock/lambderMockMswHandler.js +50 -39
- package/dist/mock.d.ts +1 -1
- package/dist/mock.js +2 -3
- package/dist/session/LambderSessionController.d.ts +108 -89
- package/dist/session/LambderSessionController.js +187 -168
- package/dist/session/LambderSessionCrypto.d.ts +16 -7
- package/dist/session/LambderSessionCrypto.js +26 -12
- package/dist/session/LambderSessionManager.d.ts +124 -46
- package/dist/session/LambderSessionManager.js +262 -137
- package/dist/shared/LambderHtml.d.ts +42 -3
- package/dist/shared/LambderHtml.js +127 -7
- package/dist/shared/LambderHtmlPositions.d.ts +173 -0
- package/dist/shared/LambderHtmlPositions.js +652 -0
- package/dist/shared/LambderI18n.d.ts +10 -11
- package/dist/shared/LambderI18n.js +33 -21
- package/dist/shared/contracts/LambderCache.d.ts +66 -0
- package/dist/shared/contracts/LambderCache.js +11 -0
- package/dist/shared/contracts/LambderFileSource.d.ts +6 -6
- package/dist/shared/contracts/LambderFileSource.js +5 -8
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +51 -22
- package/dist/shared/contracts/LambderIdempotencyStore.js +4 -5
- package/dist/shared/contracts/LambderRateLimiter.d.ts +27 -15
- package/dist/shared/contracts/LambderRateLimiter.js +4 -5
- package/dist/shared/contracts/LambderSessionStore.d.ts +65 -26
- package/dist/shared/contracts/LambderSessionStore.js +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +27 -27
- package/dist/shared/transport/LambderApiTransport.js +7 -7
- package/dist/shared/transport/LambderCookieJar.d.ts +28 -35
- package/dist/shared/transport/LambderCookieJar.js +54 -66
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +11 -13
- package/dist/shared/transport/lambderCookieJarTransport.js +24 -23
- package/dist/shared/util/LambderCallAbort.d.ts +5 -5
- package/dist/shared/util/LambderCallAbort.js +5 -5
- package/dist/shared/util/LambderClientIp.d.ts +27 -11
- package/dist/shared/util/LambderClientIp.js +96 -13
- package/dist/shared/util/LambderExpiringMap.d.ts +35 -49
- package/dist/shared/util/LambderExpiringMap.js +41 -57
- package/dist/shared/util/LambderNodeModules.js +6 -7
- package/dist/shared/util/LambderOptionChecks.d.ts +4 -4
- package/dist/shared/util/LambderOptionChecks.js +4 -4
- package/dist/shared/util/LambderResponseBrand.d.ts +5 -5
- package/dist/shared/util/LambderResponseBrand.js +5 -5
- package/dist/shared/util/LambderTypeUtilities.d.ts +7 -8
- package/dist/shared/util/LambderTypeUtilities.js +3 -3
- package/dist/shared/util/boundKeyField.d.ts +20 -0
- package/dist/shared/util/boundKeyField.js +34 -0
- package/dist/shared/util/canonicalJson.d.ts +11 -0
- package/dist/shared/util/canonicalJson.js +28 -0
- package/dist/shared/util/joinKeyFields.d.ts +20 -0
- package/dist/shared/util/joinKeyFields.js +22 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +12 -16
- package/dist/shared/wire/LambderAnswerHeaders.js +12 -16
- package/dist/shared/wire/LambderApiContract.d.ts +107 -32
- package/dist/shared/wire/LambderApiOutcome.d.ts +43 -31
- package/dist/shared/wire/LambderApiOutcome.js +48 -23
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -27
- package/dist/shared/wire/LambderApiRefusal.js +36 -7
- package/dist/shared/wire/LambderApiSignature.d.ts +18 -22
- package/dist/shared/wire/LambderApiSignature.js +16 -19
- package/dist/shared/wire/LambderCallOptions.d.ts +38 -47
- package/dist/shared/wire/LambderCallOptions.js +9 -11
- package/dist/shared/wire/LambderCompressionCodec.d.ts +29 -34
- package/dist/shared/wire/LambderCompressionCodec.js +31 -36
- package/dist/shared/wire/LambderCompressionOption.d.ts +9 -9
- package/dist/shared/wire/LambderCompressionOption.js +9 -9
- package/dist/shared/wire/LambderCrashDetail.d.ts +12 -15
- package/dist/shared/wire/LambderCrashDetail.js +12 -15
- package/dist/shared/wire/LambderDefaultApiPath.d.ts +6 -0
- package/dist/shared/wire/LambderDefaultApiPath.js +6 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -7
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +89 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +146 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +27 -0
- package/dist/shared/wire/LambderInvokeApiId.js +27 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +6 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +6 -7
- package/dist/shared/wire/LambderRequestPayload.d.ts +18 -20
- package/dist/shared/wire/LambderRequestPayload.js +4 -6
- package/dist/stores/LambderCacheFiller.d.ts +48 -0
- package/dist/stores/LambderCacheFiller.js +119 -0
- package/dist/stores/LambderCacheKeys.d.ts +26 -0
- package/dist/stores/LambderCacheKeys.js +54 -0
- package/dist/stores/LambderCacheValues.d.ts +45 -0
- package/dist/stores/LambderCacheValues.js +74 -0
- package/dist/stores/LambderDdbCache.d.ts +121 -56
- package/dist/stores/LambderDdbCache.js +528 -225
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +33 -22
- package/dist/stores/LambderDdbIdempotencyStore.js +75 -50
- package/dist/stores/LambderDdbRateLimiter.d.ts +76 -20
- package/dist/stores/LambderDdbRateLimiter.js +151 -39
- package/dist/stores/LambderDdbSdk.d.ts +43 -31
- package/dist/stores/LambderDdbSdk.js +79 -33
- package/dist/stores/LambderDdbSessionStore.d.ts +27 -14
- package/dist/stores/LambderDdbSessionStore.js +119 -47
- package/dist/stores/LambderHttpFileSource.d.ts +15 -6
- package/dist/stores/LambderHttpFileSource.js +15 -13
- package/dist/stores/LambderMemoryCache.d.ts +49 -0
- package/dist/stores/LambderMemoryCache.js +113 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +13 -12
- package/dist/stores/LambderMemoryIdempotencyStore.js +31 -30
- package/dist/stores/LambderMemoryRateLimiter.d.ts +8 -9
- package/dist/stores/LambderMemoryRateLimiter.js +14 -13
- package/dist/stores/LambderMemorySessionStore.d.ts +14 -11
- package/dist/stores/LambderMemorySessionStore.js +38 -19
- package/dist/stores/LambderS3FileSource.d.ts +21 -6
- package/dist/stores/LambderS3FileSource.js +12 -7
- package/dist/testing/LambderTestApp.d.ts +21 -23
- package/dist/testing/LambderTestApp.js +22 -24
- package/dist/testing/LambderTestVisitor.d.ts +10 -12
- package/dist/testing/LambderTestVisitor.js +15 -15
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- package/package.json +12 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -47
- package/dist/api/LambderApiPolicyEngine.js +0 -85
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- 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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 (
|
|
42
|
-
* LambderDdbCache, see the `compression` option):
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
* (
|
|
104
|
-
*
|
|
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
|
|
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,
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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 (
|
|
57
|
-
* LambderDdbCache, see the `compression` option):
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
* (
|
|
231
|
-
*
|
|
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
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
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
|
|
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
|
-
|
|
310
|
-
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
/**
|
|
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;
|