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.
@@ -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