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 +4 -4
- data/CHANGELOG.md +6 -1
- data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +14 -4
- data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +13 -5
- data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +13 -4
- data/lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb +185 -0
- data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +11 -0
- data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +50 -0
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac161b519f294da86eb8956d3aab55f11f4cd87d7fe710c43c033640b630efe5
|
|
4
|
+
data.tar.gz: bf64b0446e44cd89f9f9cc8be018a2629f0d375ccbfea4848e4668a16ad04598
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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#
|
|
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)
|
|
@@ -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.
|
|
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
|