Module: Alplus::Heartbeat

Defined in:
lib/alplus/heartbeat.rb

Overview

Cron/job liveness pings against Monitor's GET|POST /h/:token (issue #16; docs/ARCHITECTURE.md §8): the token is the auth (recognized → 202 even paused, unrecognized → 404); ?state=start|finish|fail, an unrecognized state falls back to finish (still records the run, matching the Elixir SDK) rather than silently no-op-ing. Mirrors packages/sdk/src/core/heartbeat.ts's wire contract and reuses Retry (issue #15) for backoff/Retry-After instead of duplicating it — see Retry's comment on why Ruby shares this where the JS SDK deliberately doesn't.

Alplus.heartbeat (the public entry point in alplus.rb) calls ping synchronously on the caller's thread: a cron/job runner is not a web request, and the caller (typically the very last statement of a job) wants the ping actually sent before the process exits, not fired into a background thread that may never run. This DOES block the caller briefly — bounded to HEARTBEAT_MAX_ATTEMPTS attempts and a HEARTBEAT_MAX_RETRY_AFTER_SECONDS cap on any 429 Retry-After (ignoring a server-requested wait beyond that), so a slow ingest endpoint can add at most ~2s, never Transport's full ~30s budget.

Constant Summary collapse

VALID_STATES =
%w[start finish fail].freeze
HEARTBEAT_MAX_ATTEMPTS =
2
HEARTBEAT_MAX_RETRY_AFTER_SECONDS =
2

Class Method Summary collapse

Class Method Details

.build_uri(token, state, ping_id, config) ⇒ Object

Exposed for tests asserting the exact URL shape; not otherwise part of the public surface.



74
75
76
77
78
79
# File 'lib/alplus/heartbeat.rb', line 74

def build_uri(token, state, ping_id, config)
  base = config.endpoint.to_s.sub(%r{/+\z}, "")
  uri = URI.parse("#{base}/h/#{URI.encode_www_form_component(token)}")
  uri.query = URI.encode_www_form(state: state, ping_id: ping_id)
  uri
end

.generate_ping_idObject



68
69
70
# File 'lib/alplus/heartbeat.rb', line 68

def generate_ping_id
  SecureRandom.uuid
end

.ping(token, state: "finish", config: Alplus.configuration, sleeper: method(:sleep), ping_id: nil) ⇒ Object

Never raises: an internal error, network failure, or retryable non-ok response is retried (bounded, see module doc) and then swallowed, logged via config.logger if the retry budget is exhausted. Returns nil always.

The SAME ping_id is reused on every retry attempt within one call (issue #16 defect): ingest dedups a retried ping on ping_id, so reusing it — instead of minting a fresh id per HTTP attempt — is what keeps a retried fail/finish from being processed as two separate events (a duplicate incident/notification).



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/alplus/heartbeat.rb', line 47

def ping(token, state: "finish", config: Alplus.configuration, sleeper: method(:sleep), ping_id: nil)
  resolved_state = VALID_STATES.include?(state.to_s) ? state.to_s : "finish"
  resolved_ping_id = ping_id || generate_ping_id
  uri = build_uri(token, resolved_state, resolved_ping_id, config)

  result = Retry.perform(
    sleeper: sleeper,
    max_attempts: HEARTBEAT_MAX_ATTEMPTS,
    max_retry_after_seconds: HEARTBEAT_MAX_RETRY_AFTER_SECONDS
  ) { |_attempt| post(uri, config) }

  if result.outcome == :exhausted
    detail = result.error ? "#{result.error.class}: #{result.error.message}" : "status #{result.response&.code}"
    config.logger&.warn("[alplus] heartbeat exhausted #{HEARTBEAT_MAX_ATTEMPTS} attempt(s) (token #{token.inspect}, state #{resolved_state.inspect}): #{detail}")
  end
  nil
rescue StandardError => e
  config.logger&.warn("[alplus] heartbeat failed internally: #{e.class}: #{e.message}")
  nil
end

.post(uri, config) ⇒ Object



81
82
83
84
85
86
87
88
# File 'lib/alplus/heartbeat.rb', line 81

def post(uri, config)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == "https"
  http.open_timeout = config.open_timeout
  http.read_timeout = config.read_timeout

  http.request(Net::HTTP::Post.new(uri.request_uri))
end