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.
- package/CHANGELOG.md +1021 -3
- package/README.md +43 -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 +74 -61
- package/dist/api/LambderApiIdempotency.js +226 -151
- package/dist/api/LambderApiOutputValidationError.d.ts +32 -0
- package/dist/api/LambderApiOutputValidationError.js +50 -0
- package/dist/api/LambderApiPipeline.d.ts +77 -39
- package/dist/api/LambderApiPipeline.js +135 -62
- package/dist/api/LambderApiRateLimits.d.ts +208 -54
- package/dist/api/LambderApiRateLimits.js +197 -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 +161 -69
- package/dist/core/Lambder.js +370 -226
- 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 +28 -7
- package/dist/core/LambderFiles.js +73 -33
- 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 +44 -10
- package/dist/invoke/LambderLambdaEvent.js +80 -37
- package/dist/invoke/lambderHandlerTransport.d.ts +12 -10
- package/dist/invoke/lambderHandlerTransport.js +16 -19
- 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 +3 -1
- package/dist/mock.js +5 -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 +136 -47
- package/dist/session/LambderSessionManager.js +280 -139
- 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/LambderTestingDoors.d.ts +29 -0
- package/dist/shared/util/LambderTestingDoors.js +29 -0
- 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 +79 -0
- package/dist/shared/wire/LambderOutcomeAssertions.js +112 -0
- 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 +176 -0
- package/dist/testing/LambderTestApp.js +204 -0
- package/dist/testing/LambderTestVisitor.d.ts +153 -0
- package/dist/testing/LambderTestVisitor.js +154 -0
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +24 -0
- package/package.json +20 -3
- package/dist/api/LambderApiPolicyEngine.d.ts +0 -36
- package/dist/api/LambderApiPolicyEngine.js +0 -77
- package/dist/shared/util/LambderKeyFields.d.ts +0 -32
- package/dist/shared/util/LambderKeyFields.js +0 -34
|
@@ -1,70 +1,132 @@
|
|
|
1
1
|
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
2
2
|
import { LambderApiRefusal, LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
3
|
-
import { joinKeyFields } from "../shared/util/
|
|
3
|
+
import { joinKeyFields } from "../shared/util/joinKeyFields.js";
|
|
4
|
+
import { boundKeyField } from "../shared/util/boundKeyField.js";
|
|
4
5
|
import { assertPositiveInteger } from "../shared/util/LambderOptionChecks.js";
|
|
6
|
+
import { LAMBDER_BACKEND_SWAP } from "../shared/util/LambderTestingDoors.js";
|
|
7
|
+
import { canonicalJson } from "../shared/util/canonicalJson.js";
|
|
8
|
+
import { sha256HexOf } from "../shared/util/LambderTextDigest.js";
|
|
9
|
+
import { crashAnswer } from "./LambderApiEnvelope.js";
|
|
10
|
+
import { LambderApiOutputValidationError } from "./LambderApiOutputValidationError.js";
|
|
5
11
|
/**
|
|
6
|
-
* A
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* its own handler is still working hands the next retry a free scope, so the
|
|
13
|
-
* operation runs a second time, which is the one thing idempotency exists to
|
|
14
|
-
* prevent. An app whose handlers can run long should raise it to just past
|
|
15
|
-
* its own timeout through `idempotency: { pendingTtlSeconds }`, or per API.
|
|
12
|
+
* A pending claim expires on its own so a crashed original cannot block
|
|
13
|
+
* retries forever. Five minutes covers most handlers, but the claim must
|
|
14
|
+
* outlive its handler: a Lambda may run for fifteen minutes, and a claim that
|
|
15
|
+
* expires mid-run hands the next retry a free scope, so the operation runs
|
|
16
|
+
* twice. Apps with long handlers raise it past their timeout through
|
|
17
|
+
* `idempotency: { pendingTtlSeconds }`, or per API.
|
|
16
18
|
*/
|
|
17
19
|
const DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS = 300;
|
|
18
20
|
/**
|
|
19
21
|
* Keys must be unguessable: without a session, the replay scope is the key
|
|
20
22
|
* itself, so a guessable key would let one client read another's stored
|
|
21
|
-
* response.
|
|
23
|
+
* response. createIdempotencyKey() returns 36 chars.
|
|
22
24
|
*/
|
|
23
25
|
const IDEMPOTENCY_MIN_KEY_LENGTH = 16;
|
|
24
26
|
const IDEMPOTENCY_MAX_KEY_LENGTH = 200;
|
|
25
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* An answer's headers with the map and every value list copied, on the way
|
|
29
|
+
* into a store and on the way out of one. The pipeline applies the CALL's
|
|
30
|
+
* headers into the answer's own map as the call ends, so a custom store that
|
|
31
|
+
* kept the object it was given, or handed back the one it keeps (the
|
|
32
|
+
* interface asks for copies both ways, but nothing enforces it), would store
|
|
33
|
+
* one call's Set-Cookie and replay it to everyone.
|
|
34
|
+
*/
|
|
35
|
+
const copyAnswerHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, values]) => [name, [...values]]));
|
|
36
|
+
/**
|
|
37
|
+
* A stored record as an answer: the three fields, nothing else, and the
|
|
38
|
+
* headers copied (see copyAnswerHeaders). The pipeline applies the replaying
|
|
39
|
+
* call's own headers into the answer's map, so a store that handed back the
|
|
40
|
+
* object it keeps would have this call's Set-Cookie written into its record
|
|
41
|
+
* and replayed to the next caller.
|
|
42
|
+
*/
|
|
26
43
|
const answerFromRecord = (record) => ({
|
|
27
44
|
statusCode: record.statusCode,
|
|
28
|
-
headers: record.headers,
|
|
45
|
+
headers: copyAnswerHeaders(record.headers),
|
|
29
46
|
body: record.body,
|
|
30
47
|
});
|
|
31
48
|
/**
|
|
32
|
-
* A replay window a store can act on
|
|
33
|
-
* its own windows. NaN was the one that mattered: it survives every
|
|
49
|
+
* A replay window a store can act on. NaN matters most: it survives every
|
|
34
50
|
* comparison an expiry test makes, so an in-memory record with a NaN expiry
|
|
35
|
-
* outlives every sweep, while DynamoDB rejects the
|
|
36
|
-
*
|
|
37
|
-
*
|
|
51
|
+
* outlives every sweep, while DynamoDB rejects the number outright. Zero is
|
|
52
|
+
* refused too, since a record that expires as it is written silently turns
|
|
53
|
+
* replay off for the API that asked for it.
|
|
38
54
|
*/
|
|
39
55
|
const assertReplayTtl = (subject, ttlSeconds) => {
|
|
40
56
|
if (ttlSeconds === undefined)
|
|
41
57
|
return;
|
|
42
|
-
// The shared positive-integer check, with the subject naming the option;
|
|
43
|
-
// the wording "a replay window is a positive whole number of seconds"
|
|
44
|
-
// lived here alone and said the same thing in different words.
|
|
45
58
|
assertPositiveInteger(ttlSeconds, `${subject} (a replay window in whole seconds)`);
|
|
46
59
|
};
|
|
47
|
-
/** A per-API replay window, falling back to the configured default.
|
|
60
|
+
/** A per-API replay window, falling back to the configured default. */
|
|
48
61
|
const resolveWindowSeconds = (config, field, fallback) => (typeof config === "object" ? config[field] : undefined) ?? fallback;
|
|
49
62
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* into the very object handed here. A store that keeps the reference it was
|
|
55
|
-
* given (the interface asks for a copy, and the shipped stores make one, but
|
|
56
|
-
* a custom store is under no compiler's supervision) would have this call's
|
|
57
|
-
* Set-Cookie become part of the stored record and replay to everyone.
|
|
63
|
+
* Whether a handler's answer is stored for replays. A 5xx is not: the
|
|
64
|
+
* operation may not have happened, and a retry should try it. Nor is a
|
|
65
|
+
* Set-Cookie (replaying another request's session cookies would be wrong),
|
|
66
|
+
* or a binary body.
|
|
58
67
|
*/
|
|
59
|
-
const
|
|
68
|
+
const isReplayableAnswer = (answer) => answer.statusCode < 500
|
|
69
|
+
&& !answer.isBodyBase64
|
|
70
|
+
&& getAnswerHeader(answer.headers, "Set-Cookie") === undefined;
|
|
71
|
+
/**
|
|
72
|
+
* The refusal for a key that already belongs to a different request. Without
|
|
73
|
+
* it the second request would get the first one's stored answer: a corrected
|
|
74
|
+
* order (qty 2 after a refused qty 10) would get the stale refusal for a day,
|
|
75
|
+
* and an edited retry after a timeout (qty 4 after a processed qty 3) would
|
|
76
|
+
* get the first order's success though only the first was placed.
|
|
77
|
+
*/
|
|
78
|
+
const keyReusedRefusal = (apiName) => new LambderApiRefusal(`Idempotency key reused for a different request to "${apiName}".`, {
|
|
79
|
+
statusCode: 409,
|
|
80
|
+
errorMessage: {
|
|
81
|
+
type: "error",
|
|
82
|
+
code: LAMBDER_REFUSAL_CODES.idempotencyKeyReused,
|
|
83
|
+
content: "This request key was already used for a different request. Start the operation again.",
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* A request's fingerprint: what makes two requests under one key the same
|
|
88
|
+
* request. The payload as it was posted, as canonical JSON so key order does
|
|
89
|
+
* not matter, digested so a record keeps 64 characters rather than the
|
|
90
|
+
* payload. Guard inputs stay out: a captcha or proof token is single use, so
|
|
91
|
+
* a genuine retry carries a new one, and a fingerprint over it would refuse
|
|
92
|
+
* that retry as another request. A runtime without
|
|
93
|
+
* WebCrypto (the mock on a page served over plain http, a phone on the LAN)
|
|
94
|
+
* keeps the canonical JSON itself: a fingerprint is only compared with ones
|
|
95
|
+
* the same runtime took, so its form does not matter, only that one payload
|
|
96
|
+
* always gives the same one. Either form is non-empty (canonical JSON is at
|
|
97
|
+
* least `null`), which is what lets a store report the empty string for a
|
|
98
|
+
* record it cannot tie to any request.
|
|
99
|
+
*/
|
|
100
|
+
const requestFingerprintOf = async (payload) => {
|
|
101
|
+
let canonical;
|
|
102
|
+
try {
|
|
103
|
+
canonical = canonicalJson(payload ?? null);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Sorting the keys recurses once per nesting level, so a payload
|
|
107
|
+
// nested deep enough to exhaust the stack is refused as the caller's
|
|
108
|
+
// error rather than answered as a crash: nothing an app posts nests
|
|
109
|
+
// that deep, and the schema would refuse it a step later anyway.
|
|
110
|
+
const content = "Invalid request payload: nested too deep to fingerprint.";
|
|
111
|
+
throw new LambderApiRefusal(content, {
|
|
112
|
+
statusCode: 400,
|
|
113
|
+
errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content },
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return await sha256HexOf(canonical);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return canonical;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
60
123
|
/**
|
|
61
124
|
* A store failure the engine decided to ignore, said out loud. Failing open
|
|
62
|
-
* is the right default
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* caller's identity and their posted key.
|
|
125
|
+
* is the right default, but silently it looks exactly like working: permanent
|
|
126
|
+
* failures (a missing table, a missing IAM action, an SDK that would not
|
|
127
|
+
* install) could have an app execute every retry twice for months with
|
|
128
|
+
* nothing in its logs. The scope key is left out, since it carries the
|
|
129
|
+
* caller's identity and posted key.
|
|
68
130
|
*/
|
|
69
131
|
const reportFailOpen = (apiName, attempted, err) => {
|
|
70
132
|
console.error(`Lambder idempotency: "${apiName}" could not ${attempted}; the request is being executed as if it carried no idempotency key. ` +
|
|
@@ -73,24 +135,20 @@ const reportFailOpen = (apiName, attempted, err) => {
|
|
|
73
135
|
/**
|
|
74
136
|
* Runtime side of the idempotency subsystem: claims a per-operation scope
|
|
75
137
|
* around handler execution, replays stored answers, and settles claims.
|
|
76
|
-
*
|
|
138
|
+
* Held by LambderApiPipeline. Works on plain answers, so it runs
|
|
77
139
|
* unchanged under the server and the mock runtime.
|
|
78
140
|
*/
|
|
79
141
|
export class LambderApiIdempotencyEngine {
|
|
142
|
+
/** Stamped on the crash answer the engine records for an answer that broke its output schema. */
|
|
143
|
+
apiVersion;
|
|
80
144
|
store = null;
|
|
81
145
|
defaultTtlSeconds = 24 * 3600;
|
|
82
146
|
defaultPendingTtlSeconds = DEFAULT_IDEMPOTENCY_PENDING_TTL_SECONDS;
|
|
83
147
|
failOpen = true;
|
|
84
148
|
callerIdentity = undefined;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
* twice, at the replay lookup and at the claim, and callerIdentity is app
|
|
89
|
-
* code that may verify a token or read a store: running it twice per
|
|
90
|
-
* request is a cost the app never asked for, and one it cannot see.
|
|
91
|
-
* Entries go when the call's context does.
|
|
92
|
-
*/
|
|
93
|
-
scopeByCall = new WeakMap();
|
|
149
|
+
constructor(apiVersion) {
|
|
150
|
+
this.apiVersion = apiVersion;
|
|
151
|
+
}
|
|
94
152
|
configure(config) {
|
|
95
153
|
if (this.store)
|
|
96
154
|
throw new Error("Lambder: idempotency was already configured.");
|
|
@@ -102,6 +160,17 @@ export class LambderApiIdempotencyEngine {
|
|
|
102
160
|
this.failOpen = config.failOpen ?? true;
|
|
103
161
|
this.callerIdentity = config.callerIdentity;
|
|
104
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Puts the engine over another store, for `lambder/testing`; the replay
|
|
165
|
+
* TTLs, failOpen and callerIdentity stay as configured. False when
|
|
166
|
+
* idempotency was never configured.
|
|
167
|
+
*/
|
|
168
|
+
[LAMBDER_BACKEND_SWAP](store) {
|
|
169
|
+
if (!this.store)
|
|
170
|
+
return false;
|
|
171
|
+
this.store = store;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
105
174
|
/** Startup validation of one API registration's idempotency option. */
|
|
106
175
|
assertRegistration(apiName, config) {
|
|
107
176
|
if (typeof config !== "object")
|
|
@@ -130,61 +199,69 @@ export class LambderApiIdempotencyEngine {
|
|
|
130
199
|
return rawKey;
|
|
131
200
|
}
|
|
132
201
|
/**
|
|
133
|
-
* The record's scope. Session APIs scope per
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* for (a timeout
|
|
139
|
-
* different IP.
|
|
140
|
-
* give callerIdentity, since the replay is
|
|
202
|
+
* The record's scope. Session APIs scope per user (the sessionKey, which
|
|
203
|
+
* every session of one user shares), so even a leaked key cannot cross
|
|
204
|
+
* users. Public APIs scope by the key alone (it must be
|
|
205
|
+
* long and is documented to be random) unless the app supplies
|
|
206
|
+
* callerIdentity. The client IP is deliberately NOT part of the scope:
|
|
207
|
+
* the retry idempotency exists for (a timeout, then a network change)
|
|
208
|
+
* often arrives from a different IP. Apps whose public APIs are
|
|
209
|
+
* authorized by a guard should give callerIdentity, since the replay is
|
|
210
|
+
* served before guards run.
|
|
141
211
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
212
|
+
* joinKeyFields escapes the fields, so no two distinct scopes collide.
|
|
213
|
+
* The identity field is the caller's (a device token in the docs' own
|
|
214
|
+
* example), so it is bounded first (boundKeyField): an over-long one
|
|
215
|
+
* would push the scope past a store's key limit, and the store's refusal
|
|
216
|
+
* is a throw that failOpen turns into no idempotency for that caller.
|
|
144
217
|
*/
|
|
145
218
|
async scopeOf(apiName, ctx, request, key) {
|
|
146
|
-
const cached = this.scopeByCall.get(ctx);
|
|
147
|
-
if (cached)
|
|
148
|
-
return await cached;
|
|
149
|
-
const computed = this.computeScope(apiName, ctx, request, key);
|
|
150
|
-
this.scopeByCall.set(ctx, computed);
|
|
151
|
-
return await computed;
|
|
152
|
-
}
|
|
153
|
-
async computeScope(apiName, ctx, request, key) {
|
|
154
219
|
const sessionKey = ctx.session?.sessionKey;
|
|
155
220
|
if (sessionKey)
|
|
156
|
-
return joinKeyFields(
|
|
221
|
+
return joinKeyFields(await boundKeyField("s", sessionKey), apiName, key);
|
|
157
222
|
// No session to scope by. The app may still say who this is, through
|
|
158
223
|
// callerIdentity; without one the key alone is the scope, which is
|
|
159
224
|
// what makes it a bearer token for its own answer.
|
|
160
225
|
const identity = this.callerIdentity ? await this.callerIdentity(ctx, request) : null;
|
|
161
226
|
return identity
|
|
162
|
-
? joinKeyFields(
|
|
227
|
+
? joinKeyFields(await boundKeyField("i", identity), apiName, key)
|
|
163
228
|
: joinKeyFields("k", apiName, key);
|
|
164
229
|
}
|
|
165
230
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* setting.
|
|
231
|
+
* The call's key worked out, once per call: its scope and its request's
|
|
232
|
+
* fingerprint, for findReplay and withIdempotency. Null for a call that
|
|
233
|
+
* sends no key, or when idempotency is not configured; a malformed key
|
|
234
|
+
* refuses with a 400. Taken at the replay lookup, before input validation
|
|
235
|
+
* replaces the payload with its parsed form, so the fingerprint is of the
|
|
236
|
+
* request as it was posted, and callerIdentity (app code that may verify
|
|
237
|
+
* a token or read a store) runs once.
|
|
174
238
|
*/
|
|
175
|
-
async
|
|
176
|
-
|
|
177
|
-
if (!store)
|
|
239
|
+
async resolveKeyedCall(apiName, request, ctx) {
|
|
240
|
+
if (!this.store)
|
|
178
241
|
return null;
|
|
179
242
|
const key = this.readKey(request);
|
|
180
243
|
if (key === null)
|
|
181
244
|
return null;
|
|
245
|
+
return {
|
|
246
|
+
scopeKey: await this.scopeOf(apiName, ctx, request, key),
|
|
247
|
+
fingerprint: await requestFingerprintOf(request.payload),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Replay fast path, run before guards and all rate limits except
|
|
252
|
+
* `per: "ip"`: a completed record returns its stored answer, so a
|
|
253
|
+
* legitimate retry neither burns quota nor re-runs guards (the original
|
|
254
|
+
* passed them, and no handler executes). The IP limits run first because
|
|
255
|
+
* the store read a replay costs is one of the things they bound. Misses
|
|
256
|
+
* fall through to the normal pipeline; store errors follow failOpen.
|
|
257
|
+
*/
|
|
258
|
+
async findReplay(apiName, call, trace) {
|
|
259
|
+
const store = this.store;
|
|
260
|
+
if (!store)
|
|
261
|
+
return null;
|
|
262
|
+
let done;
|
|
182
263
|
try {
|
|
183
|
-
|
|
184
|
-
if (!done)
|
|
185
|
-
return null;
|
|
186
|
-
trace.replayed = true;
|
|
187
|
-
return answerFromRecord(done);
|
|
264
|
+
done = await store.peek(call.scopeKey);
|
|
188
265
|
}
|
|
189
266
|
catch (err) {
|
|
190
267
|
if (!this.failOpen)
|
|
@@ -192,36 +269,36 @@ export class LambderApiIdempotencyEngine {
|
|
|
192
269
|
reportFailOpen(apiName, "look its replay record up", err);
|
|
193
270
|
return null;
|
|
194
271
|
}
|
|
272
|
+
if (!done)
|
|
273
|
+
return null;
|
|
274
|
+
if (done.fingerprint !== call.fingerprint)
|
|
275
|
+
throw keyReusedRefusal(apiName);
|
|
276
|
+
trace.replayed = true;
|
|
277
|
+
return answerFromRecord(done);
|
|
195
278
|
}
|
|
196
279
|
/**
|
|
197
|
-
* Idempotency wrapper around validation-passed handler execution
|
|
198
|
-
* a
|
|
199
|
-
* (identity + api + key) is claimed atomically: duplicates of an
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* so a retry actually retries.
|
|
280
|
+
* Idempotency wrapper around validation-passed handler execution, for a
|
|
281
|
+
* call resolveKeyedCall found a key on (a keyless call runs its handler
|
|
282
|
+
* without one): the scope (identity + api + key) is claimed atomically: duplicates of an in-flight original
|
|
283
|
+
* refuse with 409, replays of a completed one return the stored answer
|
|
284
|
+
* verbatim, and a crashed original releases its claim so a retry retries.
|
|
203
285
|
*
|
|
204
|
-
* `exec`
|
|
205
|
-
*
|
|
206
|
-
* so a Set-Cookie the handler set is visible to the caching rule below.
|
|
286
|
+
* `exec` returns the handler's own answer with the headers the handler
|
|
287
|
+
* wrote, so a Set-Cookie it set is visible to the caching rule below.
|
|
207
288
|
* Headers written EARLIER in the call (a session read evicting a stale
|
|
208
|
-
* cookie) are deliberately
|
|
209
|
-
*
|
|
210
|
-
*
|
|
289
|
+
* cookie) are deliberately left off: they reach the client either way,
|
|
290
|
+
* and charging them to this answer would make it uncacheable, silently
|
|
291
|
+
* ending the operation's idempotency.
|
|
211
292
|
*/
|
|
212
|
-
async withIdempotency(apiName,
|
|
293
|
+
async withIdempotency(apiName, { scopeKey, fingerprint }, config, trace, exec) {
|
|
213
294
|
const store = this.store;
|
|
214
295
|
if (!store)
|
|
215
296
|
return await exec();
|
|
216
|
-
const rawKey = this.readKey(request);
|
|
217
|
-
if (rawKey === null)
|
|
218
|
-
return await exec();
|
|
219
297
|
const ttlSeconds = resolveWindowSeconds(config, "ttlSeconds", this.defaultTtlSeconds);
|
|
220
298
|
const pendingTtlSeconds = resolveWindowSeconds(config, "pendingTtlSeconds", this.defaultPendingTtlSeconds);
|
|
221
|
-
const scopeKey = await this.scopeOf(apiName, ctx, request, rawKey);
|
|
222
299
|
let begun;
|
|
223
300
|
try {
|
|
224
|
-
begun = await store.begin(scopeKey, { pendingTtlSeconds });
|
|
301
|
+
begun = await store.begin(scopeKey, { pendingTtlSeconds, fingerprint });
|
|
225
302
|
}
|
|
226
303
|
catch (err) {
|
|
227
304
|
if (!this.failOpen)
|
|
@@ -229,6 +306,8 @@ export class LambderApiIdempotencyEngine {
|
|
|
229
306
|
reportFailOpen(apiName, "claim its scope", err);
|
|
230
307
|
return await exec();
|
|
231
308
|
}
|
|
309
|
+
if (begun.state !== "new" && begun.fingerprint !== fingerprint)
|
|
310
|
+
throw keyReusedRefusal(apiName);
|
|
232
311
|
if (begun.state === "pending") {
|
|
233
312
|
throw new LambderApiRefusal(`Duplicate request for "${apiName}": the original is still processing.`, {
|
|
234
313
|
statusCode: 409,
|
|
@@ -236,43 +315,34 @@ export class LambderApiIdempotencyEngine {
|
|
|
236
315
|
});
|
|
237
316
|
}
|
|
238
317
|
// The other replay path: the original settled between this request's
|
|
239
|
-
// peek and its claim
|
|
240
|
-
//
|
|
318
|
+
// peek and its claim. No handler runs, so it is a replay like any
|
|
319
|
+
// other.
|
|
241
320
|
if (begun.state === "done") {
|
|
242
321
|
trace.replayed = true;
|
|
243
322
|
return answerFromRecord(begun);
|
|
244
323
|
}
|
|
245
324
|
const ownerToken = begun.ownerToken;
|
|
246
325
|
// Store the answer for replays when it qualifies, release the claim
|
|
247
|
-
// otherwise.
|
|
248
|
-
// another request's cookies, e.g. session tokens, would be wrong), and
|
|
249
|
-
// so does a binary body.
|
|
326
|
+
// otherwise.
|
|
250
327
|
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
// reported and swallowed.
|
|
259
|
-
const settleClaim = async (answer) => {
|
|
260
|
-
const cacheable = answer.statusCode < 500
|
|
261
|
-
&& !answer.isBodyBase64
|
|
262
|
-
&& getAnswerHeader(answer.headers, "Set-Cookie") === undefined;
|
|
328
|
+
// A settle failure is reported and swallowed, not subject to
|
|
329
|
+
// failOpen: the work is done and the answer is owed to the caller.
|
|
330
|
+
// failOpen governs begin(), where refusing still means not acting.
|
|
331
|
+
// Here it would turn a completed operation into a 500 and hand the
|
|
332
|
+
// retry a released claim, which is the double execution idempotency
|
|
333
|
+
// exists to prevent.
|
|
334
|
+
const settleClaim = async (answer, replayable) => {
|
|
263
335
|
try {
|
|
264
|
-
if (
|
|
265
|
-
// The store owns the size decision:
|
|
266
|
-
//
|
|
267
|
-
//
|
|
336
|
+
if (replayable) {
|
|
337
|
+
// The store owns the size decision: it may compress bodies,
|
|
338
|
+
// and answers "too-large" only when even that exceeds its
|
|
339
|
+
// budget.
|
|
268
340
|
const completion = await store.complete(scopeKey, ownerToken, {
|
|
269
341
|
statusCode: answer.statusCode,
|
|
270
|
-
// Copied
|
|
271
|
-
// apply this call's own headers into answer.headers,
|
|
272
|
-
// and a store that kept the reference would have them
|
|
273
|
-
// in the record.
|
|
342
|
+
// Copied: see copyAnswerHeaders.
|
|
274
343
|
headers: copyAnswerHeaders(answer.headers),
|
|
275
344
|
body: answer.body,
|
|
345
|
+
fingerprint,
|
|
276
346
|
ttlSeconds,
|
|
277
347
|
});
|
|
278
348
|
if (completion !== "too-large")
|
|
@@ -283,41 +353,46 @@ export class LambderApiIdempotencyEngine {
|
|
|
283
353
|
await store.abandon(scopeKey, ownerToken);
|
|
284
354
|
}
|
|
285
355
|
catch (storeErr) {
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
// complete()
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// again costs an execution, while a claim nobody can clear is
|
|
295
|
-
// a caller who cannot get through at all.
|
|
356
|
+
// The failed call may have landed anyway (a complete() whose
|
|
357
|
+
// answer was lost), and abandon() releases only a claim still
|
|
358
|
+
// pending, so one more release settles every case. A
|
|
359
|
+
// complete() that never landed frees the scope, where the
|
|
360
|
+
// pending claim would 409 genuine retries until
|
|
361
|
+
// pendingTtlSeconds ran out; one that landed keeps its record,
|
|
362
|
+
// and the retry replays it rather than running the operation
|
|
363
|
+
// a second time.
|
|
296
364
|
try {
|
|
297
365
|
await store.abandon(scopeKey, ownerToken);
|
|
298
366
|
}
|
|
299
367
|
catch { /* claim expires on its own */ }
|
|
300
|
-
console.warn(`Lambder idempotency: "${apiName}" ran and answered, but its record
|
|
301
|
-
"The caller keeps this answer; a retry under the same key
|
|
368
|
+
console.warn(`Lambder idempotency: "${apiName}" ran and answered, but storing its record failed. ` +
|
|
369
|
+
"The caller keeps this answer; a retry under the same key replays it if the write landed after all, and executes again if it did not.", storeErr);
|
|
302
370
|
}
|
|
303
371
|
};
|
|
304
372
|
try {
|
|
305
373
|
const answer = await exec();
|
|
306
|
-
await settleClaim(answer);
|
|
374
|
+
await settleClaim(answer, isReplayableAnswer(answer));
|
|
307
375
|
return answer;
|
|
308
376
|
}
|
|
309
377
|
catch (err) {
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
//
|
|
320
|
-
//
|
|
378
|
+
// The handler ran to its answer, and the answer broke its output
|
|
379
|
+
// schema: whatever the operation wrote or charged is done, so a
|
|
380
|
+
// released claim would run it again on every retry. The
|
|
381
|
+
// framework's crash answer is recorded as the key's answer
|
|
382
|
+
// instead, and a retry is told the same thing.
|
|
383
|
+
if (err instanceof LambderApiOutputValidationError) {
|
|
384
|
+
await settleClaim(crashAnswer(this.apiVersion), true);
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
// A crash or a thrown refusal (LambderApiRefusal, refuse())
|
|
388
|
+
// releases the claim so a retry retries. The rule is deliberate:
|
|
389
|
+
// ANSWERS are stored and replayed, returned refusal envelopes
|
|
390
|
+
// included; EXCEPTIONS are not, so a thrown refusal re-executes
|
|
391
|
+
// on retry and the handler decides afresh. (On the server,
|
|
392
|
+
// res.die.api() is an answer: the adapter catches it before it
|
|
393
|
+
// reaches here.) The handler's own error is what gets rethrown,
|
|
394
|
+
// since a cleanup error in its place would hide why the call
|
|
395
|
+
// failed.
|
|
321
396
|
try {
|
|
322
397
|
await store.abandon(scopeKey, ownerToken);
|
|
323
398
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A handler answered a payload its API's output schema could not turn into
|
|
4
|
+
* the declared shape, so the payload was not sent and the call is answered
|
|
5
|
+
* as a crash. Either the schema rejected the payload, or the parse threw: an
|
|
6
|
+
* async refinement or transform (the output is parsed synchronously, so an
|
|
7
|
+
* output schema cannot be async), or a transform that threw. The output
|
|
8
|
+
* side's counterpart of LambderApiValidationRefusal, with the API it happened
|
|
9
|
+
* in, for a crash reporter.
|
|
10
|
+
*
|
|
11
|
+
* Its own class because it is a crash after the fact: the handler ran to
|
|
12
|
+
* its answer, whatever it wrote or charged along the way included. The
|
|
13
|
+
* idempotency engine reads it that way and keeps the key's record, so a
|
|
14
|
+
* retry is told the same thing instead of running the operation again.
|
|
15
|
+
*/
|
|
16
|
+
export declare class LambderApiOutputValidationError extends Error {
|
|
17
|
+
readonly apiName: string;
|
|
18
|
+
/**
|
|
19
|
+
* The schema's issues when it rejected the payload. Null when the parse
|
|
20
|
+
* threw instead; what it threw is the `cause`.
|
|
21
|
+
*/
|
|
22
|
+
readonly zodError: z.ZodError | null;
|
|
23
|
+
/**
|
|
24
|
+
* `failure` is how the parse ended: `{ zodError }` when the schema
|
|
25
|
+
* rejected the payload, `{ thrown }` when parsing it threw.
|
|
26
|
+
*/
|
|
27
|
+
constructor(apiName: string, failure: {
|
|
28
|
+
zodError: z.ZodError;
|
|
29
|
+
} | {
|
|
30
|
+
thrown: unknown;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A handler answered a payload its API's output schema could not turn into
|
|
4
|
+
* the declared shape, so the payload was not sent and the call is answered
|
|
5
|
+
* as a crash. Either the schema rejected the payload, or the parse threw: an
|
|
6
|
+
* async refinement or transform (the output is parsed synchronously, so an
|
|
7
|
+
* output schema cannot be async), or a transform that threw. The output
|
|
8
|
+
* side's counterpart of LambderApiValidationRefusal, with the API it happened
|
|
9
|
+
* in, for a crash reporter.
|
|
10
|
+
*
|
|
11
|
+
* Its own class because it is a crash after the fact: the handler ran to
|
|
12
|
+
* its answer, whatever it wrote or charged along the way included. The
|
|
13
|
+
* idempotency engine reads it that way and keeps the key's record, so a
|
|
14
|
+
* retry is told the same thing instead of running the operation again.
|
|
15
|
+
*/
|
|
16
|
+
export class LambderApiOutputValidationError extends Error {
|
|
17
|
+
apiName;
|
|
18
|
+
/**
|
|
19
|
+
* The schema's issues when it rejected the payload. Null when the parse
|
|
20
|
+
* threw instead; what it threw is the `cause`.
|
|
21
|
+
*/
|
|
22
|
+
zodError;
|
|
23
|
+
/**
|
|
24
|
+
* `failure` is how the parse ended: `{ zodError }` when the schema
|
|
25
|
+
* rejected the payload, `{ thrown }` when parsing it threw.
|
|
26
|
+
*/
|
|
27
|
+
constructor(apiName, failure) {
|
|
28
|
+
let message;
|
|
29
|
+
if ("zodError" in failure) {
|
|
30
|
+
// Paths and messages only: an issue's message names the expected
|
|
31
|
+
// type, and the values themselves stay out of a crash report.
|
|
32
|
+
const issues = failure.zodError.issues.slice(0, 3).map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
33
|
+
message = `Lambder: API "${apiName}" answered a payload its output schema does not accept, so it was not sent. ${issues}`;
|
|
34
|
+
}
|
|
35
|
+
else if (failure.thrown instanceof z.core.$ZodAsyncError) {
|
|
36
|
+
message = `Lambder: API "${apiName}" has an async refinement or transform in its output schema, and an output schema cannot be async: `
|
|
37
|
+
+ "a payload is parsed synchronously on its way out. The payload was not sent.";
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// The thrown error's own message stays in the cause: it is the
|
|
41
|
+
// app's text and may carry the value the transform was given.
|
|
42
|
+
message = `Lambder: API "${apiName}" answered a payload its output schema threw on while parsing it (a transform that threw, `
|
|
43
|
+
+ "or an async step a synchronous parse cannot run), so it was not sent. What the schema threw is the cause.";
|
|
44
|
+
}
|
|
45
|
+
super(message, { cause: "zodError" in failure ? failure.zodError : failure.thrown });
|
|
46
|
+
this.name = "LambderApiOutputValidationError";
|
|
47
|
+
this.apiName = apiName;
|
|
48
|
+
this.zodError = "zodError" in failure ? failure.zodError : null;
|
|
49
|
+
}
|
|
50
|
+
}
|