Module: Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
- Included in:
- Actor::ApiIngest, Actor::MeetingIngest, Actor::PresencePoller
- Defined in:
- lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb
Overview
Mixin that lets an ‘Actors::Every`-style poller honour a Graph throttle by *deferring its own next run* instead of re-firing on its fixed timer interval.
Background: ‘Faraday::RetryAfter` raises `Errors::Throttled` centrally when it exhausts retries (or when the advertised wait alone exceeds the retry budget), and `Faraday::ThrottleCircuit` opens a shared circuit so the whole fleet stops hammering a quota that Graph has already flagged. But the actors that drive those calls schedule themselves with a `Concurrent::TimerTask` whose `execution_interval` is fixed at boot. Catching `Throttled` in a `rescue` block stops the current tick, but the next tick still fires on the original cadence — so a poller on a 30–300s interval keeps walking straight back into an open circuit, emitting an ERROR every interval and contributing nothing but log noise (and, via the shared circuit, refreshing the block for cheaper callers).
This mixin closes that gap. An actor wraps its Graph work in #with_throttle_deferral. On ‘Errors::Throttled` it records a “suppress until” instant of `now + retry_after` (falling back to a bounded default when the server gave no usable `Retry-After`). Subsequent ticks short-circuit via #throttle_suppressed? until that instant passes, so the actor effectively reschedules itself at `now + retry_after` rather than `now + interval` — exactly the behaviour the throttle integration spec and the 0.6.51 CHANGELOG flagged as the outstanding follow-up.
State is per-actor-instance and guarded by a monitor because an actor’s ‘manual` runs on a `Concurrent` thread-pool worker. The mixin reads the carried `retry_after` only when `retry_after_known?` is true; otherwise it applies DEFAULT_DEFERRAL so a throttle with a missing/garbage header still backs the poller off rather than letting it spin.
Constant Summary collapse
- DEFAULT_DEFERRAL =
Fallback deferral (seconds) used when a ‘Throttled` carries no usable `retry_after` (header absent or unparseable). One minute matches Microsoft Graph’s documented typical Retry-After and the circuit middleware’s ‘DEFAULT_FALLBACK_TTL`.
60.0- MAX_DEFERRAL =
Hard ceiling (seconds) on a single deferral so a pathological advertised ‘Retry-After` can’t park a poller for an unbounded time. Mirrors the circuit middleware’s 600s cap in ‘ThrottleCircuit#set_hard_circuit`.
600.0
Instance Method Summary collapse
-
#defer_for(seconds, now: Time.now) ⇒ Object
Open (or extend) a deferral window of ‘seconds` from `now`.
-
#reset_throttle_deferral ⇒ Object
Clear any active deferral.
-
#throttle_remaining(now: Time.now) ⇒ Object
Seconds remaining in the current deferral window (0.0 if none / elapsed).
-
#throttle_suppressed?(now: Time.now) ⇒ Boolean
True while the actor is inside a deferral window opened by a previous throttle.
-
#throttled_until ⇒ Object
The instant before which the actor should not poll again, or nil if no deferral is active.
-
#with_throttle_deferral(now: Time.now) ⇒ Object?
Run ‘block` unless the actor is currently deferring after a recent throttle.
Instance Method Details
#defer_for(seconds, now: Time.now) ⇒ Object
Open (or extend) a deferral window of ‘seconds` from `now`. A later throttle never shortens an existing window — we keep the furthest-out instant so overlapping throttles compose safely.
103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb', line 103 def defer_for(seconds, now: Time.now) window = clamp_deferral(seconds) target = now + window throttle_monitor.synchronize do @throttled_until = if @throttled_until.nil? || target > @throttled_until target else @throttled_until end end window end |
#reset_throttle_deferral ⇒ Object
Clear any active deferral. Called implicitly is unnecessary —#throttle_suppressed? expires on its own once the clock passes ‘throttled_until` — but exposed for tests and explicit resets.
119 120 121 |
# File 'lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb', line 119 def reset_throttle_deferral throttle_monitor.synchronize { @throttled_until = nil } end |
#throttle_remaining(now: Time.now) ⇒ Object
Seconds remaining in the current deferral window (0.0 if none / elapsed). Useful for logging and for tests.
92 93 94 95 96 97 98 |
# File 'lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb', line 92 def throttle_remaining(now: Time.now) until_at = throttled_until return 0.0 if until_at.nil? remaining = until_at - now remaining.positive? ? remaining : 0.0 end |
#throttle_suppressed?(now: Time.now) ⇒ Boolean
Returns true while the actor is inside a deferral window opened by a previous throttle.
79 80 81 82 |
# File 'lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb', line 79 def throttle_suppressed?(now: Time.now) until_at = throttled_until !until_at.nil? && now < until_at end |
#throttled_until ⇒ Object
The instant before which the actor should not poll again, or nil if no deferral is active. Thread-safe read.
86 87 88 |
# File 'lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb', line 86 def throttled_until throttle_monitor.synchronize { @throttled_until } end |
#with_throttle_deferral(now: Time.now) ⇒ Object?
Run ‘block` unless the actor is currently deferring after a recent throttle. If a `Throttled` escapes the block, record the deferral window and swallow it (the throttle has already been logged at the middleware/circuit layer; re-raising would just trip the actor’s generic ‘rescue StandardError` and double-log).
65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb', line 65 def with_throttle_deferral(now: Time.now) if throttle_suppressed?(now: now) log_throttle_skip(now: now) return nil end yield rescue Legion::Extensions::MicrosoftTeams::Errors::Throttled => e defer_after_throttle(e, now: now) nil end |