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

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_deferralObject

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.

Returns:

  • (Boolean)

    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_untilObject

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

Parameters:

  • now (Time) (defaults to: Time.now)

    injectable clock for deterministic tests

Returns:

  • (Object, nil)

    the block’s value, or nil when suppressed or when a throttle was caught



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