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 +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +22 -17
- data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +5 -18
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +4 -13
- data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +6 -17
- data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +16 -15
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +4 -14
- data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +16 -10
- data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +5 -10
- data/lib/legion/extensions/microsoft_teams/errors.rb +84 -0
- data/lib/legion/extensions/microsoft_teams/faraday/retry_after.rb +209 -0
- data/lib/legion/extensions/microsoft_teams/faraday/throttle_circuit.rb +150 -0
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +80 -3
- data/lib/legion/extensions/microsoft_teams/helpers/graph_cache.rb +65 -0
- data/lib/legion/extensions/microsoft_teams/helpers/graph_client.rb +20 -0
- data/lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb +185 -0
- data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +45 -22
- data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +73 -11
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +60 -0
- metadata +6 -1
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module MicrosoftTeams
|
|
10
|
+
module Faraday
|
|
11
|
+
# Faraday middleware that retries throttled responses honoring the
|
|
12
|
+
# upstream Retry-After header per RFC 9110 §10.2.3 (originally
|
|
13
|
+
# specified in RFC 7231 §7.1.3). Retries HTTP 429 by default; 503
|
|
14
|
+
# and 504 are opt-in via `retry_statuses:`.
|
|
15
|
+
#
|
|
16
|
+
# Retry-After is parsed in two forms:
|
|
17
|
+
#
|
|
18
|
+
# * delta-seconds (e.g. "120") — used as-is
|
|
19
|
+
# * HTTP-date (e.g. "Wed, 27 May 2026 12:00:00 GMT")
|
|
20
|
+
# — converted to delta
|
|
21
|
+
# from current UTC,
|
|
22
|
+
# clamped to >= 0
|
|
23
|
+
#
|
|
24
|
+
# The advertised wait is jittered by ±(jitter * wait) to avoid
|
|
25
|
+
# thundering-herd behavior across instances sharing one Entra app
|
|
26
|
+
# registration's Graph quota.
|
|
27
|
+
#
|
|
28
|
+
# When `max_retries` is reached, or cumulative wait would exceed
|
|
29
|
+
# `max_wait`, the middleware raises `Errors::Throttled` carrying
|
|
30
|
+
# the last advertised Retry-After (nil if the header was missing or
|
|
31
|
+
# unparseable), the final HTTP status, attempt count, and request
|
|
32
|
+
# path. Raising centrally — rather than returning a raw 429 and
|
|
33
|
+
# trusting every caller to detect it — is the difference between
|
|
34
|
+
# one typed event the fleet can defer on, and 60+ runner callsites
|
|
35
|
+
# that silently treat throttle envelopes as data.
|
|
36
|
+
class RetryAfter < ::Faraday::Middleware
|
|
37
|
+
DEFAULT_MAX_RETRIES = 3
|
|
38
|
+
DEFAULT_MAX_WAIT = 60.0
|
|
39
|
+
DEFAULT_JITTER = 0.2
|
|
40
|
+
DEFAULT_FALLBACK_WAIT = 1.0
|
|
41
|
+
DEFAULT_RETRY_STATUSES = [429].freeze
|
|
42
|
+
|
|
43
|
+
# Parse an HTTP Retry-After header value.
|
|
44
|
+
#
|
|
45
|
+
# @param raw [String, nil] the raw header value
|
|
46
|
+
# @param clock [#call] a callable returning current UTC Time,
|
|
47
|
+
# injectable for deterministic tests
|
|
48
|
+
# @return [Float, nil] seconds to wait, or nil if `raw` is absent,
|
|
49
|
+
# empty, or neither a numeric delta nor a valid HTTP-date
|
|
50
|
+
def self.parse_header(raw, clock: -> { Time.now.utc })
|
|
51
|
+
return nil if raw.nil?
|
|
52
|
+
|
|
53
|
+
value = raw.to_s.strip
|
|
54
|
+
return nil if value.empty?
|
|
55
|
+
return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
target = Time.httpdate(value).utc
|
|
59
|
+
[(target - clock.call), 0.0].max
|
|
60
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
61
|
+
# Pure parser — no logger access. The instance method
|
|
62
|
+
# `parse_advertised` warns on the same condition with full
|
|
63
|
+
# context; logging twice would just be noise.
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
nil
|
|
66
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def initialize(app, # rubocop:disable Metrics/ParameterLists
|
|
71
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
72
|
+
max_wait: DEFAULT_MAX_WAIT,
|
|
73
|
+
jitter: DEFAULT_JITTER,
|
|
74
|
+
fallback_wait: DEFAULT_FALLBACK_WAIT,
|
|
75
|
+
retry_statuses: DEFAULT_RETRY_STATUSES,
|
|
76
|
+
sleeper: ->(seconds) { sleep(seconds) },
|
|
77
|
+
logger: nil,
|
|
78
|
+
clock: -> { Time.now.utc })
|
|
79
|
+
super(app)
|
|
80
|
+
@max_retries = Integer(max_retries)
|
|
81
|
+
@max_wait = Float(max_wait)
|
|
82
|
+
@jitter = Float(jitter)
|
|
83
|
+
@fallback_wait = Float(fallback_wait)
|
|
84
|
+
@retry_statuses = Array(retry_statuses).map(&:to_i).freeze
|
|
85
|
+
@sleeper = sleeper
|
|
86
|
+
@logger = logger
|
|
87
|
+
@clock = clock
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def call(env)
|
|
91
|
+
attempts = 0
|
|
92
|
+
total_wait = 0.0
|
|
93
|
+
last_advertised = nil
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
response = @app.call(env.dup)
|
|
97
|
+
return response unless retryable?(response.status)
|
|
98
|
+
|
|
99
|
+
last_advertised = parse_advertised(response)
|
|
100
|
+
wait = compute_wait(last_advertised)
|
|
101
|
+
|
|
102
|
+
if attempts >= @max_retries || (total_wait + wait) > @max_wait
|
|
103
|
+
log_giveup(env, response, attempts, total_wait)
|
|
104
|
+
raise Errors::Throttled.new(
|
|
105
|
+
status: response.status,
|
|
106
|
+
retry_after: last_advertised,
|
|
107
|
+
request: request_path(env),
|
|
108
|
+
attempts: attempts
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
attempts += 1
|
|
113
|
+
total_wait += wait
|
|
114
|
+
log_retry(env, response, wait, attempts)
|
|
115
|
+
@sleeper.call(wait)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def retryable?(status)
|
|
122
|
+
@retry_statuses.include?(status.to_i)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parse the advertised Retry-After value from a response. Returns
|
|
126
|
+
# nil if the header is missing or unparseable — callers branch on
|
|
127
|
+
# nil to distinguish "no server guidance" from "retry now".
|
|
128
|
+
def parse_advertised(response)
|
|
129
|
+
raw = retry_after_header(response)
|
|
130
|
+
parsed = self.class.parse_header(raw, clock: @clock)
|
|
131
|
+
@logger&.warn("[microsoft_teams][retry_after] unparseable Retry-After=#{raw.inspect}") if raw && !raw.to_s.strip.empty? && parsed.nil?
|
|
132
|
+
parsed
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Computes the actual wait the middleware will sleep before the
|
|
136
|
+
# next attempt. Falls back to `@fallback_wait` only when the
|
|
137
|
+
# server gave no usable guidance; jitter is always applied so
|
|
138
|
+
# concurrent instances don't synchronize their retries.
|
|
139
|
+
def compute_wait(advertised)
|
|
140
|
+
seconds = advertised || @fallback_wait
|
|
141
|
+
apply_jitter(seconds)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def retry_after_header(response)
|
|
145
|
+
headers = response_headers(response)
|
|
146
|
+
return nil unless headers
|
|
147
|
+
|
|
148
|
+
headers['Retry-After'] ||
|
|
149
|
+
headers['retry-after'] ||
|
|
150
|
+
headers['RETRY-AFTER']
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Faraday's Response exposes headers via #headers; some test
|
|
154
|
+
# doubles may not, so look in both common places.
|
|
155
|
+
def response_headers(response)
|
|
156
|
+
if response.respond_to?(:headers) && response.headers
|
|
157
|
+
response.headers
|
|
158
|
+
elsif response.respond_to?(:response_headers)
|
|
159
|
+
response.response_headers
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def apply_jitter(seconds)
|
|
164
|
+
return seconds if @jitter.zero?
|
|
165
|
+
|
|
166
|
+
spread = seconds * @jitter
|
|
167
|
+
offset = ((rand * 2.0) - 1.0) * spread
|
|
168
|
+
wait = seconds + offset
|
|
169
|
+
wait.negative? ? 0.0 : wait
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def request_path(env)
|
|
173
|
+
env.url.respond_to?(:path) ? env.url.path : env.url.to_s
|
|
174
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
175
|
+
# Defensive fallback for malformed env.url; the path is only used
|
|
176
|
+
# for log lines and error messages, never for control flow.
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def log_retry(env, response, wait, attempts)
|
|
183
|
+
return unless @logger
|
|
184
|
+
|
|
185
|
+
@logger.warn(
|
|
186
|
+
"[microsoft_teams][retry_after] status=#{response.status} " \
|
|
187
|
+
"method=#{env.method.to_s.upcase} path=#{request_path(env)} " \
|
|
188
|
+
"wait=#{format('%.2f', wait)}s attempt=#{attempts}"
|
|
189
|
+
)
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
warn("[microsoft_teams][retry_after] log_retry suppressed #{e.class}: #{e.message}")
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def log_giveup(env, response, attempts, total_wait)
|
|
195
|
+
return unless @logger
|
|
196
|
+
|
|
197
|
+
@logger.error(
|
|
198
|
+
"[microsoft_teams][retry_after] giving up; status=#{response.status} " \
|
|
199
|
+
"method=#{env.method.to_s.upcase} path=#{request_path(env)} " \
|
|
200
|
+
"attempts=#{attempts} total_wait=#{format('%.2f', total_wait)}s"
|
|
201
|
+
)
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
warn("[microsoft_teams][retry_after] log_giveup suppressed #{e.class}: #{e.message}")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module MicrosoftTeams
|
|
9
|
+
module Faraday
|
|
10
|
+
class ThrottleCircuit < ::Faraday::Middleware
|
|
11
|
+
CIRCUIT_KEY = 'microsoft_teams:graph:circuit:v1:global'
|
|
12
|
+
DEFAULT_SOFT_PERCENTAGE = 0.8
|
|
13
|
+
DEFAULT_SOFT_TTL = 60
|
|
14
|
+
DEFAULT_FALLBACK_TTL = 60
|
|
15
|
+
DEFAULT_INSIGHTS_TTL = 600
|
|
16
|
+
|
|
17
|
+
def initialize(app,
|
|
18
|
+
soft_percentage: DEFAULT_SOFT_PERCENTAGE,
|
|
19
|
+
soft_ttl: DEFAULT_SOFT_TTL,
|
|
20
|
+
fallback_ttl: DEFAULT_FALLBACK_TTL,
|
|
21
|
+
insights_ttl: DEFAULT_INSIGHTS_TTL,
|
|
22
|
+
logger: nil)
|
|
23
|
+
super(app)
|
|
24
|
+
@soft_percentage = Float(soft_percentage)
|
|
25
|
+
@soft_ttl = Integer(soft_ttl)
|
|
26
|
+
@fallback_ttl = Integer(fallback_ttl)
|
|
27
|
+
@insights_ttl = Integer(insights_ttl)
|
|
28
|
+
@logger = logger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(env)
|
|
32
|
+
remaining = circuit_remaining
|
|
33
|
+
if remaining&.positive?
|
|
34
|
+
path = request_path(env)
|
|
35
|
+
@logger&.debug("[throttle_circuit] circuit open, #{remaining}s remaining, blocking #{path}")
|
|
36
|
+
raise Errors::Throttled.new(
|
|
37
|
+
status: 429,
|
|
38
|
+
retry_after: remaining,
|
|
39
|
+
request: path,
|
|
40
|
+
attempts: 0
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
response = @app.call(env)
|
|
45
|
+
check_throttle_percentage(env, response)
|
|
46
|
+
response
|
|
47
|
+
rescue Errors::Throttled => e
|
|
48
|
+
set_hard_circuit(path: request_path(env), retry_after: e.retry_after)
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def circuit_remaining
|
|
55
|
+
return nil unless cache_available?
|
|
56
|
+
|
|
57
|
+
raw = Legion::Cache.get(CIRCUIT_KEY) # rubocop:disable Legion/HelperMigration/DirectCache
|
|
58
|
+
return nil unless raw
|
|
59
|
+
|
|
60
|
+
expires_at = raw.to_f
|
|
61
|
+
remaining = expires_at - Time.now.to_f
|
|
62
|
+
remaining.positive? ? remaining.ceil : nil
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
@logger&.debug("[throttle_circuit] circuit_remaining error: #{e.message}")
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_throttle_percentage(env, response)
|
|
69
|
+
headers = response_headers(response)
|
|
70
|
+
return unless headers
|
|
71
|
+
|
|
72
|
+
raw = headers['x-ms-throttle-limit-percentage'] ||
|
|
73
|
+
headers['X-Ms-Throttle-Limit-Percentage']
|
|
74
|
+
return unless raw
|
|
75
|
+
|
|
76
|
+
percentage = raw.to_f
|
|
77
|
+
return unless percentage >= @soft_percentage
|
|
78
|
+
|
|
79
|
+
path = request_path(env)
|
|
80
|
+
log_throttle_diagnostics(headers, path: path, percentage: percentage)
|
|
81
|
+
set_circuit(ttl: @soft_ttl)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def log_throttle_diagnostics(headers, path:, percentage:)
|
|
85
|
+
scope = headers['x-ms-throttle-scope'] || headers['X-Ms-Throttle-Scope']
|
|
86
|
+
info = headers['x-ms-throttle-information'] || headers['X-Ms-Throttle-Information']
|
|
87
|
+
ru = headers['x-ms-resource-unit'] || headers['X-Ms-Resource-Unit']
|
|
88
|
+
req_id = headers['request-id'] || headers['Request-Id']
|
|
89
|
+
client_req_id = headers['client-request-id'] || headers['Client-Request-Id']
|
|
90
|
+
|
|
91
|
+
@logger&.warn(
|
|
92
|
+
"[throttle_circuit] soft circuit: percentage=#{percentage} " \
|
|
93
|
+
"path=#{path} ttl=#{@soft_ttl}s scope=#{scope} " \
|
|
94
|
+
"info=#{info} resource_unit=#{ru} " \
|
|
95
|
+
"request_id=#{req_id} client_request_id=#{client_req_id}"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def set_hard_circuit(path:, retry_after:)
|
|
100
|
+
ttl = if retry_after&.positive?
|
|
101
|
+
[retry_after.ceil, 600].min
|
|
102
|
+
else
|
|
103
|
+
classified_ttl(path)
|
|
104
|
+
end
|
|
105
|
+
@logger&.error(
|
|
106
|
+
"[throttle_circuit] hard circuit: path=#{path} " \
|
|
107
|
+
"retry_after=#{retry_after} ttl=#{ttl}s"
|
|
108
|
+
)
|
|
109
|
+
set_circuit(ttl: ttl)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def classified_ttl(path)
|
|
113
|
+
return @insights_ttl if path&.match?(%r{/me/people|/me/insights|aiInsights})
|
|
114
|
+
return @fallback_ttl if path&.match?(/chats|channels|messages|presence|meetings/)
|
|
115
|
+
|
|
116
|
+
@fallback_ttl
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def set_circuit(ttl:)
|
|
120
|
+
return unless cache_available?
|
|
121
|
+
|
|
122
|
+
expires_at = Time.now.to_f + ttl
|
|
123
|
+
Legion::Cache.set(CIRCUIT_KEY, expires_at.to_s, ttl: ttl, async: false) # rubocop:disable Legion/HelperMigration/DirectCache
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
@logger&.debug("[throttle_circuit] set_circuit error: #{e.message}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def cache_available?
|
|
129
|
+
defined?(Legion::Cache) && Legion::Cache.respond_to?(:get)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def request_path(env)
|
|
133
|
+
env.url.respond_to?(:path) ? env.url.path : env.url.to_s
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
@logger&.debug("[throttle_circuit] request_path error: #{e.message}")
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def response_headers(response)
|
|
140
|
+
if response.respond_to?(:headers) && response.headers
|
|
141
|
+
response.headers
|
|
142
|
+
elsif response.respond_to?(:response_headers)
|
|
143
|
+
response.response_headers
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
+
require 'legion/extensions/microsoft_teams/faraday/throttle_circuit'
|
|
5
|
+
require 'legion/extensions/microsoft_teams/faraday/retry_after'
|
|
4
6
|
|
|
5
7
|
module Legion
|
|
6
8
|
module Extensions
|
|
@@ -12,8 +14,10 @@ module Legion
|
|
|
12
14
|
|
|
13
15
|
def graph_connection(token: nil, api_url: 'https://graph.microsoft.com/v1.0', **_opts)
|
|
14
16
|
token ||= entra_delegated_token
|
|
15
|
-
Faraday.new(url: api_url) do |conn|
|
|
17
|
+
::Faraday.new(url: api_url) do |conn|
|
|
16
18
|
conn.request :json
|
|
19
|
+
conn.use Legion::Extensions::MicrosoftTeams::Faraday::ThrottleCircuit, **throttle_circuit_options
|
|
20
|
+
conn.use Legion::Extensions::MicrosoftTeams::Faraday::RetryAfter, **retry_after_options
|
|
17
21
|
conn.response :json, content_type: /\bjson$/
|
|
18
22
|
conn.headers['Authorization'] = "Bearer #{token}" if token
|
|
19
23
|
conn.headers['Content-Type'] = 'application/json'
|
|
@@ -21,8 +25,10 @@ module Legion
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def bot_connection(token: nil, service_url: 'https://smba.trafficmanager.net/teams/', **_opts)
|
|
24
|
-
Faraday.new(url: service_url) do |conn|
|
|
28
|
+
::Faraday.new(url: service_url) do |conn|
|
|
25
29
|
conn.request :json
|
|
30
|
+
conn.use Legion::Extensions::MicrosoftTeams::Faraday::ThrottleCircuit, **throttle_circuit_options
|
|
31
|
+
conn.use Legion::Extensions::MicrosoftTeams::Faraday::RetryAfter, **retry_after_options
|
|
26
32
|
conn.response :json, content_type: /\bjson$/
|
|
27
33
|
conn.headers['Authorization'] = "Bearer #{token}" if token
|
|
28
34
|
conn.headers['Content-Type'] = 'application/json'
|
|
@@ -34,7 +40,7 @@ module Legion
|
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
def oauth_connection(tenant_id: 'common', **_opts)
|
|
37
|
-
Faraday.new(url: "https://login.microsoftonline.com/#{tenant_id}") do |conn|
|
|
43
|
+
::Faraday.new(url: "https://login.microsoftonline.com/#{tenant_id}") do |conn|
|
|
38
44
|
conn.request :url_encoded
|
|
39
45
|
conn.response :json, content_type: /\bjson$/
|
|
40
46
|
end
|
|
@@ -48,6 +54,77 @@ module Legion
|
|
|
48
54
|
handle_exception(e, level: :debug, operation: 'Client#entra_delegated_token')
|
|
49
55
|
nil
|
|
50
56
|
end
|
|
57
|
+
|
|
58
|
+
def throttle_circuit_options
|
|
59
|
+
cfg = throttle_circuit_settings || {}
|
|
60
|
+
{
|
|
61
|
+
soft_percentage: fetch_setting(cfg, :soft_percentage, 0.8),
|
|
62
|
+
soft_ttl: fetch_setting(cfg, :soft_ttl, 60),
|
|
63
|
+
fallback_ttl: fetch_setting(cfg, :fallback_ttl, 60),
|
|
64
|
+
insights_ttl: fetch_setting(cfg, :insights_ttl, 600),
|
|
65
|
+
logger: retry_after_logger
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def throttle_circuit_settings
|
|
70
|
+
return nil unless respond_to?(:settings, true)
|
|
71
|
+
|
|
72
|
+
section = settings
|
|
73
|
+
return nil unless section.respond_to?(:dig)
|
|
74
|
+
|
|
75
|
+
section.dig(:client, :throttle_circuit) || section.dig('client', 'throttle_circuit')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Tunable knobs for the Retry-After middleware. Reads from the lex
|
|
79
|
+
# settings under `client.retry.*` (rooted at the gem's settings
|
|
80
|
+
# namespace via `Helpers::Lex`, so the full key is
|
|
81
|
+
# `microsoft_teams.client.retry.*`). Uses `fetch` rather than `||`
|
|
82
|
+
# so an operator who explicitly sets `max_retries: 0` to disable
|
|
83
|
+
# retries gets exactly that — not the default. Wires the Lex
|
|
84
|
+
# logger so retry / giveup events get structured logging.
|
|
85
|
+
def retry_after_options
|
|
86
|
+
cfg = retry_after_settings || {}
|
|
87
|
+
{
|
|
88
|
+
max_retries: fetch_setting(cfg, :max_retries, 3),
|
|
89
|
+
max_wait: fetch_setting(cfg, :max_wait, 60.0),
|
|
90
|
+
jitter: fetch_setting(cfg, :jitter, 0.2),
|
|
91
|
+
fallback_wait: fetch_setting(cfg, :fallback_wait, 1.0),
|
|
92
|
+
retry_statuses: fetch_setting(cfg, :retry_statuses,
|
|
93
|
+
Legion::Extensions::MicrosoftTeams::Faraday::RetryAfter::DEFAULT_RETRY_STATUSES),
|
|
94
|
+
logger: retry_after_logger
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def retry_after_settings
|
|
99
|
+
return nil unless respond_to?(:settings, true)
|
|
100
|
+
|
|
101
|
+
section = settings
|
|
102
|
+
return nil unless section.respond_to?(:dig)
|
|
103
|
+
|
|
104
|
+
section.dig(:client, :retry) || section.dig('client', 'retry')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Read a setting under both symbol and string keys, falling back
|
|
108
|
+
# to `default` only when neither is present. `fetch` is required
|
|
109
|
+
# so falsey values (0, false, nil) survive — `||` would silently
|
|
110
|
+
# promote them to the default.
|
|
111
|
+
def fetch_setting(cfg, key, default)
|
|
112
|
+
cfg.fetch(key) { cfg.fetch(key.to_s, default) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Bind the Lex logger so the middleware emits structured retry /
|
|
116
|
+
# giveup events. If `log` raises before the include chain is
|
|
117
|
+
# ready, fall back to `Legion::Logging` (the underlying logger
|
|
118
|
+
# the helper wraps) so we never silently lose telemetry — losing
|
|
119
|
+
# those signals is exactly how the original fleet outage went
|
|
120
|
+
# undiagnosed for days.
|
|
121
|
+
def retry_after_logger
|
|
122
|
+
log
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
fallback = defined?(::Legion::Logging) ? ::Legion::Logging : nil
|
|
125
|
+
fallback&.error("[microsoft_teams][client] retry_after_logger fallback: #{e.class}: #{e.message}")
|
|
126
|
+
fallback
|
|
127
|
+
end
|
|
51
128
|
end
|
|
52
129
|
end
|
|
53
130
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module MicrosoftTeams
|
|
6
|
+
module Helpers
|
|
7
|
+
module GraphCache
|
|
8
|
+
include Legion::Cache::Helper if defined?(Legion::Cache::Helper)
|
|
9
|
+
|
|
10
|
+
def graph_cache_ttl
|
|
11
|
+
settings = teams_extension_settings
|
|
12
|
+
settings.dig(:cache, :graph_ttl) || 300
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cached_graph_get(conn:, path:, params: {}, ttl: nil, shared: false)
|
|
16
|
+
effective_ttl = ttl || graph_cache_ttl
|
|
17
|
+
key = graph_cache_key(path: path, params: params, shared: shared)
|
|
18
|
+
|
|
19
|
+
cache_fetch(key, ttl: effective_ttl) do
|
|
20
|
+
resp = conn.get(path, params)
|
|
21
|
+
resp.body
|
|
22
|
+
end
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
handle_exception(e, level: :debug, operation: 'GraphCache#cached_graph_get', path: path)
|
|
25
|
+
conn.get(path, params).body
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def graph_user_key
|
|
29
|
+
return @graph_user_key if defined?(@graph_user_key)
|
|
30
|
+
|
|
31
|
+
@graph_user_key = (Legion::Identity::Process.id if defined?(Legion::Identity::Process) && Legion::Identity::Process.resolved?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def invalidate_graph_cache(path:, params: {}, shared: false)
|
|
35
|
+
key = graph_cache_key(path: path, params: params, shared: shared)
|
|
36
|
+
cache_delete(key)
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
handle_exception(e, level: :debug, operation: 'GraphCache#invalidate_graph_cache')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def graph_cache_key(path:, params: {}, shared: false)
|
|
44
|
+
scope = if shared
|
|
45
|
+
'shared'
|
|
46
|
+
else
|
|
47
|
+
graph_user_key || 'anon'
|
|
48
|
+
end
|
|
49
|
+
param_str = params.empty? ? '' : ":#{params.sort.map { |k, v| "#{k}=#{v}" }.join('&')}"
|
|
50
|
+
"graph:#{scope}:#{path}#{param_str}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def teams_extension_settings
|
|
54
|
+
return {} unless defined?(Legion::Settings)
|
|
55
|
+
|
|
56
|
+
Legion::Settings[:microsoft_teams] || {}
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
handle_exception(e, level: :debug, operation: 'GraphCache#teams_extension_settings')
|
|
59
|
+
{}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
4
|
+
require 'legion/extensions/microsoft_teams/faraday/retry_after'
|
|
5
|
+
|
|
3
6
|
module Legion
|
|
4
7
|
module Extensions
|
|
5
8
|
module MicrosoftTeams
|
|
@@ -62,12 +65,29 @@ module Legion
|
|
|
62
65
|
when 403
|
|
63
66
|
detail = error_message || 'Caller does not have sufficient permissions to perform this action.'
|
|
64
67
|
raise GraphError, "Graph API 403 Forbidden on #{path}: #{detail}"
|
|
68
|
+
when 429, 503, 504
|
|
69
|
+
# Defensive: the RetryAfter middleware raises Throttled itself
|
|
70
|
+
# when it exhausts retries, so this branch is rarely hit. It
|
|
71
|
+
# still fires when a caller uses a Faraday connection without
|
|
72
|
+
# the middleware installed (custom tests, ad-hoc tooling).
|
|
73
|
+
raise Errors::Throttled.new(
|
|
74
|
+
status: response.status,
|
|
75
|
+
retry_after: Faraday::RetryAfter.parse_header(retry_after_value(response)),
|
|
76
|
+
request: path
|
|
77
|
+
)
|
|
65
78
|
else
|
|
66
79
|
base_message = "Graph API #{response.status} on #{path}"
|
|
67
80
|
base_message = "#{base_message}: #{error_message}" if error_message
|
|
68
81
|
raise GraphError, base_message
|
|
69
82
|
end
|
|
70
83
|
end
|
|
84
|
+
|
|
85
|
+
def retry_after_value(response)
|
|
86
|
+
headers = response.respond_to?(:headers) ? response.headers : nil
|
|
87
|
+
return nil unless headers
|
|
88
|
+
|
|
89
|
+
headers['Retry-After'] || headers['retry-after'] || headers['RETRY-AFTER']
|
|
90
|
+
end
|
|
71
91
|
end
|
|
72
92
|
end
|
|
73
93
|
end
|