Class: Legion::Extensions::MicrosoftTeams::Faraday::RetryAfter
- Inherits:
-
Faraday::Middleware
- Object
- Faraday::Middleware
- Legion::Extensions::MicrosoftTeams::Faraday::RetryAfter
- 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
-
.parse_header(raw, clock: -> { Time.now.utc }) ⇒ Float?
Parse an HTTP Retry-After header value.
Instance Method Summary collapse
- #call(env) ⇒ Object
-
#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
constructor
rubocop:disable Metrics/ParameterLists.
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.
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 |