Module: Alplus::Retry

Defined in:
lib/alplus/retry.rb

Overview

Shared retry/backoff loop for both Transport (POST /e/errors) and Heartbeat (POST /h/:token) — issue #16 explicitly asks the two share this, unlike the JS SDK's transport.ts/heartbeat.ts, which duplicate it deliberately because heartbeat there already shipped and touching it was the higher-risk move. Both Ruby callers land in this change together, so that risk does not apply here.

Constants (3 attempts, 500ms jittered exponential base, 30s Retry-After cap) mirror packages/sdk/src/core/observe/transport.ts byte-for-byte so all SDKs back off the same way against the same ingest endpoint.

Defined Under Namespace

Classes: Result

Constant Summary collapse

MAX_ATTEMPTS =
3
BACKOFF_BASE_SECONDS =
0.5
BACKOFF_JITTER =
0.5
MAX_RETRY_AFTER_SECONDS =
30
PERMANENT_STATUSES =

400 (malformed request) and 401/403 (bad/scopeless key) and 404 (unrecognized route/token) can't be fixed by retrying.

[400, 401, 403, 404].freeze

Class Method Summary collapse

Class Method Details

.backoff_seconds(attempt) ⇒ Object



70
71
72
73
74
# File 'lib/alplus/retry.rb', line 70

def backoff_seconds(attempt)
  exponential = BACKOFF_BASE_SECONDS * (2**(attempt - 1))
  jitter_factor = (1 - BACKOFF_JITTER) + (rand * 2 * BACKOFF_JITTER)
  exponential * jitter_factor
end

.perform(sleeper: method(:sleep), max_attempts: MAX_ATTEMPTS, max_retry_after_seconds: MAX_RETRY_AFTER_SECONDS) ⇒ Object

Calls the block up to max_attempts times (default MAX_ATTEMPTS). The block must return a Net::HTTPResponse or raise. Sleeps between attempts via sleeper (injectable so specs never wait on real wall-clock time). A 429's Retry-After is capped at max_retry_after_seconds (default MAX_RETRY_AFTER_SECONDS) — Heartbeat passes a much lower cap so a caller pinging synchronously can never be made to block anywhere near the server's full 30s cap. Never raises: a block error on the final attempt is captured on the returned Result, not re-raised.



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/alplus/retry.rb', line 40

def perform(sleeper: method(:sleep), max_attempts: MAX_ATTEMPTS, max_retry_after_seconds: MAX_RETRY_AFTER_SECONDS)
  result = nil

  max_attempts.times do |i|
    attempt = i + 1
    begin
      response = yield(attempt)

      if response.is_a?(Net::HTTPSuccess)
        return Result.new(outcome: :sent, response: response)
      end

      code = response.code.to_i
      if PERMANENT_STATUSES.include?(code)
        return Result.new(outcome: :permanent, response: response)
      end

      result = Result.new(outcome: :exhausted, response: response)
      unless attempt == max_attempts
        sleeper.call(code == 429 ? (retry_after_seconds(response, max_retry_after_seconds) || backoff_seconds(attempt)) : backoff_seconds(attempt))
      end
    rescue StandardError => e
      result = Result.new(outcome: :exhausted, error: e)
      sleeper.call(backoff_seconds(attempt)) unless attempt == max_attempts
    end
  end

  result
end

.retry_after_seconds(response, max_seconds = MAX_RETRY_AFTER_SECONDS) ⇒ Object



76
77
78
79
80
81
82
83
84
# File 'lib/alplus/retry.rb', line 76

def retry_after_seconds(response, max_seconds = MAX_RETRY_AFTER_SECONDS)
  value = response["Retry-After"]
  return nil if value.nil? || value.strip.empty?

  seconds = Float(value, exception: false)
  return nil if seconds.nil? || seconds.negative?

  [seconds, max_seconds].min
end