@microsoft/agents-hosting 1.9.0-beta.8.gf58e66b2ee → 1.9.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/README.md +182 -4
- package/dist/package.json +3 -3
- package/dist/src/app/agentApplication.d.ts +4 -3
- package/dist/src/app/agentApplication.js +24 -21
- package/dist/src/app/agentApplication.js.map +1 -1
- package/dist/src/app/agentApplicationBuilder.d.ts +9 -2
- package/dist/src/app/agentApplicationBuilder.js +9 -0
- package/dist/src/app/agentApplicationBuilder.js.map +1 -1
- package/dist/src/app/agentApplicationOptions.d.ts +16 -4
- package/dist/src/app/auth/authorizationManager.d.ts +0 -8
- package/dist/src/app/auth/authorizationManager.js +104 -178
- package/dist/src/app/auth/authorizationManager.js.map +1 -1
- package/dist/src/app/auth/handlerStorage.d.ts +5 -3
- package/dist/src/app/auth/handlerStorage.js +36 -10
- package/dist/src/app/auth/handlerStorage.js.map +1 -1
- package/dist/src/app/auth/types.d.ts +2 -2
- package/dist/src/app/index.d.ts +1 -0
- package/dist/src/app/proactive/proactive.js +10 -6
- package/dist/src/app/proactive/proactive.js.map +1 -1
- package/dist/src/app/proactive/proactiveOptions.d.ts +2 -2
- package/dist/src/app/rateLimit.d.ts +315 -0
- package/dist/src/app/rateLimit.js +333 -0
- package/dist/src/app/rateLimit.js.map +1 -0
- package/dist/src/app/turnState.d.ts +6 -3
- package/dist/src/app/turnState.js +43 -4
- package/dist/src/app/turnState.js.map +1 -1
- package/dist/src/auth/authConfiguration.d.ts +10 -3
- package/dist/src/auth/authConfiguration.js +292 -259
- package/dist/src/auth/authConfiguration.js.map +1 -1
- package/dist/src/auth/settings.d.ts +8 -70
- package/dist/src/auth/settings.js +5 -67
- package/dist/src/auth/settings.js.map +1 -1
- package/dist/src/cloudAdapter.d.ts +5 -0
- package/dist/src/cloudAdapter.js +13 -103
- package/dist/src/cloudAdapter.js.map +1 -1
- package/dist/src/configuration/botFrameworkEnvironmentCompatibility.d.ts +23 -0
- package/dist/src/configuration/botFrameworkEnvironmentCompatibility.js +183 -0
- package/dist/src/configuration/botFrameworkEnvironmentCompatibility.js.map +1 -0
- package/dist/src/configuration/configuration.d.ts +83 -0
- package/dist/src/configuration/configuration.js +836 -0
- package/dist/src/configuration/configuration.js.map +1 -0
- package/dist/src/configuration/configurationSource.d.ts +57 -0
- package/dist/src/configuration/configurationSource.js +7 -0
- package/dist/src/configuration/configurationSource.js.map +1 -0
- package/dist/src/configuration/environmentConfiguration.d.ts +14 -0
- package/dist/src/configuration/environmentConfiguration.js +271 -0
- package/dist/src/configuration/environmentConfiguration.js.map +1 -0
- package/dist/src/configuration/index.d.ts +7 -0
- package/dist/src/configuration/index.js +14 -0
- package/dist/src/configuration/index.js.map +1 -0
- package/dist/src/configuration/jsonFileConfigurationSource.d.ts +15 -0
- package/dist/src/configuration/jsonFileConfigurationSource.js +58 -0
- package/dist/src/configuration/jsonFileConfigurationSource.js.map +1 -0
- package/dist/src/createCloudAdapter.d.ts +30 -5
- package/dist/src/createCloudAdapter.js +17 -10
- package/dist/src/createCloudAdapter.js.map +1 -1
- package/dist/src/errorHelper.js +117 -5
- package/dist/src/errorHelper.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/observability/traces.d.ts +6 -4
- package/dist/src/observability/traces.js +10 -9
- package/dist/src/observability/traces.js.map +1 -1
- package/dist/src/outboundHostValidator.d.ts +4 -1
- package/dist/src/outboundHostValidator.js +25 -48
- package/dist/src/outboundHostValidator.js.map +1 -1
- package/dist/src/state/agentState.d.ts +8 -3
- package/dist/src/state/agentState.js +26 -8
- package/dist/src/state/agentState.js.map +1 -1
- package/dist/src/state/conversationState.d.ts +2 -2
- package/dist/src/state/conversationState.js.map +1 -1
- package/dist/src/state/userState.d.ts +2 -2
- package/dist/src/state/userState.js.map +1 -1
- package/dist/src/statusCodes.d.ts +5 -0
- package/dist/src/statusCodes.js +5 -0
- package/dist/src/statusCodes.js.map +1 -1
- package/dist/src/storage/fileStorage.d.ts +83 -68
- package/dist/src/storage/fileStorage.js +279 -105
- package/dist/src/storage/fileStorage.js.map +1 -1
- package/dist/src/storage/index.d.ts +1 -0
- package/dist/src/storage/index.js +1 -0
- package/dist/src/storage/index.js.map +1 -1
- package/dist/src/storage/memoryStorage.d.ts +85 -60
- package/dist/src/storage/memoryStorage.js +244 -87
- package/dist/src/storage/memoryStorage.js.map +1 -1
- package/dist/src/storage/storage.d.ts +100 -2
- package/dist/src/storage/storage.js +25 -0
- package/dist/src/storage/storage.js.map +1 -1
- package/dist/src/storage/storageCompatibility.d.ts +17 -0
- package/dist/src/storage/storageCompatibility.js +157 -0
- package/dist/src/storage/storageCompatibility.js.map +1 -0
- package/dist/src/storage/storageExpiry.d.ts +6 -0
- package/dist/src/storage/storageExpiry.js +20 -0
- package/dist/src/storage/storageExpiry.js.map +1 -0
- package/dist/src/utils/env.d.ts +43 -0
- package/dist/src/utils/env.js +42 -6
- package/dist/src/utils/env.js.map +1 -1
- package/dist/src/utils.d.ts +1 -0
- package/dist/src/utils.js +10 -1
- package/dist/src/utils.js.map +1 -1
- package/package.json +3 -3
- package/src/app/agentApplication.ts +27 -14
- package/src/app/agentApplicationBuilder.ts +13 -2
- package/src/app/agentApplicationOptions.ts +18 -4
- package/src/app/auth/authorizationManager.ts +126 -204
- package/src/app/auth/handlerStorage.ts +51 -7
- package/src/app/auth/types.ts +2 -2
- package/src/app/index.ts +7 -0
- package/src/app/proactive/proactive.ts +19 -9
- package/src/app/proactive/proactiveOptions.ts +2 -2
- package/src/app/rateLimit.ts +659 -0
- package/src/app/turnState.ts +50 -7
- package/src/auth/authConfiguration.ts +415 -260
- package/src/auth/settings.ts +5 -82
- package/src/cloudAdapter.ts +38 -113
- package/src/configuration/botFrameworkEnvironmentCompatibility.ts +245 -0
- package/src/configuration/configuration.ts +1076 -0
- package/src/configuration/configurationSource.ts +76 -0
- package/src/configuration/environmentConfiguration.ts +377 -0
- package/src/configuration/index.ts +19 -0
- package/src/configuration/jsonFileConfigurationSource.ts +74 -0
- package/src/createCloudAdapter.ts +52 -9
- package/src/errorHelper.ts +143 -5
- package/src/index.ts +1 -0
- package/src/observability/traces.ts +8 -7
- package/src/outboundHostValidator.ts +37 -44
- package/src/state/agentState.ts +39 -13
- package/src/state/conversationState.ts +2 -2
- package/src/state/userState.ts +2 -2
- package/src/statusCodes.ts +6 -0
- package/src/storage/fileStorage.ts +299 -106
- package/src/storage/index.ts +1 -0
- package/src/storage/memoryStorage.ts +268 -85
- package/src/storage/storage.ts +114 -2
- package/src/storage/storageCompatibility.ts +185 -0
- package/src/storage/storageExpiry.ts +21 -0
- package/src/utils/env.ts +63 -0
- package/src/utils.ts +10 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Activity, ActivityTypes } from '@microsoft/agents-activity'
|
|
7
|
+
import { debug } from '@microsoft/agents-telemetry'
|
|
8
|
+
import { INVOKE_RESPONSE_KEY } from '../activityHandler'
|
|
9
|
+
import { InvokeResponse } from '../invoke'
|
|
10
|
+
import {
|
|
11
|
+
MemoryStorage,
|
|
12
|
+
Storage,
|
|
13
|
+
StorageProvider,
|
|
14
|
+
StorageV2,
|
|
15
|
+
StoreItem,
|
|
16
|
+
} from '../storage'
|
|
17
|
+
import {
|
|
18
|
+
asStorageV2,
|
|
19
|
+
assertStorageWriteSucceeded,
|
|
20
|
+
getStorageReadValue,
|
|
21
|
+
} from '../storage/storageCompatibility'
|
|
22
|
+
import { StatusCodes } from '../statusCodes'
|
|
23
|
+
import { TurnContext } from '../turnContext'
|
|
24
|
+
|
|
25
|
+
const logger = debug('agents:rate-limit')
|
|
26
|
+
const DEFAULT_THROTTLE_MESSAGE = 'Too many requests. Please try again later.'
|
|
27
|
+
const DEFAULT_MAX_STORAGE_RETRIES = 3
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Defines how a rate limit rule groups incoming activities by scope key.
|
|
31
|
+
*
|
|
32
|
+
* The scope is the identity of the counter to increment after the rule has been
|
|
33
|
+
* selected for the turn. Use {@link RateLimitRule.appliesTo} to decide whether a
|
|
34
|
+
* rule should participate; use `scope` to decide which user, conversation,
|
|
35
|
+
* tenant, or other key should be counted.
|
|
36
|
+
*
|
|
37
|
+
* If a rule applies and the scope returns `undefined` or an empty string, the
|
|
38
|
+
* turn is throttled. This avoids silently bypassing a limit when the scope key
|
|
39
|
+
* cannot be derived.
|
|
40
|
+
*
|
|
41
|
+
* @example Per-user key:
|
|
42
|
+
* ```ts
|
|
43
|
+
* scope: (context) => context.activity.from?.id
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @example Per-conversation key:
|
|
47
|
+
* ```ts
|
|
48
|
+
* scope: (context) => context.activity.conversation?.id
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* @example Per-user-per-conversation key:
|
|
52
|
+
* ```ts
|
|
53
|
+
* scope: (context) => `${context.activity.from?.id}:${context.activity.conversation?.id}`
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example Per-tenant key:
|
|
57
|
+
* ```ts
|
|
58
|
+
* scope: (context) => context.activity.channelData?.tenantId
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export type RateLimitScope = (context: TurnContext) => string | undefined | Promise<string | undefined>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Defines how the application behaves when rate limit storage cannot be read or written.
|
|
65
|
+
*/
|
|
66
|
+
export type RateLimitStorageErrorBehavior = 'throttle' | 'allow' | 'throw'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Details about a rate limit decision.
|
|
70
|
+
*/
|
|
71
|
+
export interface RateLimitResult {
|
|
72
|
+
/**
|
|
73
|
+
* The index of the rule that produced the decision.
|
|
74
|
+
*/
|
|
75
|
+
ruleIndex: number
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The derived rate limit key.
|
|
79
|
+
*/
|
|
80
|
+
key?: string
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Approximate delay before another activity would be accepted.
|
|
84
|
+
*
|
|
85
|
+
* For fixed-window limits, this is the time remaining until the current window
|
|
86
|
+
* for the scope key resets.
|
|
87
|
+
*/
|
|
88
|
+
retryAfterMs: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Creates a message to send when a turn is throttled.
|
|
93
|
+
*
|
|
94
|
+
* The `result.retryAfterMs` value can be used to tell the user when another
|
|
95
|
+
* activity is expected to be accepted.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* message: (_context, result) => {
|
|
100
|
+
* const seconds = Math.ceil(result.retryAfterMs / 1000)
|
|
101
|
+
* return `Too many requests. Try again in ${seconds} seconds.`
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export type RateLimitMessageFactory =
|
|
106
|
+
(context: TurnContext, result: RateLimitResult) => string | Activity | Promise<string | Activity>
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Configures one rate limit rule for incoming turns.
|
|
110
|
+
*
|
|
111
|
+
* A rule first decides whether it applies to the turn, then derives a scope key,
|
|
112
|
+
* then increments the fixed-window counter for that scope key.
|
|
113
|
+
* Multiple rules can apply to the same turn; the first rule whose counter is at
|
|
114
|
+
* the limit throttles the turn and prevents the route handler from running.
|
|
115
|
+
*
|
|
116
|
+
* The fixed window starts when the first matching activity for a scope key is
|
|
117
|
+
* accepted. It does not start when the limit is reached. For example, with
|
|
118
|
+
* `limit: 3` and `windowMs: 10_000`:
|
|
119
|
+
*
|
|
120
|
+
* - at 0s, the first matching activity is accepted and starts a window ending at 10s
|
|
121
|
+
* - at 1s and 2s, the second and third matching activities are accepted
|
|
122
|
+
* - at 3s, the fourth matching activity is throttled with `retryAfterMs` near 7000
|
|
123
|
+
* - at 10s, the window has expired and the next accepted activity starts a new window
|
|
124
|
+
*
|
|
125
|
+
* @example Limit all activities per user:
|
|
126
|
+
* ```ts
|
|
127
|
+
* {
|
|
128
|
+
* scope: (context) => context.activity.from?.id,
|
|
129
|
+
* limit: 10,
|
|
130
|
+
* windowMs: 60_000
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @example Limit only message activities per conversation:
|
|
135
|
+
* ```ts
|
|
136
|
+
* {
|
|
137
|
+
* scope: (context) => context.activity.conversation?.id,
|
|
138
|
+
* activityTypes: [ActivityTypes.Message],
|
|
139
|
+
* limit: 20,
|
|
140
|
+
* windowMs: 60_000
|
|
141
|
+
* }
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* @example Limit only an expensive command per user:
|
|
145
|
+
* ```ts
|
|
146
|
+
* {
|
|
147
|
+
* appliesTo: (context) => context.activity.text?.startsWith('/expensive') === true,
|
|
148
|
+
* scope: (context) => context.activity.from?.id,
|
|
149
|
+
* limit: 3,
|
|
150
|
+
* windowMs: 60_000
|
|
151
|
+
* }
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export interface RateLimitRule {
|
|
155
|
+
/**
|
|
156
|
+
* Derives the scope key that will be counted when this rule applies.
|
|
157
|
+
*
|
|
158
|
+
* This property is required.
|
|
159
|
+
*
|
|
160
|
+
* This is for grouping, not filtering. Return the same key for activities that
|
|
161
|
+
* should share a quota, such as a user ID for per-user limits or a
|
|
162
|
+
* conversation ID for per-conversation limits.
|
|
163
|
+
*
|
|
164
|
+
* If this rule applies and the returned key is missing or blank, the turn is
|
|
165
|
+
* throttled instead of being allowed through without a counter.
|
|
166
|
+
*/
|
|
167
|
+
scope: RateLimitScope
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Maximum number of activities allowed for each scope key over `windowMs`.
|
|
171
|
+
*
|
|
172
|
+
* This property is required.
|
|
173
|
+
*
|
|
174
|
+
* Each accepted turn that matches the rule increments the counter for its
|
|
175
|
+
* scope key.
|
|
176
|
+
*/
|
|
177
|
+
limit: number
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Time window, in milliseconds, used for the rule's fixed-window counter.
|
|
181
|
+
*
|
|
182
|
+
* This property is required.
|
|
183
|
+
*
|
|
184
|
+
* For example, `limit: 5` and `windowMs: 60_000` allows up to five activities
|
|
185
|
+
* per minute for each scope key. The window starts when the first matching
|
|
186
|
+
* activity for the scope key is accepted, and resets after `windowMs`. If the
|
|
187
|
+
* counter reaches `limit` before the window resets, additional matching
|
|
188
|
+
* activities are throttled and `retryAfterMs` reports the remaining time in
|
|
189
|
+
* that same window.
|
|
190
|
+
*/
|
|
191
|
+
windowMs: number
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Activity types counted by this rule.
|
|
195
|
+
*
|
|
196
|
+
* When provided, the rule only applies to activities whose type is in this
|
|
197
|
+
* array.
|
|
198
|
+
*
|
|
199
|
+
* When omitted, the rule applies to all activity types, subject to `appliesTo`
|
|
200
|
+
* if that predicate is also configured.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```ts
|
|
204
|
+
* activityTypes: [ActivityTypes.Message]
|
|
205
|
+
* ```
|
|
206
|
+
*/
|
|
207
|
+
activityTypes?: ActivityTypes[]
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Optional predicate that decides whether this rule participates in the turn.
|
|
211
|
+
*
|
|
212
|
+
* When provided, it runs after the `activityTypes` check. If it returns
|
|
213
|
+
* `true`, the rule continues to `scope` and may increment a counter. If it
|
|
214
|
+
* returns `false`, the rule is skipped and no window state is read or written.
|
|
215
|
+
*
|
|
216
|
+
* When omitted, every activity that passed `activityTypes` is considered to
|
|
217
|
+
* match this rule.
|
|
218
|
+
*
|
|
219
|
+
* Use this for policy or feature filtering, such as limiting only a command,
|
|
220
|
+
* tenant, channel, or class of users.
|
|
221
|
+
*
|
|
222
|
+
* Keep this separate from `scope`: `appliesTo` answers "does this rule apply?",
|
|
223
|
+
* while `scope` answers "which scope key should be counted?".
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```ts
|
|
227
|
+
* appliesTo: (context) => context.activity.text?.startsWith('/expensive') === true
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
appliesTo?: (context: TurnContext) => boolean | Promise<boolean>
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Message sent when this rule throttles a turn.
|
|
234
|
+
*
|
|
235
|
+
* When provided, this message is used for throttled message turns. It can be a
|
|
236
|
+
* plain string, an Activity, or a factory. Use a factory when the response
|
|
237
|
+
* should include details such as `retryAfterMs`.
|
|
238
|
+
*
|
|
239
|
+
* When omitted, the default message is sent: "Too many requests. Please try
|
|
240
|
+
* again later."
|
|
241
|
+
*/
|
|
242
|
+
message?: string | Activity | RateLimitMessageFactory
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Storage used for this rule's fixed-window state.
|
|
246
|
+
*
|
|
247
|
+
* When provided, the rule reads and writes window state through this storage
|
|
248
|
+
* provider.
|
|
249
|
+
*
|
|
250
|
+
* When omitted, the rule uses the `AgentApplication` storage. If the
|
|
251
|
+
* application does not have storage configured, the limiter falls back to
|
|
252
|
+
* app-local memory storage. Provide rule-specific durable or shared storage
|
|
253
|
+
* when this rule should use a different store than the application state, or
|
|
254
|
+
* when limits must survive restarts or apply across multiple app instances.
|
|
255
|
+
*/
|
|
256
|
+
storage?: StorageProvider
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Behavior when rate limit storage fails. Defaults to `throttle`.
|
|
260
|
+
*
|
|
261
|
+
* When provided, the selected behavior is used when storage cannot be read or
|
|
262
|
+
* written. `throttle` fails closed and rejects the turn, `allow` fails open and
|
|
263
|
+
* lets the turn continue, and `throw` surfaces the storage error.
|
|
264
|
+
*
|
|
265
|
+
* When omitted, storage failures use `throttle`.
|
|
266
|
+
*/
|
|
267
|
+
storageErrorBehavior?: RateLimitStorageErrorBehavior
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Number of times to retry storage operations. Defaults to 3.
|
|
271
|
+
*
|
|
272
|
+
* When provided, failed window evaluations and writes are retried up to this many times before
|
|
273
|
+
* applying `storageErrorBehavior`.
|
|
274
|
+
*
|
|
275
|
+
* When omitted, failed window evaluations and writes are retried up to 3 times.
|
|
276
|
+
* Retries help when shared storage reports transient read or write failures.
|
|
277
|
+
*/
|
|
278
|
+
maxStorageRetries?: number
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
interface WindowState extends StoreItem {
|
|
282
|
+
count: number
|
|
283
|
+
resetAt: number
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
interface RateLimitDecision {
|
|
287
|
+
allowed: boolean
|
|
288
|
+
result?: RateLimitResult
|
|
289
|
+
rule?: RateLimitRule
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface PendingRuleEvaluation {
|
|
293
|
+
rule: RateLimitRule
|
|
294
|
+
ruleIndex: number
|
|
295
|
+
storage: StorageV2
|
|
296
|
+
storageKey: string
|
|
297
|
+
key: string
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface WindowWrite extends PendingRuleEvaluation {
|
|
301
|
+
window: WindowState
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export class AgentApplicationRateLimiter {
|
|
305
|
+
private readonly fallbackStorage = asStorageV2(new MemoryStorage())
|
|
306
|
+
private readonly storageAdapters = new WeakMap<Storage, StorageV2>()
|
|
307
|
+
private readonly storageIds = new WeakMap<StorageV2, number>()
|
|
308
|
+
private readonly locks = new Map<string, Promise<void>>()
|
|
309
|
+
private nextStorageId = 1
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Creates a limiter for the configured rules, using application storage as
|
|
313
|
+
* the shared fallback when a rule does not provide its own storage.
|
|
314
|
+
*/
|
|
315
|
+
constructor (private readonly rules: RateLimitRule[], private readonly storage?: StorageProvider) {}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Evaluates every applicable rule and stages window writes. The turn is
|
|
319
|
+
* allowed only when no rule is over its limit and all required writes succeed.
|
|
320
|
+
*/
|
|
321
|
+
async shouldAllowTurn (context: TurnContext): Promise<RateLimitDecision> {
|
|
322
|
+
const pendingEvaluations: PendingRuleEvaluation[] = []
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i < this.rules.length; i++) {
|
|
325
|
+
const rule = this.rules[i]
|
|
326
|
+
if (!(await this.appliesToTurn(rule, context))) {
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const key = await this.getScopeKey(rule, context)
|
|
331
|
+
if (!key) {
|
|
332
|
+
return {
|
|
333
|
+
allowed: false,
|
|
334
|
+
rule,
|
|
335
|
+
result: { ruleIndex: i, retryAfterMs: rule.windowMs }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
pendingEvaluations.push(this.createPendingEvaluation(rule, i, key))
|
|
341
|
+
} catch (err) {
|
|
342
|
+
const decision = this.handleStorageError(rule, { ruleIndex: i, key, retryAfterMs: rule.windowMs }, err)
|
|
343
|
+
if (!decision) {
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
return decision
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (pendingEvaluations.length > 0) {
|
|
351
|
+
return await this.withLocks(
|
|
352
|
+
pendingEvaluations.map(pending => this.getLockKey(pending.storage, pending.storageKey)),
|
|
353
|
+
async () => {
|
|
354
|
+
const decision = await this.commitPendingEvaluations(pendingEvaluations)
|
|
355
|
+
return decision ?? { allowed: true }
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { allowed: true }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Reads and persists fixed-window state updates while holding key locks.
|
|
365
|
+
*
|
|
366
|
+
* The evaluation happens here so reads and writes use fresh eTags, and so
|
|
367
|
+
* concurrent first requests cannot all write wildcard-created counters.
|
|
368
|
+
*/
|
|
369
|
+
private async commitPendingEvaluations (pendingEvaluations: PendingRuleEvaluation[]): Promise<RateLimitDecision | undefined> {
|
|
370
|
+
let attempt = 0
|
|
371
|
+
const committed = new Set<string>()
|
|
372
|
+
|
|
373
|
+
while (true) {
|
|
374
|
+
const writes: WindowWrite[] = []
|
|
375
|
+
let retry = false
|
|
376
|
+
|
|
377
|
+
for (const pending of pendingEvaluations) {
|
|
378
|
+
if (committed.has(this.getLockKey(pending.storage, pending.storageKey))) {
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
const { result, write } = await this.evaluateRule(pending)
|
|
384
|
+
if (result) {
|
|
385
|
+
return { allowed: false, rule: pending.rule, result }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (write) {
|
|
389
|
+
writes.push(write)
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
if (attempt >= (pending.rule.maxStorageRetries ?? DEFAULT_MAX_STORAGE_RETRIES)) {
|
|
393
|
+
const decision = this.handleStorageError(pending.rule, {
|
|
394
|
+
ruleIndex: pending.ruleIndex,
|
|
395
|
+
key: pending.key,
|
|
396
|
+
retryAfterMs: pending.rule.windowMs
|
|
397
|
+
}, err)
|
|
398
|
+
if (decision) {
|
|
399
|
+
return decision
|
|
400
|
+
}
|
|
401
|
+
continue
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
retry = true
|
|
405
|
+
break
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (retry) {
|
|
410
|
+
attempt++
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
for (const pending of writes) {
|
|
415
|
+
try {
|
|
416
|
+
const results = await pending.storage.write(
|
|
417
|
+
{ [pending.storageKey]: pending.window },
|
|
418
|
+
pending.window.eTag === undefined ? undefined : { expectedVersion: pending.window.eTag }
|
|
419
|
+
)
|
|
420
|
+
assertStorageWriteSucceeded(results, [pending.storageKey])
|
|
421
|
+
committed.add(this.getLockKey(pending.storage, pending.storageKey))
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (attempt >= (pending.rule.maxStorageRetries ?? DEFAULT_MAX_STORAGE_RETRIES)) {
|
|
424
|
+
const decision = this.handleStorageError(pending.rule, {
|
|
425
|
+
ruleIndex: pending.ruleIndex,
|
|
426
|
+
key: pending.key,
|
|
427
|
+
retryAfterMs: pending.rule.windowMs
|
|
428
|
+
}, err)
|
|
429
|
+
if (decision) {
|
|
430
|
+
return decision
|
|
431
|
+
}
|
|
432
|
+
continue
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
retry = true
|
|
436
|
+
break
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!retry) {
|
|
441
|
+
return undefined
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
attempt++
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Converts storage failures into the configured fail-open, fail-closed, or
|
|
450
|
+
* throw behavior.
|
|
451
|
+
*/
|
|
452
|
+
private handleStorageError (rule: RateLimitRule, result: RateLimitResult, err: unknown): RateLimitDecision | undefined {
|
|
453
|
+
const behavior = rule.storageErrorBehavior ?? 'throttle'
|
|
454
|
+
if (behavior === 'allow') {
|
|
455
|
+
logger.warn('Rate limit storage failed; allowing turn:', err)
|
|
456
|
+
return undefined
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (behavior === 'throw') {
|
|
460
|
+
throw err
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
logger.warn('Rate limit storage failed; throttling turn:', err)
|
|
464
|
+
return {
|
|
465
|
+
allowed: false,
|
|
466
|
+
rule,
|
|
467
|
+
result
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Sends the throttled response through the correct channel mechanism.
|
|
473
|
+
*/
|
|
474
|
+
async handleThrottledTurn (context: TurnContext, rule: RateLimitRule, result: RateLimitResult): Promise<void> {
|
|
475
|
+
const message = await this.resolveMessage(context, rule, result)
|
|
476
|
+
if (context.activity.type === ActivityTypes.Invoke) {
|
|
477
|
+
context.turnState.set(INVOKE_RESPONSE_KEY, {
|
|
478
|
+
type: ActivityTypes.InvokeResponse,
|
|
479
|
+
value: {
|
|
480
|
+
status: StatusCodes.TOO_MANY_REQUESTS,
|
|
481
|
+
body: { message: typeof message === 'string' ? message : message?.text ?? this.getDefaultMessage() }
|
|
482
|
+
} as InvokeResponse
|
|
483
|
+
})
|
|
484
|
+
return
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!message) {
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
await context.sendActivity(message)
|
|
493
|
+
} catch (err) {
|
|
494
|
+
logger.warn('Failed to send rate limit message:', err)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Checks activity type and custom predicate filters before touching storage.
|
|
500
|
+
*/
|
|
501
|
+
private async appliesToTurn (rule: RateLimitRule, context: TurnContext): Promise<boolean> {
|
|
502
|
+
if (rule.activityTypes && !rule.activityTypes.some(t => t.toLocaleLowerCase() === context.activity.type?.toLocaleLowerCase())) {
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (rule.appliesTo && !(await rule.appliesTo(context))) {
|
|
507
|
+
return false
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return true
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolves and normalizes the storage key fragment for this rule and turn.
|
|
515
|
+
*/
|
|
516
|
+
private async getScopeKey (rule: RateLimitRule, context: TurnContext): Promise<string | undefined> {
|
|
517
|
+
const key = await rule.scope(context)
|
|
518
|
+
return key?.trim() || undefined
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Reads the current window, decides whether the turn is over limit, and
|
|
523
|
+
* prepares the next window state when the turn should be counted.
|
|
524
|
+
*/
|
|
525
|
+
private async evaluateRule (pending: PendingRuleEvaluation): Promise<{ result?: RateLimitResult, write?: WindowWrite }> {
|
|
526
|
+
const { rule, ruleIndex, storage, storageKey, key } = pending
|
|
527
|
+
const items = await storage.read<WindowState>([storageKey])
|
|
528
|
+
const value = getStorageReadValue(items, storageKey)
|
|
529
|
+
const current = value === undefined
|
|
530
|
+
? undefined
|
|
531
|
+
: { ...value, eTag: items[storageKey].version }
|
|
532
|
+
const now = Date.now()
|
|
533
|
+
const window = this.getFixedWindow(rule, current, now)
|
|
534
|
+
|
|
535
|
+
if (window.count >= rule.limit) {
|
|
536
|
+
return {
|
|
537
|
+
result: {
|
|
538
|
+
ruleIndex,
|
|
539
|
+
key,
|
|
540
|
+
retryAfterMs: Math.max(0, window.resetAt - now)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
window.count++
|
|
546
|
+
return {
|
|
547
|
+
write: { ...pending, window }
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private createPendingEvaluation (rule: RateLimitRule, ruleIndex: number, key: string): PendingRuleEvaluation {
|
|
552
|
+
const storage = this.getStorageV2(rule.storage ?? this.storage ?? this.fallbackStorage)
|
|
553
|
+
const storageKey = `rateLimit:${ruleIndex}:${key}`
|
|
554
|
+
return { rule, ruleIndex, storage, storageKey, key }
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private getStorageV2 (storage: StorageProvider): StorageV2 {
|
|
558
|
+
if (storage instanceof StorageV2) {
|
|
559
|
+
return storage
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const existing = this.storageAdapters.get(storage)
|
|
563
|
+
if (existing) {
|
|
564
|
+
return existing
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const adapter = asStorageV2(storage)
|
|
568
|
+
this.storageAdapters.set(storage, adapter)
|
|
569
|
+
return adapter
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async withLocks<T> (lockKeys: string[], action: () => Promise<T>): Promise<T> {
|
|
573
|
+
const releases: Array<() => void> = []
|
|
574
|
+
const uniqueLockKeys = [...new Set(lockKeys)].sort()
|
|
575
|
+
|
|
576
|
+
for (const key of uniqueLockKeys) {
|
|
577
|
+
releases.push(await this.acquireLock(key))
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
return await action()
|
|
582
|
+
} finally {
|
|
583
|
+
for (const release of releases.reverse()) {
|
|
584
|
+
release()
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private async acquireLock (key: string): Promise<() => void> {
|
|
590
|
+
const previous = this.locks.get(key) ?? Promise.resolve()
|
|
591
|
+
let releaseCurrent!: () => void
|
|
592
|
+
const current = new Promise<void>(resolve => {
|
|
593
|
+
releaseCurrent = resolve
|
|
594
|
+
})
|
|
595
|
+
const next = previous.then(() => current)
|
|
596
|
+
this.locks.set(key, next)
|
|
597
|
+
|
|
598
|
+
await previous
|
|
599
|
+
|
|
600
|
+
return () => {
|
|
601
|
+
if (this.locks.get(key) === next) {
|
|
602
|
+
this.locks.delete(key)
|
|
603
|
+
}
|
|
604
|
+
releaseCurrent()
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private getLockKey (storage: StorageV2, storageKey: string): string {
|
|
609
|
+
return `${this.getStorageId(storage)}:${storageKey}`
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private getStorageId (storage: StorageV2): number {
|
|
613
|
+
const existing = this.storageIds.get(storage)
|
|
614
|
+
if (existing) {
|
|
615
|
+
return existing
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const id = this.nextStorageId++
|
|
619
|
+
this.storageIds.set(storage, id)
|
|
620
|
+
return id
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Reuses the active window or starts a new one after the reset time.
|
|
625
|
+
*/
|
|
626
|
+
private getFixedWindow (rule: RateLimitRule, current: WindowState | undefined, now: number): WindowState {
|
|
627
|
+
if (!current || !Number.isFinite(current.resetAt) || current.resetAt <= now) {
|
|
628
|
+
return {
|
|
629
|
+
count: 0,
|
|
630
|
+
resetAt: now + rule.windowMs,
|
|
631
|
+
// TODO: Once microsoft/Agents-for-net#883 lands in this SDK's storage
|
|
632
|
+
// contract, use a create-if-absent write for new rate limit counters.
|
|
633
|
+
// Unconditional writes are not protected by process-local locks in
|
|
634
|
+
// scaled-out deployments.
|
|
635
|
+
eTag: current?.eTag
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return { ...current }
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Resolves static and factory throttling messages to a sendable value.
|
|
644
|
+
*/
|
|
645
|
+
private async resolveMessage (context: TurnContext, rule: RateLimitRule, result: RateLimitResult): Promise<string | Activity | undefined> {
|
|
646
|
+
if (typeof rule.message === 'function') {
|
|
647
|
+
return await rule.message(context, result)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return rule.message ?? this.getDefaultMessage()
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Returns the fallback throttling text when a rule does not provide one.
|
|
655
|
+
*/
|
|
656
|
+
private getDefaultMessage (): string {
|
|
657
|
+
return DEFAULT_THROTTLE_MESSAGE
|
|
658
|
+
}
|
|
659
|
+
}
|