Class: Legion::Extensions::MicrosoftTeams::Faraday::RetryAfter

Inherits:
Faraday::Middleware
  • Object
show all
Defined in:
lib/legion/extensions/microsoft_teams/faraday/retry_after.rb

Overview

Faraday middleware that retries throttled responses honoring the upstream Retry-After header per RFC 9110 §10.2.3 (originally specified in RFC 7231 §7.1.3). Retries HTTP 429 by default; 503 and 504 are opt-in via ‘retry_statuses:`.

Retry-After is parsed in two forms:

  • delta-seconds (e.g. “120”) — used as-is

  • HTTP-date (e.g. “Wed, 27 May 2026 12:00:00 GMT”)

    — converted to delta
      from current UTC,
      clamped to >= 0
    

The advertised wait is jittered by ±(jitter * wait) to avoid thundering-herd behavior across instances sharing one Entra app registration’s Graph quota.

When ‘max_retries` is reached, or cumulative wait would exceed `max_wait`, the middleware raises `Errors::Throttled` carrying the last advertised Retry-After (nil if the header was missing or unparseable), the final HTTP status, attempt count, and request path. Raising centrally — rather than returning a raw 429 and trusting every caller to detect it — is the difference between one typed event the fleet can defer on, and 60+ runner callsites that silently treat throttle envelopes as data.

Constant Summary collapse

DEFAULT_MAX_RETRIES =
3
DEFAULT_MAX_WAIT =
60.0
DEFAULT_JITTER =
0.2
DEFAULT_FALLBACK_WAIT =
1.0
DEFAULT_RETRY_STATUSES =
[429].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app, max_retries: DEFAULT_MAX_RETRIES, max_wait: DEFAULT_MAX_WAIT, jitter: DEFAULT_JITTER, fallback_wait: DEFAULT_FALLBACK_WAIT, retry_statuses: DEFAULT_RETRY_STATUSES, sleeper: ->(seconds) { sleep(seconds) }, logger: nil, clock: -> { Time.now.utc }) ⇒ RetryAfter

rubocop:disable Metrics/ParameterLists



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/legion/extensions/microsoft_teams/faraday/retry_after.rb', line 70

def initialize(app, # rubocop:disable Metrics/ParameterLists
               max_retries: DEFAULT_MAX_RETRIES,
               max_wait: DEFAULT_MAX_WAIT,
               jitter: DEFAULT_JITTER,
               fallback_wait: DEFAULT_FALLBACK_WAIT,
               retry_statuses: DEFAULT_RETRY_STATUSES,
               sleeper: ->(seconds) { sleep(seconds) },
               logger: nil,
               clock: -> { Time.now.utc })
  super(app)
  @max_retries    = Integer(max_retries)
  @max_wait       = Float(max_wait)
  @jitter         = Float(jitter)
  @fallback_wait  = Float(fallback_wait)
  @retry_statuses = Array(retry_statuses).map(&:to_i).freeze
  @sleeper        = sleeper
  @logger         = logger
  @clock          = clock
end

Class Method Details

.parse_header(raw, clock: -> { Time.now.utc }) ⇒ Float?

Parse an HTTP Retry-After header value.

Parameters:

  • raw (String, nil)

    the raw header value

  • clock (#call) (defaults to: -> { Time.now.utc })

    a callable returning current UTC Time, injectable for deterministic tests

Returns:

  • (Float, nil)

    seconds to wait, or nil if ‘raw` is absent, empty, or neither a numeric delta nor a valid HTTP-date



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/legion/extensions/microsoft_teams/faraday/retry_after.rb', line 50

def self.parse_header(raw, clock: -> { Time.now.utc })
  return nil if raw.nil?

  value = raw.to_s.strip
  return nil if value.empty?
  return value.to_f if value.match?(/\A\d+(\.\d+)?\z/)

  begin
    target = Time.httpdate(value).utc
    [(target - clock.call), 0.0].max
    # rubocop:disable Legion/RescueLogging/NoCapture
    # Pure parser — no logger access. The instance method
    # `parse_advertised` warns on the same condition with full
    # context; logging twice would just be noise.
  rescue ArgumentError
    nil
    # rubocop:enable Legion/RescueLogging/NoCapture
  end
end

Instance Method Details

#call(env) ⇒ Object



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/legion/extensions/microsoft_teams/faraday/retry_after.rb', line 90

def call(env)
  attempts          = 0
  total_wait        = 0.0
  last_advertised   = nil

  loop do
    response = @app.call(env.dup)
    return response unless retryable?(response.status)

    last_advertised = parse_advertised(response)
    wait            = compute_wait(last_advertised)

    if attempts >= @max_retries || (total_wait + wait) > @max_wait
      log_giveup(env, response, attempts, total_wait)
      raise Errors::Throttled.new(
        status:      response.status,
        retry_after: last_advertised,
        request:     request_path(env),
        attempts:    attempts
      )
    end

    attempts   += 1
    total_wait += wait
    log_retry(env, response, wait, attempts)
    @sleeper.call(wait)
  end
end