lambder 6.0.1 → 7.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +26 -24
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import type { LambderIdempotencyStore, LambderIdempotencyDoneRecord, LambderIdempotencyBeginResult } from "../shared/contracts/LambderIdempotencyStore.js";
|
|
3
|
+
import { type LambderCompressionOption } from "../shared/wire/LambderCompressionOption.js";
|
|
4
|
+
export interface LambderDdbIdempotencyStoreOptions {
|
|
4
5
|
tableName: string;
|
|
6
|
+
/** Region the client is created for on first use; the SDK's default chain otherwise. */
|
|
5
7
|
region?: string;
|
|
6
8
|
/** Partition key prefix, keeps records separated from other systems in a shared table. Default: "IDEM". */
|
|
7
9
|
keyPrefix?: string;
|
|
@@ -13,21 +15,13 @@ export interface LambderDdbIdempotencyOptions {
|
|
|
13
15
|
*/
|
|
14
16
|
compression?: LambderCompressionOption;
|
|
15
17
|
client?: DynamoDBClient;
|
|
18
|
+
/**
|
|
19
|
+
* The clock claims and records are expired against, injectable the way
|
|
20
|
+
* LambderMemoryIdempotencyStore's is, so the conformance suite can expire a
|
|
21
|
+
* claim in both implementations through one clock.
|
|
22
|
+
*/
|
|
23
|
+
now?: () => number;
|
|
16
24
|
}
|
|
17
|
-
export type LambderIdempotencyDoneRecord = {
|
|
18
|
-
statusCode: number;
|
|
19
|
-
/** Response headers stored with the record (normalized multi-value map). */
|
|
20
|
-
headers: Record<string, string[]>;
|
|
21
|
-
body: string;
|
|
22
|
-
};
|
|
23
|
-
export type LambderIdempotencyBeginResult = {
|
|
24
|
-
state: "new";
|
|
25
|
-
ownerToken: string;
|
|
26
|
-
} | {
|
|
27
|
-
state: "pending";
|
|
28
|
-
} | ({
|
|
29
|
-
state: "done";
|
|
30
|
-
} & LambderIdempotencyDoneRecord);
|
|
31
25
|
/**
|
|
32
26
|
* DynamoDB-backed idempotency records: one item per (identity, api, key)
|
|
33
27
|
* scope, claimed atomically with a conditional put. The first request claims
|
|
@@ -39,7 +33,10 @@ export type LambderIdempotencyBeginResult = {
|
|
|
39
33
|
* Every claim carries a random ownerToken, and complete()/abandon() are
|
|
40
34
|
* conditional on still holding it: an original that outlives its pending TTL
|
|
41
35
|
* and loses the scope to a retry can no longer overwrite or delete the
|
|
42
|
-
* retry's claim (both settle calls become silent no-ops instead).
|
|
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.
|
|
43
40
|
*
|
|
44
41
|
* Stored bodies are Brotli-compressed from 1KB by default (same scheme as
|
|
45
42
|
* LambderDdbCache, see the `compression` option): the bodies are JSON
|
|
@@ -47,27 +44,43 @@ export type LambderIdempotencyBeginResult = {
|
|
|
47
44
|
* and lets large responses fit the item budget instead of skipping replay
|
|
48
45
|
* storage.
|
|
49
46
|
*
|
|
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".
|
|
52
|
+
*
|
|
50
53
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
51
54
|
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
52
55
|
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
|
|
53
56
|
* without key collisions.
|
|
54
57
|
*/
|
|
55
|
-
export declare class
|
|
58
|
+
export declare class LambderDdbIdempotencyStore implements LambderIdempotencyStore {
|
|
56
59
|
readonly tableName: string;
|
|
57
60
|
readonly keyPrefix: string;
|
|
58
61
|
private readonly compression;
|
|
59
|
-
/** The client given at creation, or one created from `region` on first use; the SDK arrives with it. */
|
|
60
|
-
private readonly providedClient;
|
|
61
|
-
private readonly region;
|
|
62
|
-
private readyPromise;
|
|
63
|
-
constructor(options: LambderDdbIdempotencyOptions);
|
|
64
62
|
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
65
|
-
private ready;
|
|
63
|
+
private readonly ready;
|
|
64
|
+
private readonly now;
|
|
65
|
+
constructor(options: LambderDdbIdempotencyStoreOptions);
|
|
66
|
+
private nowSeconds;
|
|
66
67
|
private itemKey;
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* A stored item's response headers: the multi-value map the answer
|
|
70
|
+
* replays. Every entry is checked to BE one, because the engine hands
|
|
71
|
+
* what comes back to the response builder, which would ship a number or a
|
|
72
|
+
* bare string as a header value.
|
|
73
|
+
*/
|
|
68
74
|
private static readItemHeaders;
|
|
69
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
|
|
77
|
+
* `bodyBytes`). The stored length is the decompression budget, so it is
|
|
78
|
+
* checked here the way the headers are: a record declaring more than this
|
|
79
|
+
* store ever writes is unusable, not an invitation to allocate it.
|
|
80
|
+
*/
|
|
70
81
|
private static readItemBody;
|
|
82
|
+
/** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
|
|
83
|
+
private static answerOf;
|
|
71
84
|
/**
|
|
72
85
|
* Read the scope without claiming it: the stored response when a
|
|
73
86
|
* completed, unexpired record exists, null otherwise (absent, pending, or
|
|
@@ -98,10 +111,7 @@ export declare class LambderDdbIdempotency {
|
|
|
98
111
|
* - "lost": the ownerToken no longer matches, i.e. the claim expired and
|
|
99
112
|
* a retry took the scope over; nothing was written.
|
|
100
113
|
*/
|
|
101
|
-
complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: {
|
|
102
|
-
statusCode: number;
|
|
103
|
-
headers: Record<string, string[]>;
|
|
104
|
-
body: string;
|
|
114
|
+
complete(scopeKey: string, ownerToken: string, { statusCode, headers, body, ttlSeconds }: LambderIdempotencyDoneRecord & {
|
|
105
115
|
ttlSeconds: number;
|
|
106
116
|
}): Promise<"stored" | "too-large" | "lost">;
|
|
107
117
|
/**
|
|
@@ -111,4 +121,4 @@ export declare class LambderDdbIdempotency {
|
|
|
111
121
|
*/
|
|
112
122
|
abandon(scopeKey: string, ownerToken: string): Promise<void>;
|
|
113
123
|
}
|
|
114
|
-
export default
|
|
124
|
+
export default LambderDdbIdempotencyStore;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { assertPartitionKeyFits, createDynamoClientLoader, isConditionalCheckFailure, } from "./LambderDdbSdk.js";
|
|
2
|
+
import { getCrypto } from "../shared/util/LambderNodeModules.js";
|
|
3
|
+
import { compressText, restoreText } from "../shared/wire/LambderCompressionCodec.js";
|
|
4
|
+
import { resolveCompressionOption, } from "../shared/wire/LambderCompressionOption.js";
|
|
5
|
+
/** Bodies of 1KB or more are stored Brotli-compressed by default; smaller ones stay plain. */
|
|
6
|
+
const COMPRESSION_DEFAULTS = { minBytes: 1024, quality: 5 };
|
|
7
|
+
/**
|
|
8
|
+
* Stored-body budget inside DynamoDB's 400KB item limit (headers, keys and
|
|
9
|
+
* attributes need headroom). Applies to the bytes actually stored, so a
|
|
10
|
+
* large compressible response (JSON usually shrinks 5-10x) still replays.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_STORED_BODY_BYTES = 350_000;
|
|
13
|
+
/**
|
|
14
|
+
* Ceiling on a stored body's declared length, which is the budget the restore
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_REPLAY_BODY_BYTES = 32 * 1024 * 1024;
|
|
23
|
+
/**
|
|
24
|
+
* A number attribute as stored, or the fallback when it is missing or not a
|
|
25
|
+
* number. `Number(undefined)` and `Number("nope")` are both NaN, which every
|
|
26
|
+
* later comparison answers false to: a NaN expiry reads as "not expired" and
|
|
27
|
+
* a NaN status code reaches the client as one.
|
|
28
|
+
*/
|
|
29
|
+
const storedNumber = (raw, fallback) => {
|
|
30
|
+
const value = Number(raw);
|
|
31
|
+
return Number.isFinite(value) ? value : fallback;
|
|
32
|
+
};
|
|
33
|
+
/** 16 random bytes, hex, through the optional-crypto seam so a bundler's browser stub cannot break the import. */
|
|
34
|
+
const newOwnerToken = async () => {
|
|
35
|
+
const crypto = await getCrypto();
|
|
36
|
+
if (!crypto)
|
|
37
|
+
throw new Error("LambderDdbIdempotencyStore requires a Node.js environment.");
|
|
38
|
+
return crypto.randomBytes(16).toString("hex");
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* DynamoDB-backed idempotency records: one item per (identity, api, key)
|
|
42
|
+
* scope, claimed atomically with a conditional put. The first request claims
|
|
43
|
+
* the scope as "pending"; concurrent duplicates see "pending"; once the
|
|
44
|
+
* response is stored via complete(), replays get it back verbatim until the
|
|
45
|
+
* TTL. Records whose expiresAt has passed count as absent (DynamoDB TTL
|
|
46
|
+
* deletion is lazy, so expiry is enforced in the condition, not left to TTL).
|
|
47
|
+
*
|
|
48
|
+
* Every claim carries a random ownerToken, and complete()/abandon() are
|
|
49
|
+
* 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.
|
|
55
|
+
*
|
|
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.
|
|
61
|
+
*
|
|
62
|
+
* 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".
|
|
67
|
+
*
|
|
68
|
+
* Table shape: string hash key `pk`, string range key `sk`, TTL on
|
|
69
|
+
* `expiresAt`. Items are prefixed `IDEM#` by default, so the table can be
|
|
70
|
+
* shared with LambderDdbRateLimiter (`RL#`) and LambderDdbCache (`CACHE#`)
|
|
71
|
+
* without key collisions.
|
|
72
|
+
*/
|
|
73
|
+
export class LambderDdbIdempotencyStore {
|
|
74
|
+
tableName;
|
|
75
|
+
keyPrefix;
|
|
76
|
+
compression;
|
|
77
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
78
|
+
ready;
|
|
79
|
+
now;
|
|
80
|
+
constructor(options) {
|
|
81
|
+
if (!options.tableName.trim())
|
|
82
|
+
throw new Error("tableName is required");
|
|
83
|
+
this.tableName = options.tableName;
|
|
84
|
+
this.keyPrefix = options.keyPrefix ?? "IDEM";
|
|
85
|
+
this.compression = resolveCompressionOption(options.compression, COMPRESSION_DEFAULTS);
|
|
86
|
+
this.now = options.now ?? (() => Date.now());
|
|
87
|
+
this.ready = createDynamoClientLoader({ user: "LambderDdbIdempotencyStore", region: options.region, client: options.client });
|
|
88
|
+
}
|
|
89
|
+
nowSeconds() { return Math.floor(this.now() / 1000); }
|
|
90
|
+
itemKey(scopeKey) {
|
|
91
|
+
const partitionKey = assertPartitionKeyFits({
|
|
92
|
+
user: "LambderDdbIdempotencyStore",
|
|
93
|
+
what: "scope key",
|
|
94
|
+
partitionKey: `${this.keyPrefix}#${scopeKey}`,
|
|
95
|
+
remedy: "Shorten the idempotency key or the identity it is scoped by.",
|
|
96
|
+
});
|
|
97
|
+
return { pk: { S: partitionKey }, sk: { S: "idem" } };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A stored item's response headers: the multi-value map the answer
|
|
101
|
+
* replays. Every entry is checked to BE one, because the engine hands
|
|
102
|
+
* what comes back to the response builder, which would ship a number or a
|
|
103
|
+
* bare string as a header value.
|
|
104
|
+
*/
|
|
105
|
+
static readItemHeaders(item) {
|
|
106
|
+
const raw = item.headersJson?.S;
|
|
107
|
+
if (!raw)
|
|
108
|
+
return {};
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(raw);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return {}; // Corrupt record: replay with no headers rather than fail the request.
|
|
115
|
+
}
|
|
116
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
117
|
+
return {};
|
|
118
|
+
const headers = {};
|
|
119
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
120
|
+
// An own "__proto__" key survives JSON.parse and assigning it here
|
|
121
|
+
// would set this object's prototype instead of adding a header.
|
|
122
|
+
if (name === "__proto__")
|
|
123
|
+
continue;
|
|
124
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
|
|
125
|
+
headers[name] = value;
|
|
126
|
+
}
|
|
127
|
+
return headers;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* A stored item's response body: plain (`body`) or Brotli (`bodyBr` +
|
|
131
|
+
* `bodyBytes`). The stored length is the decompression budget, so it is
|
|
132
|
+
* checked here the way the headers are: a record declaring more than this
|
|
133
|
+
* store ever writes is unusable, not an invitation to allocate it.
|
|
134
|
+
*/
|
|
135
|
+
static async readItemBody(item) {
|
|
136
|
+
const compressed = item.bodyBr?.B;
|
|
137
|
+
if (!compressed)
|
|
138
|
+
return item.body?.S ?? "";
|
|
139
|
+
const declaredBytes = storedNumber(item.bodyBytes?.N, 0);
|
|
140
|
+
if (declaredBytes > MAX_REPLAY_BODY_BYTES) {
|
|
141
|
+
throw new Error(`LambderDdbIdempotencyStore: the stored body declares ${declaredBytes} bytes, over the ${MAX_REPLAY_BODY_BYTES}-byte replay limit, so the record is unusable.`);
|
|
142
|
+
}
|
|
143
|
+
return await restoreText(compressed, "br", { declaredBytes });
|
|
144
|
+
}
|
|
145
|
+
/** A stored answer as the engine reads it, with every field of the record checked rather than cast. */
|
|
146
|
+
static async answerOf(item) {
|
|
147
|
+
return {
|
|
148
|
+
statusCode: storedNumber(item.statusCode?.N, 200),
|
|
149
|
+
headers: LambderDdbIdempotencyStore.readItemHeaders(item),
|
|
150
|
+
body: await LambderDdbIdempotencyStore.readItemBody(item),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Read the scope without claiming it: the stored response when a
|
|
155
|
+
* completed, unexpired record exists, null otherwise (absent, pending, or
|
|
156
|
+
* expired). Eventually-consistent read: a miss here only means the caller
|
|
157
|
+
* proceeds to begin(), whose read is authoritative.
|
|
158
|
+
*/
|
|
159
|
+
async peek(scopeKey) {
|
|
160
|
+
const { client, sdk } = await this.ready();
|
|
161
|
+
const existing = await client.send(new sdk.GetItemCommand({
|
|
162
|
+
TableName: this.tableName,
|
|
163
|
+
Key: this.itemKey(scopeKey),
|
|
164
|
+
}));
|
|
165
|
+
const item = existing.Item;
|
|
166
|
+
if (!item || item.state?.S !== "done")
|
|
167
|
+
return null;
|
|
168
|
+
if (storedNumber(item.expiresAt?.N, 0) <= this.nowSeconds())
|
|
169
|
+
return null;
|
|
170
|
+
return await LambderDdbIdempotencyStore.answerOf(item);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Claim the scope. "new" means this request now owns it (proven by the
|
|
174
|
+
* returned ownerToken) and must call complete() or abandon(); "pending"
|
|
175
|
+
* means another request owns it right now; "done" carries the stored
|
|
176
|
+
* response to replay.
|
|
177
|
+
*/
|
|
178
|
+
async begin(scopeKey, { pendingTtlSeconds }) {
|
|
179
|
+
const nowSeconds = this.nowSeconds();
|
|
180
|
+
const ownerToken = await newOwnerToken();
|
|
181
|
+
try {
|
|
182
|
+
const { client, sdk } = await this.ready();
|
|
183
|
+
await client.send(new sdk.PutItemCommand({
|
|
184
|
+
TableName: this.tableName,
|
|
185
|
+
Item: {
|
|
186
|
+
...this.itemKey(scopeKey),
|
|
187
|
+
state: { S: "pending" },
|
|
188
|
+
ownerToken: { S: ownerToken },
|
|
189
|
+
expiresAt: { N: String(nowSeconds + pendingTtlSeconds) },
|
|
190
|
+
},
|
|
191
|
+
// Every clause is one a missing attribute can satisfy rather
|
|
192
|
+
// 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.
|
|
197
|
+
ConditionExpression: "attribute_not_exists(pk) OR attribute_not_exists(expiresAt) OR expiresAt <= :now",
|
|
198
|
+
ExpressionAttributeValues: { ":now": { N: String(nowSeconds) } },
|
|
199
|
+
}));
|
|
200
|
+
return { state: "new", ownerToken };
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (!isConditionalCheckFailure(error))
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
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.
|
|
214
|
+
if (!item)
|
|
215
|
+
return { state: "pending" };
|
|
216
|
+
// The same expiry test peek runs, because the condition above cannot
|
|
217
|
+
// make it: an item whose expiresAt is present but unreadable (a
|
|
218
|
+
// partial write, another writer on a shared table) refuses the claim
|
|
219
|
+
// AND is past nothing, so replaying it here would replay a stored
|
|
220
|
+
// answer for ever. An unreadable expiry counts as expired, and the
|
|
221
|
+
// scope reads as pending rather than as done.
|
|
222
|
+
const live = storedNumber(item.expiresAt?.N, 0) > nowSeconds;
|
|
223
|
+
if (live && item.state?.S === "done")
|
|
224
|
+
return { state: "done", ...await LambderDdbIdempotencyStore.answerOf(item) };
|
|
225
|
+
return { state: "pending" };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* 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:
|
|
234
|
+
*
|
|
235
|
+
* - "stored": the record is in place and will replay.
|
|
236
|
+
* - "too-large": even compressed, the body exceeds the item budget;
|
|
237
|
+
* nothing was written and the caller should release the claim.
|
|
238
|
+
* - "lost": the ownerToken no longer matches, i.e. the claim expired and
|
|
239
|
+
* a retry took the scope over; nothing was written.
|
|
240
|
+
*/
|
|
241
|
+
async complete(scopeKey, ownerToken, { statusCode, headers, body, ttlSeconds }) {
|
|
242
|
+
const nowSeconds = this.nowSeconds();
|
|
243
|
+
const rawBody = Buffer.from(body, "utf8");
|
|
244
|
+
let bodyAttributes;
|
|
245
|
+
// A zero-length body is never compressed, whatever minBytes says: it
|
|
246
|
+
// would be stored as `bodyBr` with `bodyBytes: 0`, and a declared
|
|
247
|
+
// length of zero is one the codec refuses on the way back, so the
|
|
248
|
+
// record would be unreadable for its whole TTL and every retry would
|
|
249
|
+
// execute again. The plain path stores it as the empty string, which
|
|
250
|
+
// reads back as one.
|
|
251
|
+
if (this.compression && rawBody.byteLength > 0 && rawBody.byteLength >= this.compression.minBytes) {
|
|
252
|
+
const compressed = await compressText(rawBody, "br", this.compression.quality);
|
|
253
|
+
if (compressed.byteLength > MAX_STORED_BODY_BYTES)
|
|
254
|
+
return "too-large";
|
|
255
|
+
// bodyBytes bounds and verifies decompression on read.
|
|
256
|
+
bodyAttributes = { bodyBr: { B: compressed }, bodyBytes: { N: String(rawBody.byteLength) } };
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
// The budget is on what actually gets stored, so the plain path is
|
|
260
|
+
// measured too. Without this an oversized body reaches DynamoDB and
|
|
261
|
+
// comes back as a ValidationException, which is not a
|
|
262
|
+
// ConditionalCheckFailedException and so escapes as a store error.
|
|
263
|
+
if (rawBody.byteLength > MAX_STORED_BODY_BYTES)
|
|
264
|
+
return "too-large";
|
|
265
|
+
bodyAttributes = { body: { S: body } };
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const { client, sdk } = await this.ready();
|
|
269
|
+
await client.send(new sdk.PutItemCommand({
|
|
270
|
+
TableName: this.tableName,
|
|
271
|
+
Item: {
|
|
272
|
+
...this.itemKey(scopeKey),
|
|
273
|
+
state: { S: "done" },
|
|
274
|
+
ownerToken: { S: ownerToken },
|
|
275
|
+
statusCode: { N: String(statusCode) },
|
|
276
|
+
headersJson: { S: JSON.stringify(headers) },
|
|
277
|
+
...bodyAttributes,
|
|
278
|
+
expiresAt: { N: String(nowSeconds + ttlSeconds) },
|
|
279
|
+
},
|
|
280
|
+
// 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.
|
|
287
|
+
ConditionExpression: "ownerToken = :owner AND expiresAt > :now",
|
|
288
|
+
ExpressionAttributeValues: { ":owner": { S: ownerToken }, ":now": { N: String(nowSeconds) } },
|
|
289
|
+
}));
|
|
290
|
+
return "stored";
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
if (!isConditionalCheckFailure(error))
|
|
294
|
+
throw error;
|
|
295
|
+
return "lost";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Release the claim without storing a response (crash, uncacheable
|
|
300
|
+
* response), so a retry can execute. Conditional on still holding the
|
|
301
|
+
* claim; a lost claim makes this a silent no-op.
|
|
302
|
+
*/
|
|
303
|
+
async abandon(scopeKey, ownerToken) {
|
|
304
|
+
try {
|
|
305
|
+
const { client, sdk } = await this.ready();
|
|
306
|
+
await client.send(new sdk.DeleteItemCommand({
|
|
307
|
+
TableName: this.tableName,
|
|
308
|
+
Key: this.itemKey(scopeKey),
|
|
309
|
+
ConditionExpression: "ownerToken = :owner",
|
|
310
|
+
ExpressionAttributeValues: { ":owner": { S: ownerToken } },
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (!isConditionalCheckFailure(error))
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export default LambderDdbIdempotencyStore;
|
|
@@ -1,52 +1,20 @@
|
|
|
1
1
|
import type { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
|
|
3
|
-
* The fixed windows a policy may cap, smallest first (the evaluation order),
|
|
4
|
-
* with their length. The policy type derives from this table, so the two can
|
|
5
|
-
* never drift.
|
|
6
|
-
*/
|
|
7
|
-
export declare const RATE_LIMIT_WINDOWS: readonly [{
|
|
8
|
-
readonly key: "perMin";
|
|
9
|
-
readonly seconds: 60;
|
|
10
|
-
}, {
|
|
11
|
-
readonly key: "per10Min";
|
|
12
|
-
readonly seconds: number;
|
|
13
|
-
}, {
|
|
14
|
-
readonly key: "perHour";
|
|
15
|
-
readonly seconds: number;
|
|
16
|
-
}, {
|
|
17
|
-
readonly key: "perDay";
|
|
18
|
-
readonly seconds: number;
|
|
19
|
-
}, {
|
|
20
|
-
readonly key: "perWeek";
|
|
21
|
-
readonly seconds: number;
|
|
22
|
-
}, {
|
|
23
|
-
readonly key: "perMonth";
|
|
24
|
-
readonly seconds: number;
|
|
25
|
-
}];
|
|
26
|
-
export type LambderRateLimitWindow = (typeof RATE_LIMIT_WINDOWS)[number]["key"];
|
|
27
|
-
/** Per-window caps. A window that is absent or 0 is not enforced. */
|
|
28
|
-
export type LambderRateLimitPolicy = Partial<Record<LambderRateLimitWindow, number>>;
|
|
29
|
-
/**
|
|
30
|
-
* The window that refused: which one, its limit, and the epoch second at
|
|
31
|
-
* which that fixed window resets (Retry-After derives from it).
|
|
32
|
-
*/
|
|
33
|
-
export type LambderRateLimitExceeded = {
|
|
34
|
-
window: LambderRateLimitWindow;
|
|
35
|
-
limit: number;
|
|
36
|
-
resetAt: number;
|
|
37
|
-
};
|
|
38
|
-
/** `false` when allowed, otherwise the window whose limit was hit. */
|
|
39
|
-
export type LambderRateLimitResult = false | LambderRateLimitExceeded;
|
|
2
|
+
import { type LambderRateLimiter, type LambderRateLimitPolicy, type LambderRateLimitResult } from "../shared/contracts/LambderRateLimiter.js";
|
|
40
3
|
export interface LambderDdbRateLimiterOptions {
|
|
41
4
|
tableName: string;
|
|
5
|
+
/** Region the client is created for on first use; the SDK's default chain otherwise. */
|
|
42
6
|
region?: string;
|
|
43
7
|
/** Partition key prefix, keeps counters separated from other systems in a shared table. Default: "RL". */
|
|
44
8
|
keyPrefix?: string;
|
|
45
9
|
/** Multiplier applied to the window length when setting the item TTL. */
|
|
46
10
|
ttlWindowMultiplier?: number;
|
|
47
|
-
/** Allow the request when DynamoDB itself errors. Defaults to false. */
|
|
48
|
-
failOpen?: boolean;
|
|
49
11
|
client?: DynamoDBClient;
|
|
12
|
+
/**
|
|
13
|
+
* The clock the windows are computed against, injectable the way
|
|
14
|
+
* LambderMemoryRateLimiter's is, so the conformance suite can drive both
|
|
15
|
+
* implementations across a window boundary through one clock.
|
|
16
|
+
*/
|
|
17
|
+
now?: () => number;
|
|
50
18
|
}
|
|
51
19
|
/**
|
|
52
20
|
* Fixed-window rate limiter backed by DynamoDB.
|
|
@@ -60,29 +28,42 @@ export interface LambderDdbRateLimiterOptions {
|
|
|
60
28
|
* would give up the conditional-ADD atomicity). Items carry an `expiresAt`
|
|
61
29
|
* attribute for DynamoDB TTL.
|
|
62
30
|
*
|
|
31
|
+
* The tracker key is caller data (an address, a session key, whatever a
|
|
32
|
+
* 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.
|
|
39
|
+
*
|
|
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.
|
|
45
|
+
*
|
|
63
46
|
* Table shape: string hash key `pk`, string range key `sk`, TTL on `expiresAt`.
|
|
64
47
|
* Items are prefixed `RL#` by default, so the table can be shared with
|
|
65
|
-
* LambderDdbCache (`CACHE#`) and
|
|
48
|
+
* LambderDdbCache (`CACHE#`) and LambderDdbIdempotencyStore (`IDEM#`) without key
|
|
66
49
|
* collisions.
|
|
67
50
|
*/
|
|
68
|
-
export declare class LambderDdbRateLimiter {
|
|
51
|
+
export declare class LambderDdbRateLimiter implements LambderRateLimiter {
|
|
69
52
|
readonly tableName: string;
|
|
70
53
|
readonly keyPrefix: string;
|
|
71
|
-
/** The
|
|
72
|
-
private readonly
|
|
73
|
-
private readonly region;
|
|
74
|
-
private readyPromise;
|
|
54
|
+
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
55
|
+
private readonly ready;
|
|
75
56
|
private readonly ttlWindowMultiplier;
|
|
76
|
-
private readonly
|
|
57
|
+
private readonly now;
|
|
77
58
|
constructor(options: LambderDdbRateLimiterOptions);
|
|
78
|
-
/** The SDK and the client, loaded and created the first time the table is touched (see LambderDdbSdk). */
|
|
79
|
-
private ready;
|
|
80
59
|
/**
|
|
81
60
|
* Increment every configured window for `trackerKey` (IP, session, user id, ...)
|
|
82
61
|
* and report whether any of them is over its limit, with the window's
|
|
83
62
|
* reset time when so.
|
|
84
63
|
*/
|
|
85
64
|
isRateLimited(trackerKey: string, policy: LambderRateLimitPolicy): Promise<LambderRateLimitResult>;
|
|
65
|
+
/** The item's partition key, refused when the tracker key makes it one DynamoDB will not take. */
|
|
66
|
+
private partitionKeyFor;
|
|
86
67
|
/** Increments one window counter. Returns true when the limit was already reached. */
|
|
87
68
|
private incrementWindow;
|
|
88
69
|
}
|