lex-microsoft_teams 0.6.51 → 0.6.52

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76994e2ead987d49e26c776ff30d9e6a2b78789a12654d38e80e36e81814d703
4
- data.tar.gz: ebf842a7daf81e017078e894d111d38f9474ecc1b3ced0f4105cea29dfb1b7fb
3
+ metadata.gz: ac161b519f294da86eb8956d3aab55f11f4cd87d7fe710c43c033640b630efe5
4
+ data.tar.gz: bf64b0446e44cd89f9f9cc8be018a2629f0d375ccbfea4848e4668a16ad04598
5
5
  SHA512:
6
- metadata.gz: ac9c034b99ef410ab0c1727f83409cd8d8d063b20082ace208be85e0a1bbd4c8314b983caf4c2a80af82d9ca6245e3616d67dae3f60cdff4a204bea087b2d777
7
- data.tar.gz: 1b13b4c36751a4dd02888c3867acafd0594d6a1a83e00668481a07533997de3bfce5abd68b66fc56adbb5bce6006601d1994daa16ebdb3738bd9309f0ff244a1
6
+ metadata.gz: 926fd8d8f6aa8bbe022fbb17491b959faea7580dd28dee76c48199392fb3d50bd917934ed4025f4a65f20f7353412abbc6788de4634ebeb00d3bf07497e53e28
7
+ data.tar.gz: 26ee2c81c7e0330b575b2aedb8f2284d86c4d40a5b42d089b8edce9a6cd33b06699cf2bac2e7671ff62eff830940ed179b27db1cbcdf2e171d856916a24a296f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.52] - 2026-05-29
4
+ ### Fixed
5
+ - **Actors now defer their next scheduled run on a Graph throttle** instead of re-firing on their fixed timer interval — resolves the 0.6.51 "Known follow-up". New `Helpers::ThrottleAware` mixin records a "suppress until" instant of `now + retry_after` when a Graph call raises `Errors::Throttled`; subsequent ticks short-circuit until that window elapses. Wired into `PresencePoller`, `MeetingIngest`, and `ApiIngest`. Falls back to a bounded 60s deferral when the server gave no usable `Retry-After`, and clamps any single deferral to 600s. Previously a poller on a 30–300s interval would walk straight back into the still-open shared circuit every interval, emitting a `[throttle_circuit] hard circuit` ERROR (and a duplicate `Errors::Throttled` WARN/ERROR) each tick while ingesting nothing.
6
+ - **`ProfileIngest#full_ingest` and `ApiIngest#ingest_api` now propagate `Errors::Throttled`** rather than folding it into a per-stage error hash. `full_ingest` catches it once and aborts the remaining fan-out stages — once the shared circuit is open every later stage would only raise `Throttled(attempts: 0)` instantly, so continuing produced a burst of identical errors. The actor that drives `ingest_api` uses the propagated throttle to open its deferral window. Non-throttle errors keep their existing error-result behavior.
7
+
3
8
  ## [0.6.51] - 2026-05-27
4
9
  ### Added
5
10
  - `Faraday::RetryAfter` middleware honoring `Retry-After` per RFC 9110 §10.2.3 (originally RFC 7231 §7.1.3) in both delta-seconds and HTTP-date forms. Retries HTTP 429 by default; 503/504 opt-in. Bounded by `max_retries` and cumulative `max_wait`; ±jitter on advertised wait to avoid thundering-herd. Configurable via `microsoft_teams.client.retry.{max_retries,max_wait,jitter,fallback_wait,retry_statuses}`; `fetch` semantics preserve explicit falsey values.
@@ -18,7 +23,7 @@
18
23
  - Actors that were accidentally re-enabled by `952607c` (rubocop removed `return false` guards) are now properly gated by `settings[:actor_name][:enabled]` — `presence_poller`, `channel_poller`, `direct_chat_poller`, and `observed_chat_poller` all default to `false`.
19
24
 
20
25
  ### Known follow-up
21
- - Actors (`*_poller.rb`, `meeting_ingest`, `profile_ingest`) still catch `Errors::Throttled` via the generic `rescue StandardError` block but do not yet *defer their next scheduled run* using the carried `retry_after`. To be addressed in a follow-up issue.
26
+ - Actors (`*_poller.rb`, `meeting_ingest`, `profile_ingest`) still catch `Errors::Throttled` via the generic `rescue StandardError` block but do not yet *defer their next scheduled run* using the carried `retry_after`. To be addressed in a follow-up issue. *(Resolved in [Unreleased] — see `Helpers::ThrottleAware`.)*
22
27
 
23
28
  ## [0.6.50] - 2026-05-27
24
29
  ### Added
@@ -5,6 +5,8 @@ module Legion
5
5
  module MicrosoftTeams
6
6
  module Actor
7
7
  class ApiIngest < Legion::Extensions::Actors::Every
8
+ include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
9
+
8
10
  def runner_class = Legion::Extensions::MicrosoftTeams::Runners::ApiIngest
9
11
 
10
12
  def runner_function = 'ingest_api'
@@ -40,6 +42,18 @@ module Legion
40
42
 
41
43
  def manual
42
44
  log.debug('ApiIngest#manual starting')
45
+ # The runner re-raises Errors::Throttled (rather than folding it
46
+ # into an error result) precisely so this actor can defer its
47
+ # next run by the advertised retry_after instead of charging the
48
+ # shared Graph circuit again on the next interval.
49
+ with_throttle_deferral { run_ingest }
50
+ rescue StandardError => e
51
+ handle_exception(e, level: :error, operation: 'ApiIngest#manual')
52
+ end
53
+
54
+ private
55
+
56
+ def run_ingest
43
57
  token = resolve_token
44
58
  unless token
45
59
  log.warn('ApiIngest: no delegated token, skipping')
@@ -57,12 +71,8 @@ module Legion
57
71
  )
58
72
  log.info("ApiIngest: #{result.inspect[0, 200]}")
59
73
  result
60
- rescue StandardError => e
61
- handle_exception(e, level: :error, operation: 'ApiIngest#manual')
62
74
  end
63
75
 
64
- private
65
-
66
76
  def token_available?
67
77
  resolve_token != nil
68
78
  end
@@ -6,6 +6,7 @@ module Legion
6
6
  module Actor
7
7
  class MeetingIngest < Legion::Extensions::Actors::Every
8
8
  include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
+ include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
9
10
 
10
11
  def runner_class = self.class
11
12
  def runner_function = 'manual'
@@ -41,6 +42,17 @@ module Legion
41
42
 
42
43
  def manual
43
44
  log.info('MeetingIngest polling for meetings')
45
+ # Defer the next run when Graph throttles the meetings listing
46
+ # (the first, fixed call every tick makes); per-meeting throttles
47
+ # are handled by the inner rescue below and don't change cadence.
48
+ with_throttle_deferral { ingest_meetings }
49
+ rescue StandardError => e
50
+ handle_exception(e, level: :error, operation: 'MeetingIngest#manual')
51
+ end
52
+
53
+ private
54
+
55
+ def ingest_meetings
44
56
  token = Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
45
57
  return if token.nil?
46
58
 
@@ -57,16 +69,12 @@ module Legion
57
69
  process_meeting(meeting_id: meeting_id, subject: meeting['subject'], token: token)
58
70
  @processed_meetings.add(meeting_id)
59
71
  rescue StandardError => e
60
- handle_exception(e, level: :error, operation: 'MeetingIngest#manual',
72
+ handle_exception(e, level: :error, operation: 'MeetingIngest#ingest_meetings',
61
73
  meeting_id: meeting_id)
62
74
  end
63
75
  end
64
- rescue StandardError => e
65
- handle_exception(e, level: :error, operation: 'MeetingIngest#manual')
66
76
  end
67
77
 
68
- private
69
-
70
78
  def process_meeting(meeting_id:, subject:, token:)
71
79
  log.debug("MeetingIngest#process_meeting meeting_id=#{meeting_id} subject=#{subject}")
72
80
  conn = graph_connection(token: token)
@@ -6,6 +6,7 @@ module Legion
6
6
  module Actor
7
7
  class PresencePoller < Legion::Extensions::Actors::Every
8
8
  include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
+ include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
9
10
 
10
11
  def runner_class = self.class
11
12
  def runner_function = 'manual'
@@ -28,6 +29,18 @@ module Legion
28
29
 
29
30
  def manual
30
31
  log.debug('PresencePoller#manual starting')
32
+ # Honour a recent Graph throttle by deferring this run; on a
33
+ # 429 the block raises Errors::Throttled, which the mixin
34
+ # converts into a deferral window so the next tick stands down
35
+ # for retry_after seconds instead of re-firing on the interval.
36
+ with_throttle_deferral { poll_presence }
37
+ rescue StandardError => e
38
+ handle_exception(e, level: :error, operation: 'PresencePoller#manual')
39
+ end
40
+
41
+ private
42
+
43
+ def poll_presence
31
44
  token = delegated_token
32
45
  unless token
33
46
  log.debug('No token available, skipping presence poll')
@@ -49,12 +62,8 @@ module Legion
49
62
  log.info("Presence changed: availability=#{availability}, activity=#{activity}")
50
63
  @last_presence = current
51
64
  end
52
- rescue StandardError => e
53
- handle_exception(e, level: :error, operation: 'PresencePoller#manual')
54
65
  end
55
66
 
56
- private
57
-
58
67
  def delegated_token
59
68
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
60
69
  rescue StandardError => e
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+ require 'legion/extensions/microsoft_teams/errors'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module MicrosoftTeams
9
+ module Helpers
10
+ # Mixin that lets an `Actors::Every`-style poller honour a Graph
11
+ # throttle by *deferring its own next run* instead of re-firing on
12
+ # its fixed timer interval.
13
+ #
14
+ # Background: `Faraday::RetryAfter` raises `Errors::Throttled`
15
+ # centrally when it exhausts retries (or when the advertised wait
16
+ # alone exceeds the retry budget), and `Faraday::ThrottleCircuit`
17
+ # opens a shared circuit so the whole fleet stops hammering a quota
18
+ # that Graph has already flagged. But the actors that *drive* those
19
+ # calls schedule themselves with a `Concurrent::TimerTask` whose
20
+ # `execution_interval` is fixed at boot. Catching `Throttled` in a
21
+ # `rescue` block stops the current tick, but the next tick still
22
+ # fires on the original cadence — so a poller on a 30–300s interval
23
+ # keeps walking straight back into an open circuit, emitting an
24
+ # ERROR every interval and contributing nothing but log noise (and,
25
+ # via the shared circuit, refreshing the block for cheaper callers).
26
+ #
27
+ # This mixin closes that gap. An actor wraps its Graph work in
28
+ # {#with_throttle_deferral}. On `Errors::Throttled` it records a
29
+ # "suppress until" instant of `now + retry_after` (falling back to a
30
+ # bounded default when the server gave no usable `Retry-After`).
31
+ # Subsequent ticks short-circuit via {#throttle_suppressed?} until
32
+ # that instant passes, so the actor effectively reschedules itself
33
+ # at `now + retry_after` rather than `now + interval` — exactly the
34
+ # behaviour the throttle integration spec and the 0.6.51 CHANGELOG
35
+ # flagged as the outstanding follow-up.
36
+ #
37
+ # State is per-actor-instance and guarded by a monitor because an
38
+ # actor's `manual` runs on a `Concurrent` thread-pool worker. The
39
+ # mixin reads the carried `retry_after` only when
40
+ # `retry_after_known?` is true; otherwise it applies
41
+ # {DEFAULT_DEFERRAL} so a throttle with a missing/garbage header
42
+ # still backs the poller off rather than letting it spin.
43
+ module ThrottleAware
44
+ # Fallback deferral (seconds) used when a `Throttled` carries no
45
+ # usable `retry_after` (header absent or unparseable). One minute
46
+ # matches Microsoft Graph's documented typical Retry-After and the
47
+ # circuit middleware's `DEFAULT_FALLBACK_TTL`.
48
+ DEFAULT_DEFERRAL = 60.0
49
+
50
+ # Hard ceiling (seconds) on a single deferral so a pathological
51
+ # advertised `Retry-After` can't park a poller for an unbounded
52
+ # time. Mirrors the circuit middleware's 600s cap in
53
+ # `ThrottleCircuit#set_hard_circuit`.
54
+ MAX_DEFERRAL = 600.0
55
+
56
+ # Run `block` unless the actor is currently deferring after a
57
+ # recent throttle. If a `Throttled` escapes the block, record the
58
+ # deferral window and swallow it (the throttle has already been
59
+ # logged at the middleware/circuit layer; re-raising would just
60
+ # trip the actor's generic `rescue StandardError` and double-log).
61
+ #
62
+ # @param now [Time] injectable clock for deterministic tests
63
+ # @return [Object, nil] the block's value, or nil when suppressed
64
+ # or when a throttle was caught
65
+ def with_throttle_deferral(now: Time.now)
66
+ if throttle_suppressed?(now: now)
67
+ log_throttle_skip(now: now)
68
+ return nil
69
+ end
70
+
71
+ yield
72
+ rescue Legion::Extensions::MicrosoftTeams::Errors::Throttled => e
73
+ defer_after_throttle(e, now: now)
74
+ nil
75
+ end
76
+
77
+ # @return [Boolean] true while the actor is inside a deferral
78
+ # window opened by a previous throttle.
79
+ def throttle_suppressed?(now: Time.now)
80
+ until_at = throttled_until
81
+ !until_at.nil? && now < until_at
82
+ end
83
+
84
+ # The instant before which the actor should not poll again, or nil
85
+ # if no deferral is active. Thread-safe read.
86
+ def throttled_until
87
+ throttle_monitor.synchronize { @throttled_until }
88
+ end
89
+
90
+ # Seconds remaining in the current deferral window (0.0 if none /
91
+ # elapsed). Useful for logging and for tests.
92
+ def throttle_remaining(now: Time.now)
93
+ until_at = throttled_until
94
+ return 0.0 if until_at.nil?
95
+
96
+ remaining = until_at - now
97
+ remaining.positive? ? remaining : 0.0
98
+ end
99
+
100
+ # Open (or extend) a deferral window of `seconds` from `now`. A
101
+ # later throttle never *shortens* an existing window — we keep the
102
+ # furthest-out instant so overlapping throttles compose safely.
103
+ def defer_for(seconds, now: Time.now)
104
+ window = clamp_deferral(seconds)
105
+ target = now + window
106
+ throttle_monitor.synchronize do
107
+ @throttled_until = if @throttled_until.nil? || target > @throttled_until
108
+ target
109
+ else
110
+ @throttled_until
111
+ end
112
+ end
113
+ window
114
+ end
115
+
116
+ # Clear any active deferral. Called implicitly is unnecessary —
117
+ # {#throttle_suppressed?} expires on its own once the clock passes
118
+ # `throttled_until` — but exposed for tests and explicit resets.
119
+ def reset_throttle_deferral
120
+ throttle_monitor.synchronize { @throttled_until = nil }
121
+ end
122
+
123
+ private
124
+
125
+ def defer_after_throttle(error, now: Time.now)
126
+ seconds = deferral_seconds_for(error)
127
+ window = defer_for(seconds, now: now)
128
+ log.warn(
129
+ "[microsoft_teams][throttle_defer] #{actor_label}: " \
130
+ "deferring next run #{format('%.1f', window)}s " \
131
+ "(retry_after=#{error.retry_after_known? ? format('%.1f', error.retry_after) : 'unknown'} " \
132
+ "path=#{error.request})"
133
+ )
134
+ end
135
+
136
+ # Short, log-friendly name for the including actor. Falls back to
137
+ # the full class string for anonymous classes (whose `name` is
138
+ # nil), so the log line never raises on `name.split`.
139
+ def actor_label
140
+ (self.class.name || self.class.to_s).split('::').last
141
+ end
142
+
143
+ # Prefer the server-advised wait; fall back to {DEFAULT_DEFERRAL}
144
+ # only when the header was absent or unparseable.
145
+ def deferral_seconds_for(error)
146
+ if error.respond_to?(:retry_after_known?) && error.retry_after_known?
147
+ error.retry_after
148
+ else
149
+ DEFAULT_DEFERRAL
150
+ end
151
+ end
152
+
153
+ def clamp_deferral(seconds)
154
+ value = begin
155
+ Float(seconds)
156
+ # rubocop:disable Legion/RescueLogging/NoCapture
157
+ # Pure coercion fallback for a non-numeric deferral; the value
158
+ # is bounded immediately below, so a bad input degrades to the
159
+ # default rather than warranting its own log line.
160
+ rescue ArgumentError, TypeError
161
+ DEFAULT_DEFERRAL
162
+ # rubocop:enable Legion/RescueLogging/NoCapture
163
+ end
164
+ value = DEFAULT_DEFERRAL if value <= 0
165
+ [value, MAX_DEFERRAL].min
166
+ end
167
+
168
+ def log_throttle_skip(now: Time.now)
169
+ log.debug(
170
+ "[microsoft_teams][throttle_defer] #{actor_label}: " \
171
+ "skipping run, #{format('%.1f', throttle_remaining(now: now))}s deferral remaining"
172
+ )
173
+ end
174
+
175
+ def throttle_monitor
176
+ # `||=` is safe here: the first tick of an actor runs before any
177
+ # concurrent re-entry (the `Every` base guards re-entry with its
178
+ # own AtomicBoolean), so the monitor is initialised single-threaded.
179
+ @throttle_monitor ||= Monitor.new
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'digest'
5
+ require 'legion/extensions/microsoft_teams/errors'
5
6
  require 'legion/extensions/microsoft_teams/helpers/client'
6
7
  require 'legion/extensions/microsoft_teams/helpers/graph_cache'
7
8
  require 'legion/extensions/microsoft_teams/helpers/permission_guard'
@@ -104,6 +105,16 @@ module Legion
104
105
  { result: { stored: stored, skipped: skipped, people_ingested: people_ingested,
105
106
  people_found: people.length, chats_found: chats.length,
106
107
  apollo: apollo_results } }
108
+ # rubocop:disable Legion/RescueLogging/NoCapture
109
+ # Re-raise unlogged: surface the typed throttle to the caller (the
110
+ # ApiIngest actor) so it can defer its next scheduled run by the
111
+ # advertised retry_after. The throttle is already logged at the
112
+ # middleware/circuit layer; folding it into an error result here
113
+ # would hide the one signal the actor needs to stop re-charging
114
+ # the shared Graph circuit on its fixed interval.
115
+ rescue Errors::Throttled
116
+ raise
117
+ # rubocop:enable Legion/RescueLogging/NoCapture
107
118
  rescue StandardError => e
108
119
  handle_exception(e, level: :error, operation: 'ApiIngest#ingest_api')
109
120
  { result: { stored: stored || 0, skipped: skipped || 0, error: e.message } }
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'legion/extensions/microsoft_teams/errors'
4
5
  require 'legion/extensions/microsoft_teams/helpers/client'
5
6
  require 'legion/extensions/microsoft_teams/helpers/graph_cache'
6
7
  require 'legion/extensions/microsoft_teams/helpers/permission_guard'
@@ -36,6 +37,15 @@ module Legion
36
37
  teams_result = ingest_teams_and_meetings(token: token)
37
38
 
38
39
  { self: self_result, people: people_result, conversations: conv_result, teams: teams_result }
40
+ rescue Errors::Throttled => e
41
+ # Once Graph throttles us, the shared circuit is open and every
42
+ # remaining stage would just raise Throttled(attempts: 0)
43
+ # instantly — a burst of identical errors that ingests nothing.
44
+ # Stop the fan-out at the first throttled stage and report it.
45
+ log.warn('[microsoft_teams][profile_ingest] throttled mid-ingest; ' \
46
+ 'aborting remaining stages (retry_after=' \
47
+ "#{e.retry_after_known? ? format('%.1f', e.retry_after) : 'unknown'} path=#{e.request})")
48
+ { throttled: true, retry_after: e.retry_after, request: e.request }
39
49
  end
40
50
 
41
51
  def ingest_self(token:, **)
@@ -67,6 +77,13 @@ module Legion
67
77
  end
68
78
 
69
79
  { profile: profile, presence: presence }
80
+ # rubocop:disable Legion/RescueLogging/NoCapture
81
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
82
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
83
+ # logging it here too would just duplicate the line per stage.
84
+ rescue Errors::Throttled
85
+ raise
86
+ # rubocop:enable Legion/RescueLogging/NoCapture
70
87
  rescue StandardError => e
71
88
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_self')
72
89
  { error: e.message }
@@ -99,6 +116,13 @@ module Legion
99
116
  end
100
117
 
101
118
  { people: people, count: people.length }
119
+ # rubocop:disable Legion/RescueLogging/NoCapture
120
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
121
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
122
+ # logging it here too would just duplicate the line per stage.
123
+ rescue Errors::Throttled
124
+ raise
125
+ # rubocop:enable Legion/RescueLogging/NoCapture
102
126
  rescue StandardError => e
103
127
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_people')
104
128
  { error: e.message, skipped: false }
@@ -148,6 +172,13 @@ module Legion
148
172
  end
149
173
 
150
174
  { ingested: ingested }
175
+ # rubocop:disable Legion/RescueLogging/NoCapture
176
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
177
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
178
+ # logging it here too would just duplicate the line per stage.
179
+ rescue Errors::Throttled
180
+ raise
181
+ # rubocop:enable Legion/RescueLogging/NoCapture
151
182
  rescue StandardError => e
152
183
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_conversations')
153
184
  { error: e.message, ingested: ingested || 0 }
@@ -194,6 +225,13 @@ module Legion
194
225
  end
195
226
 
196
227
  { teams: teams_count, meetings: meetings_count }
228
+ # rubocop:disable Legion/RescueLogging/NoCapture
229
+ # Re-raise unlogged: full_ingest logs the throttle once and aborts
230
+ # the fan-out. A throttle is a fleet signal, not a per-stage error;
231
+ # logging it here too would just duplicate the line per stage.
232
+ rescue Errors::Throttled
233
+ raise
234
+ # rubocop:enable Legion/RescueLogging/NoCapture
197
235
  rescue StandardError => e
198
236
  handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_teams_and_meetings')
199
237
  { error: e.message }
@@ -224,6 +262,12 @@ module Legion
224
262
  end
225
263
  end
226
264
  index
265
+ # rubocop:disable Legion/RescueLogging/NoCapture
266
+ # Re-raise unlogged: propagate to full_ingest so the per-chat
267
+ # fan-out stops on the first throttle; full_ingest logs it once.
268
+ rescue Errors::Throttled
269
+ raise
270
+ # rubocop:enable Legion/RescueLogging/NoCapture
227
271
  rescue StandardError => e
228
272
  handle_exception(e, level: :warn, operation: 'ProfileIngest#build_chat_member_index')
229
273
  {}
@@ -236,6 +280,12 @@ module Legion
236
280
 
237
281
  resp = conn.get("chats/#{chat_id}/messages", params)
238
282
  (resp.body || {}).fetch('value', [])
283
+ # rubocop:disable Legion/RescueLogging/NoCapture
284
+ # Re-raise unlogged: propagate to full_ingest so the per-chat
285
+ # fan-out stops on the first throttle; full_ingest logs it once.
286
+ rescue Errors::Throttled
287
+ raise
288
+ # rubocop:enable Legion/RescueLogging/NoCapture
239
289
  rescue StandardError => e
240
290
  handle_exception(e, level: :warn, operation: 'ProfileIngest#fetch_new_messages',
241
291
  chat_id: chat_id)
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.6.51'
6
+ VERSION = '0.6.52'
7
7
  end
8
8
  end
9
9
  end
@@ -4,6 +4,7 @@ require 'legion/extensions/identity/entra/helpers/token_manager'
4
4
  require 'legion/extensions/microsoft_teams/version'
5
5
  require 'legion/extensions/microsoft_teams/errors'
6
6
  require 'legion/extensions/microsoft_teams/faraday/retry_after'
7
+ require 'legion/extensions/microsoft_teams/helpers/throttle_aware'
7
8
  require 'legion/extensions/microsoft_teams/helpers/client'
8
9
  require 'legion/extensions/microsoft_teams/runners/auth'
9
10
  require 'legion/extensions/microsoft_teams/runners/teams'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-microsoft_teams
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.51
4
+ version: 0.6.52
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -218,6 +218,7 @@ files:
218
218
  - lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb
219
219
  - lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
220
220
  - lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
221
+ - lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb
221
222
  - lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb
222
223
  - lib/legion/extensions/microsoft_teams/helpers/transform_definitions.rb
223
224
  - lib/legion/extensions/microsoft_teams/local_cache/extractor.rb