@microsoft/agents-hosting 1.9.0-beta.3.gb2d23c1c44 → 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.
Files changed (150) hide show
  1. package/README.md +182 -4
  2. package/dist/package.json +3 -3
  3. package/dist/src/app/agentApplication.d.ts +4 -3
  4. package/dist/src/app/agentApplication.js +24 -21
  5. package/dist/src/app/agentApplication.js.map +1 -1
  6. package/dist/src/app/agentApplicationBuilder.d.ts +9 -2
  7. package/dist/src/app/agentApplicationBuilder.js +9 -0
  8. package/dist/src/app/agentApplicationBuilder.js.map +1 -1
  9. package/dist/src/app/agentApplicationOptions.d.ts +16 -4
  10. package/dist/src/app/auth/authorization.d.ts +54 -0
  11. package/dist/src/app/auth/authorization.js +63 -2
  12. package/dist/src/app/auth/authorization.js.map +1 -1
  13. package/dist/src/app/auth/authorizationManager.d.ts +0 -8
  14. package/dist/src/app/auth/authorizationManager.js +104 -178
  15. package/dist/src/app/auth/authorizationManager.js.map +1 -1
  16. package/dist/src/app/auth/handlerStorage.d.ts +5 -3
  17. package/dist/src/app/auth/handlerStorage.js +36 -10
  18. package/dist/src/app/auth/handlerStorage.js.map +1 -1
  19. package/dist/src/app/auth/types.d.ts +2 -2
  20. package/dist/src/app/index.d.ts +1 -0
  21. package/dist/src/app/proactive/proactive.js +10 -6
  22. package/dist/src/app/proactive/proactive.js.map +1 -1
  23. package/dist/src/app/proactive/proactiveOptions.d.ts +2 -2
  24. package/dist/src/app/rateLimit.d.ts +315 -0
  25. package/dist/src/app/rateLimit.js +333 -0
  26. package/dist/src/app/rateLimit.js.map +1 -0
  27. package/dist/src/app/turnState.d.ts +6 -3
  28. package/dist/src/app/turnState.js +43 -4
  29. package/dist/src/app/turnState.js.map +1 -1
  30. package/dist/src/auth/authConfiguration.d.ts +10 -3
  31. package/dist/src/auth/authConfiguration.js +292 -259
  32. package/dist/src/auth/authConfiguration.js.map +1 -1
  33. package/dist/src/auth/delegatedTokenCredential.d.ts +29 -0
  34. package/dist/src/auth/delegatedTokenCredential.js +49 -0
  35. package/dist/src/auth/delegatedTokenCredential.js.map +1 -0
  36. package/dist/src/auth/msal/msalTokenCredential.js +1 -1
  37. package/dist/src/auth/msal/msalTokenCredential.js.map +1 -1
  38. package/dist/src/auth/settings.d.ts +8 -70
  39. package/dist/src/auth/settings.js +5 -67
  40. package/dist/src/auth/settings.js.map +1 -1
  41. package/dist/src/cloudAdapter.d.ts +5 -0
  42. package/dist/src/cloudAdapter.js +13 -103
  43. package/dist/src/cloudAdapter.js.map +1 -1
  44. package/dist/src/configuration/botFrameworkEnvironmentCompatibility.d.ts +23 -0
  45. package/dist/src/configuration/botFrameworkEnvironmentCompatibility.js +183 -0
  46. package/dist/src/configuration/botFrameworkEnvironmentCompatibility.js.map +1 -0
  47. package/dist/src/configuration/configuration.d.ts +83 -0
  48. package/dist/src/configuration/configuration.js +836 -0
  49. package/dist/src/configuration/configuration.js.map +1 -0
  50. package/dist/src/configuration/configurationSource.d.ts +57 -0
  51. package/dist/src/configuration/configurationSource.js +7 -0
  52. package/dist/src/configuration/configurationSource.js.map +1 -0
  53. package/dist/src/configuration/environmentConfiguration.d.ts +14 -0
  54. package/dist/src/configuration/environmentConfiguration.js +271 -0
  55. package/dist/src/configuration/environmentConfiguration.js.map +1 -0
  56. package/dist/src/configuration/index.d.ts +7 -0
  57. package/dist/src/configuration/index.js +14 -0
  58. package/dist/src/configuration/index.js.map +1 -0
  59. package/dist/src/configuration/jsonFileConfigurationSource.d.ts +15 -0
  60. package/dist/src/configuration/jsonFileConfigurationSource.js +58 -0
  61. package/dist/src/configuration/jsonFileConfigurationSource.js.map +1 -0
  62. package/dist/src/createCloudAdapter.d.ts +30 -5
  63. package/dist/src/createCloudAdapter.js +17 -10
  64. package/dist/src/createCloudAdapter.js.map +1 -1
  65. package/dist/src/errorHelper.js +124 -5
  66. package/dist/src/errorHelper.js.map +1 -1
  67. package/dist/src/index.d.ts +1 -0
  68. package/dist/src/index.js +1 -0
  69. package/dist/src/index.js.map +1 -1
  70. package/dist/src/observability/traces.d.ts +6 -4
  71. package/dist/src/observability/traces.js +10 -9
  72. package/dist/src/observability/traces.js.map +1 -1
  73. package/dist/src/outboundHostValidator.d.ts +4 -1
  74. package/dist/src/outboundHostValidator.js +25 -48
  75. package/dist/src/outboundHostValidator.js.map +1 -1
  76. package/dist/src/state/agentState.d.ts +8 -3
  77. package/dist/src/state/agentState.js +26 -8
  78. package/dist/src/state/agentState.js.map +1 -1
  79. package/dist/src/state/conversationState.d.ts +2 -2
  80. package/dist/src/state/conversationState.js.map +1 -1
  81. package/dist/src/state/userState.d.ts +2 -2
  82. package/dist/src/state/userState.js.map +1 -1
  83. package/dist/src/statusCodes.d.ts +5 -0
  84. package/dist/src/statusCodes.js +5 -0
  85. package/dist/src/statusCodes.js.map +1 -1
  86. package/dist/src/storage/fileStorage.d.ts +83 -68
  87. package/dist/src/storage/fileStorage.js +279 -105
  88. package/dist/src/storage/fileStorage.js.map +1 -1
  89. package/dist/src/storage/index.d.ts +1 -0
  90. package/dist/src/storage/index.js +1 -0
  91. package/dist/src/storage/index.js.map +1 -1
  92. package/dist/src/storage/memoryStorage.d.ts +85 -60
  93. package/dist/src/storage/memoryStorage.js +244 -87
  94. package/dist/src/storage/memoryStorage.js.map +1 -1
  95. package/dist/src/storage/storage.d.ts +100 -2
  96. package/dist/src/storage/storage.js +25 -0
  97. package/dist/src/storage/storage.js.map +1 -1
  98. package/dist/src/storage/storageCompatibility.d.ts +17 -0
  99. package/dist/src/storage/storageCompatibility.js +157 -0
  100. package/dist/src/storage/storageCompatibility.js.map +1 -0
  101. package/dist/src/storage/storageExpiry.d.ts +6 -0
  102. package/dist/src/storage/storageExpiry.js +20 -0
  103. package/dist/src/storage/storageExpiry.js.map +1 -0
  104. package/dist/src/utils/env.d.ts +43 -0
  105. package/dist/src/utils/env.js +42 -6
  106. package/dist/src/utils/env.js.map +1 -1
  107. package/dist/src/utils.d.ts +1 -0
  108. package/dist/src/utils.js +10 -1
  109. package/dist/src/utils.js.map +1 -1
  110. package/package.json +3 -3
  111. package/src/app/agentApplication.ts +27 -14
  112. package/src/app/agentApplicationBuilder.ts +13 -2
  113. package/src/app/agentApplicationOptions.ts +18 -4
  114. package/src/app/auth/authorization.ts +67 -2
  115. package/src/app/auth/authorizationManager.ts +126 -204
  116. package/src/app/auth/handlerStorage.ts +51 -7
  117. package/src/app/auth/types.ts +2 -2
  118. package/src/app/index.ts +7 -0
  119. package/src/app/proactive/proactive.ts +19 -9
  120. package/src/app/proactive/proactiveOptions.ts +2 -2
  121. package/src/app/rateLimit.ts +659 -0
  122. package/src/app/turnState.ts +50 -7
  123. package/src/auth/authConfiguration.ts +415 -260
  124. package/src/auth/delegatedTokenCredential.ts +49 -0
  125. package/src/auth/msal/msalTokenCredential.ts +1 -1
  126. package/src/auth/settings.ts +5 -82
  127. package/src/cloudAdapter.ts +38 -113
  128. package/src/configuration/botFrameworkEnvironmentCompatibility.ts +245 -0
  129. package/src/configuration/configuration.ts +1076 -0
  130. package/src/configuration/configurationSource.ts +76 -0
  131. package/src/configuration/environmentConfiguration.ts +377 -0
  132. package/src/configuration/index.ts +19 -0
  133. package/src/configuration/jsonFileConfigurationSource.ts +74 -0
  134. package/src/createCloudAdapter.ts +52 -9
  135. package/src/errorHelper.ts +151 -5
  136. package/src/index.ts +1 -0
  137. package/src/observability/traces.ts +8 -7
  138. package/src/outboundHostValidator.ts +37 -44
  139. package/src/state/agentState.ts +39 -13
  140. package/src/state/conversationState.ts +2 -2
  141. package/src/state/userState.ts +2 -2
  142. package/src/statusCodes.ts +6 -0
  143. package/src/storage/fileStorage.ts +299 -106
  144. package/src/storage/index.ts +1 -0
  145. package/src/storage/memoryStorage.ts +268 -85
  146. package/src/storage/storage.ts +114 -2
  147. package/src/storage/storageCompatibility.ts +185 -0
  148. package/src/storage/storageExpiry.ts +21 -0
  149. package/src/utils/env.ts +63 -0
  150. 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
+ }