lex-microsoft_teams 0.6.50 → 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: 35010357ecbf55560b6957ae2467c5975eec04056da102a34b1d4c870a0f1ce3
4
- data.tar.gz: 38d375c241ba2691948bf6d80f275410f0a1003e0e7456852f3bc2ca1bf4acce
3
+ metadata.gz: ac161b519f294da86eb8956d3aab55f11f4cd87d7fe710c43c033640b630efe5
4
+ data.tar.gz: bf64b0446e44cd89f9f9cc8be018a2629f0d375ccbfea4848e4668a16ad04598
5
5
  SHA512:
6
- metadata.gz: ae71a656141975e85dc7b5e294e99d7d5a1f67a34e57feea943f177d4bf1c31224ef462adc5140163f6cb028ca1fede816fa713c25185522d8718fe01631a0af
7
- data.tar.gz: b2e865c65531f7d464d7f058c2e8d965b1ca96643ca15bee7a6018c0dd260d7477771b0418404f9a6dd0ef8fc8dd983d8c68d0a06704fb2432af031d377150d6
6
+ metadata.gz: 926fd8d8f6aa8bbe022fbb17491b959faea7580dd28dee76c48199392fb3d50bd917934ed4025f4a65f20f7353412abbc6788de4634ebeb00d3bf07497e53e28
7
+ data.tar.gz: 26ee2c81c7e0330b575b2aedb8f2284d86c4d40a5b42d089b8edce9a6cd33b06699cf2bac2e7671ff62eff830940ed179b27db1cbcdf2e171d856916a24a296f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
8
+ ## [0.6.51] - 2026-05-27
9
+ ### Added
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.
11
+ - `Errors::Throttled` exception with `status`, `retry_after` (nullable; `nil` distinguishes "no server guidance" from "retry immediately"), `retry_after_known?` predicate, `request`, and `attempts`.
12
+ - `Faraday::RetryAfter.parse_header` shared class method (single source of truth for Retry-After parsing).
13
+ - `default_settings` for all actors with explicit `enabled`, `interval`, and tuning knobs. Actors no longer need nil guards — values are guaranteed by the extension settings merge on boot. Defaults: `api_ingest` 3600s, `incremental_sync` 900s, `presence_poller` disabled, `channel_poller` disabled, `direct_chat_poller` disabled, `observed_chat_poller` disabled, `meeting_ingest` 900s, `profile_ingest` enabled (once on boot).
14
+ - `Helpers::GraphCache` module — `cached_graph_get` wraps Graph API calls with `Legion::Cache` TTL-based caching. Supports `shared: true` for resource-scoped endpoints (e.g., `/chats/{id}/members` — same data for all participants) vs user-scoped for `/me/*` endpoints. Cache keys incorporate the process identity UUID to prevent cross-user leakage.
15
+
16
+ ### Fixed
17
+ - Stuck or chatty consumers no longer brown out other users on the same Entra app registration's Graph quota. The `RetryAfter` middleware now raises `Errors::Throttled` **centrally on exhaustion** — every consumer of `graph_connection` and `bot_connection` gets the typed event without per-callsite handling. Fixes #18.
18
+ - `Helpers::GraphClient#handle_graph_response` retains a defensive 429/503/504 branch that raises `Errors::Throttled` for callers that build a Faraday connection without the middleware (custom tests, ad-hoc tooling).
19
+ - Logger acquisition failures no longer silently drop retry telemetry — falls back to `Legion::Logging` unconditionally; loss of those signals was how the original outage went undiagnosed for days.
20
+ - **O(N×M) member scan eliminated** — `ProfileIngest#find_chat_for_person` and `ApiIngest#match_chat_to_person` previously called `GET /chats/{id}/members` for every chat × every person (up to 500+ calls per tick). Replaced with `build_chat_member_index` that fetches members once per chat and builds an in-memory lookup hash. Reduces ~514 calls/tick to ~50 for `IncrementalSync`; ~7,500 calls/tick to ~65 for `ApiIngest`.
21
+ - `IncrementalSync` interval raised from 120s to 900s (was the single largest source of sustained Graph pressure).
22
+ - `ApiIngest` interval raised from 1800s to 3600s.
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`.
24
+
25
+ ### Known follow-up
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`.)*
27
+
3
28
  ## [0.6.50] - 2026-05-27
4
29
  ### Added
5
30
  - Full OData query parameter support across all Graph API runner methods per Microsoft Graph REST v1.0 docs
@@ -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'
@@ -27,12 +29,12 @@ module Legion
27
29
  end
28
30
 
29
31
  def time
30
- interval = teams_settings.dig(:ingest, :api_interval) || 1800
31
- interval.to_i
32
+ teams_settings.dig(:api_ingest, :interval)
32
33
  end
33
34
 
34
35
  def enabled?
35
- defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
36
+ teams_settings.dig(:api_ingest, :enabled) &&
37
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
36
38
  rescue StandardError => e
37
39
  handle_exception(e, level: :warn, operation: 'ApiIngest#enabled?')
38
40
  false
@@ -40,29 +42,37 @@ 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')
46
60
  return
47
61
  end
48
62
 
49
- ingest = teams_settings[:ingest] || {}
63
+ ai_settings = teams_settings[:api_ingest]
50
64
  log.info('ApiIngest: starting Graph API ingest')
51
65
  result = runner_class.ingest_api(
52
66
  token: token,
53
- top_people: ingest.fetch(:top_people, 15),
54
- message_depth: ingest.fetch(:message_depth, 50),
55
- skip_bots: ingest.fetch(:skip_bots, true),
67
+ top_people: ai_settings[:top_people],
68
+ message_depth: ai_settings[:message_depth],
69
+ skip_bots: ai_settings[:skip_bots],
56
70
  imprint_active: imprint_active?
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
@@ -75,12 +85,7 @@ module Legion
75
85
  end
76
86
 
77
87
  def teams_settings
78
- return {} unless defined?(Legion::Settings)
79
-
80
- Legion::Settings[:microsoft_teams] || {}
81
- rescue StandardError => e
82
- handle_exception(e, level: :warn, operation: 'ApiIngest#teams_settings')
83
- {}
88
+ settings
84
89
  end
85
90
 
86
91
  def imprint_active?
@@ -7,10 +7,6 @@ module Legion
7
7
  class ChannelPoller < Legion::Extensions::Actors::Every
8
8
  include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
9
 
10
- DEFAULT_INTERVAL = 60
11
- DEFAULT_MAX_TEAMS = 10
12
- DEFAULT_MAX_CHANNELS = 5
13
-
14
10
  def initialize(**opts)
15
11
  return unless enabled?
16
12
 
@@ -20,7 +16,7 @@ module Legion
20
16
 
21
17
  def runner_class = self.class
22
18
  def runner_function = 'manual'
23
- def time = channel_setting(:poll_interval, DEFAULT_INTERVAL)
19
+ def time = settings.dig(:channel_poller, :interval)
24
20
  def delay = 300
25
21
  def run_now? = false
26
22
  def use_runner? = false
@@ -36,7 +32,7 @@ module Legion
36
32
  end
37
33
 
38
34
  def enabled?
39
- # channel_setting(:enabled, false) == true
35
+ settings.dig(:channel_poller, :enabled)
40
36
  rescue StandardError => e
41
37
  handle_exception(e, level: :debug, operation: 'ChannelPoller#enabled?')
42
38
  false
@@ -148,11 +144,11 @@ module Legion
148
144
  end
149
145
 
150
146
  def max_teams
151
- channel_setting(:max_teams, DEFAULT_MAX_TEAMS)
147
+ settings.dig(:channel_poller, :max_teams)
152
148
  end
153
149
 
154
150
  def max_channels_per_team
155
- channel_setting(:max_channels_per_team, DEFAULT_MAX_CHANNELS)
151
+ settings.dig(:channel_poller, :max_channels_per_team)
156
152
  end
157
153
 
158
154
  def delegated_token
@@ -162,17 +158,8 @@ module Legion
162
158
  nil
163
159
  end
164
160
 
165
- def channel_setting(key, default)
166
- return default unless defined?(Legion::Settings)
167
-
168
- Legion::Settings.dig(:microsoft_teams, :channels, key) || default
169
- rescue StandardError => e
170
- handle_exception(e, level: :debug, operation: "ChannelPoller#channel_setting(#{key})")
171
- default
172
- end
173
-
174
161
  def channel_traces_enabled?
175
- channel_setting(:store_traces, false) == true
162
+ settings.dig(:channel_poller, :store_traces) == true
176
163
  end
177
164
 
178
165
  def store_channel_message_trace(team_name:, channel_name:, msg:)
@@ -8,8 +8,6 @@ module Legion
8
8
  include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
9
  include Legion::Extensions::MicrosoftTeams::Helpers::HighWaterMark
10
10
 
11
- POLL_INTERVAL = 15
12
-
13
11
  def initialize(**opts)
14
12
  return unless enabled?
15
13
 
@@ -19,7 +17,7 @@ module Legion
19
17
 
20
18
  def runner_class = Legion::Extensions::MicrosoftTeams::Runners::Bot
21
19
  def runner_function = 'handle_message'
22
- def time = settings_interval(:direct_poll_interval, POLL_INTERVAL)
20
+ def time = settings.dig(:direct_chat_poller, :interval)
23
21
  def delay = 60
24
22
  def run_now? = false
25
23
  def use_runner? = false
@@ -27,7 +25,8 @@ module Legion
27
25
  def generate_task? = false
28
26
 
29
27
  def enabled?
30
- defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
28
+ settings.dig(:direct_chat_poller, :enabled) &&
29
+ defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
31
30
  Legion.const_defined?(:Transport, false)
32
31
  rescue StandardError => e
33
32
  handle_exception(e, level: :debug, operation: 'DirectChatPoller#enabled?')
@@ -95,9 +94,7 @@ module Legion
95
94
  end
96
95
 
97
96
  def bot_id_from_settings
98
- return nil unless defined?(Legion::Settings)
99
-
100
- Legion::Settings.dig(:microsoft_teams, :bot, :bot_id)
97
+ settings.dig(:bot, :bot_id)
101
98
  end
102
99
 
103
100
  def delegated_token
@@ -106,12 +103,6 @@ module Legion
106
103
  handle_exception(e, level: :warn, operation: 'DirectChatPoller#delegated_token')
107
104
  nil
108
105
  end
109
-
110
- def settings_interval(key, default)
111
- return default unless defined?(Legion::Settings)
112
-
113
- Legion::Settings.dig(:microsoft_teams, :bot, key) || default
114
- end
115
106
  end
116
107
  end
117
108
  end
@@ -14,17 +14,12 @@ module Legion
14
14
  def delay = 60
15
15
 
16
16
  def time
17
- settings = begin
18
- Legion::Settings[:microsoft_teams] || {}
19
- rescue StandardError => e
20
- handle_exception(e, level: :debug, operation: 'IncrementalSync#time')
21
- {}
22
- end
23
- settings.dig(:ingest, :incremental_interval) || 120
17
+ settings.dig(:incremental_sync, :interval)
24
18
  end
25
19
 
26
20
  def enabled?
27
- defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
21
+ settings.dig(:incremental_sync, :enabled) &&
22
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
28
23
  token_available?
29
24
  rescue StandardError => e
30
25
  handle_exception(e, level: :debug, operation: 'IncrementalSync#enabled?')
@@ -36,17 +31,11 @@ module Legion
36
31
  token = resolve_token
37
32
  return unless token
38
33
 
39
- settings = begin
40
- Legion::Settings[:microsoft_teams] || {}
41
- rescue StandardError => e
42
- handle_exception(e, level: :debug, operation: 'IncrementalSync#manual settings')
43
- {}
44
- end
45
- ingest = settings[:ingest] || {}
34
+ is_settings = settings[:incremental_sync]
46
35
  runner_class.incremental_sync(
47
36
  token: token,
48
- top_people: ingest.fetch(:top_people, 10),
49
- message_depth: ingest.fetch(:message_depth, 50)
37
+ top_people: is_settings[:top_people],
38
+ message_depth: is_settings[:message_depth]
50
39
  )
51
40
  rescue StandardError => e
52
41
  handle_exception(e, level: :error, operation: 'IncrementalSync#manual')
@@ -6,8 +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
-
10
- DEFAULT_INGEST_INTERVAL = 300
9
+ include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
11
10
 
12
11
  def runner_class = self.class
13
12
  def runner_function = 'manual'
@@ -22,17 +21,12 @@ module Legion
22
21
  end
23
22
 
24
23
  def time
25
- settings = begin
26
- Legion::Settings[:microsoft_teams] || {}
27
- rescue StandardError => e
28
- handle_exception(e, level: :debug, operation: 'MeetingIngest#time')
29
- {}
30
- end
31
- settings.dig(:meetings, :ingest_interval) || DEFAULT_INGEST_INTERVAL
24
+ settings.dig(:meeting_ingest, :interval)
32
25
  end
33
26
 
34
27
  def enabled?
35
- Legion::Extensions::Identity::Entra::Helpers::TokenManager.respond_to?(:load_token)
28
+ settings.dig(:meeting_ingest, :enabled) &&
29
+ Legion::Extensions::Identity::Entra::Helpers::TokenManager.respond_to?(:load_token)
36
30
  rescue StandardError => e
37
31
  handle_exception(e, level: :debug, operation: 'MeetingIngest#enabled?')
38
32
  false
@@ -48,6 +42,17 @@ module Legion
48
42
 
49
43
  def manual
50
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
51
56
  token = Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
52
57
  return if token.nil?
53
58
 
@@ -64,16 +69,12 @@ module Legion
64
69
  process_meeting(meeting_id: meeting_id, subject: meeting['subject'], token: token)
65
70
  @processed_meetings.add(meeting_id)
66
71
  rescue StandardError => e
67
- handle_exception(e, level: :error, operation: 'MeetingIngest#manual',
72
+ handle_exception(e, level: :error, operation: 'MeetingIngest#ingest_meetings',
68
73
  meeting_id: meeting_id)
69
74
  end
70
75
  end
71
- rescue StandardError => e
72
- handle_exception(e, level: :error, operation: 'MeetingIngest#manual')
73
76
  end
74
77
 
75
- private
76
-
77
78
  def process_meeting(meeting_id:, subject:, token:)
78
79
  log.debug("MeetingIngest#process_meeting meeting_id=#{meeting_id} subject=#{subject}")
79
80
  conn = graph_connection(token: token)
@@ -8,8 +8,6 @@ module Legion
8
8
  include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
9
  include Legion::Extensions::MicrosoftTeams::Helpers::HighWaterMark
10
10
 
11
- POLL_INTERVAL = 30
12
-
13
11
  def initialize(**opts)
14
12
  return unless enabled?
15
13
 
@@ -18,7 +16,7 @@ module Legion
18
16
 
19
17
  def runner_class = Legion::Extensions::MicrosoftTeams::Runners::Bot
20
18
  def runner_function = 'observe_message'
21
- def time = settings_interval(:observe_poll_interval, POLL_INTERVAL)
19
+ def time = settings.dig(:observed_chat_poller, :interval)
22
20
  def delay = 180
23
21
  def run_now? = false
24
22
  def use_runner? = false
@@ -26,11 +24,9 @@ module Legion
26
24
  def generate_task? = false
27
25
 
28
26
  def enabled?
29
- return false unless defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot)
30
- return false unless Legion.const_defined?(:Transport, false)
31
- return false unless defined?(Legion::Settings)
32
-
33
- Legion::Settings.dig(:microsoft_teams, :bot, :observe, :enabled) == true
27
+ settings.dig(:observed_chat_poller, :enabled) &&
28
+ defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
29
+ Legion.const_defined?(:Transport, false)
34
30
  rescue StandardError => e
35
31
  handle_exception(e, level: :debug, operation: 'ObservedChatPoller#enabled?')
36
32
  false
@@ -106,12 +102,6 @@ module Legion
106
102
  handle_exception(e, level: :warn, operation: 'ObservedChatPoller#delegated_token')
107
103
  nil
108
104
  end
109
-
110
- def settings_interval(key, default)
111
- return default unless defined?(Legion::Settings)
112
-
113
- Legion::Settings.dig(:microsoft_teams, :bot, key) || default
114
- end
115
105
  end
116
106
  end
117
107
  end
@@ -6,8 +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
-
10
- DEFAULT_POLL_INTERVAL = 60
9
+ include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
11
10
 
12
11
  def runner_class = self.class
13
12
  def runner_function = 'manual'
@@ -17,13 +16,12 @@ module Legion
17
16
  def generate_task? = false
18
17
 
19
18
  def time
20
- return DEFAULT_POLL_INTERVAL unless defined?(Legion::Settings)
21
-
22
- Legion::Settings.dig(:microsoft_teams, :presence, :poll_interval) || DEFAULT_POLL_INTERVAL
19
+ settings.dig(:presence_poller, :interval)
23
20
  end
24
21
 
25
22
  def enabled?
26
- Legion::Extensions::Identity::Entra::Helpers::TokenManager.respond_to?(:load_token)
23
+ settings.dig(:presence_poller, :enabled) &&
24
+ Legion::Extensions::Identity::Entra::Helpers::TokenManager.respond_to?(:load_token)
27
25
  rescue StandardError => e
28
26
  handle_exception(e, level: :debug, operation: 'PresencePoller#enabled?')
29
27
  false
@@ -31,6 +29,18 @@ module Legion
31
29
 
32
30
  def manual
33
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
34
44
  token = delegated_token
35
45
  unless token
36
46
  log.debug('No token available, skipping presence poll')
@@ -52,12 +62,8 @@ module Legion
52
62
  log.info("Presence changed: availability=#{availability}, activity=#{activity}")
53
63
  @last_presence = current
54
64
  end
55
- rescue StandardError => e
56
- handle_exception(e, level: :error, operation: 'PresencePoller#manual')
57
65
  end
58
66
 
59
- private
60
-
61
67
  def delegated_token
62
68
  Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
63
69
  rescue StandardError => e
@@ -21,7 +21,8 @@ module Legion
21
21
  end
22
22
 
23
23
  def enabled?
24
- defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
24
+ settings.dig(:profile_ingest, :enabled) &&
25
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
25
26
  token_available?
26
27
  rescue StandardError => e
27
28
  handle_exception(e, level: :debug, operation: 'ProfileIngest#enabled?')
@@ -37,17 +38,11 @@ module Legion
37
38
  end
38
39
  log.info('ProfileIngest: token acquired, starting ingest')
39
40
 
40
- settings = begin
41
- Legion::Settings[:microsoft_teams] || {}
42
- rescue StandardError => e
43
- handle_exception(e, level: :debug, operation: 'ProfileIngest#manual settings')
44
- {}
45
- end
46
- ingest = settings[:ingest] || {}
41
+ pi_settings = settings[:profile_ingest]
47
42
  runner_class.full_ingest(
48
43
  token: token,
49
- top_people: ingest.fetch(:top_people, 10),
50
- message_depth: ingest.fetch(:message_depth, 50)
44
+ top_people: pi_settings[:top_people],
45
+ message_depth: pi_settings[:message_depth]
51
46
  )
52
47
  rescue StandardError => e
53
48
  handle_exception(e, level: :error, operation: 'ProfileIngest#manual')
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Errors
7
+ # Raised when Microsoft Graph (or the Bot Framework) throttles the
8
+ # caller and the retry policy has been exhausted, or when an actor
9
+ # wants to surface a throttle event without retrying further.
10
+ #
11
+ # `retry_after` carries the last advertised Retry-After interval
12
+ # (in seconds) as parsed from the upstream header. **It is nil when
13
+ # the server returned no Retry-After header or one we could not
14
+ # parse** — callers must check `retry_after_known?` before treating
15
+ # the value as a server directive. Conflating "header missing" with
16
+ # "retry immediately" was the bug the original fleet outage exposed.
17
+ class Throttled < StandardError
18
+ attr_reader :status, :retry_after, :request, :attempts
19
+
20
+ # @param status [Integer] the upstream HTTP status (e.g. 429)
21
+ # @param retry_after [Float, Integer, nil] seconds the server
22
+ # advised waiting, or nil if the header was absent/unparseable
23
+ # @param request [String, nil] the path or URL that was throttled
24
+ # @param attempts [Integer, nil] how many retries the middleware
25
+ # tried before giving up; nil means "not tracked"
26
+ def initialize(status:, retry_after: nil, request: nil, attempts: nil)
27
+ @status = coerce_status(status)
28
+ @retry_after = coerce_retry_after(retry_after)
29
+ @request = request
30
+ @attempts = attempts.nil? ? nil : Integer(attempts)
31
+ super(build_message)
32
+ end
33
+
34
+ # @return [Boolean] true when the upstream advised a specific wait
35
+ # interval; false when the header was missing or unparseable. Use
36
+ # this to decide whether to honor the wait verbatim or apply a
37
+ # local policy default before re-scheduling.
38
+ def retry_after_known?
39
+ !@retry_after.nil?
40
+ end
41
+
42
+ private
43
+
44
+ def coerce_status(value)
45
+ Integer(value)
46
+ # rubocop:disable Legion/RescueLogging/NoCapture
47
+ # No logger available during exception construction; we re-raise
48
+ # with a clearer message instead.
49
+ rescue ArgumentError, TypeError
50
+ raise ArgumentError, "Throttled status must be an Integer, got #{value.inspect}"
51
+ # rubocop:enable Legion/RescueLogging/NoCapture
52
+ end
53
+
54
+ def coerce_retry_after(value)
55
+ return nil if value.nil?
56
+
57
+ seconds = Float(value)
58
+ seconds.negative? ? 0.0 : seconds
59
+ # rubocop:disable Legion/RescueLogging/NoCapture
60
+ # Unparseable retry_after intentionally collapses to nil so the
61
+ # public `retry_after_known?` predicate is the single source of
62
+ # truth for "did the server give us usable guidance." Logging
63
+ # belongs at the parse site (Faraday::RetryAfter), not here.
64
+ rescue ArgumentError, TypeError
65
+ nil
66
+ # rubocop:enable Legion/RescueLogging/NoCapture
67
+ end
68
+
69
+ def build_message
70
+ parts = ["Microsoft Graph throttled (HTTP #{@status})"]
71
+ parts << "after #{@attempts} attempt(s)" if @attempts
72
+ parts << if retry_after_known?
73
+ "retry_after=#{format('%.2f', @retry_after)}s"
74
+ else
75
+ 'retry_after=unknown'
76
+ end
77
+ parts << "request=#{@request}" if @request
78
+ parts.join('; ')
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end