Class: Risenexa::Tracking::HttpClient

Inherits:
Object
  • Object
show all
Defined in:
lib/risenexa/tracking/http_client.rb

Overview

HTTP layer for the Risenexa Tracking SDK.

Implements the retry algorithm from SDK-SPEC.md Section 4:

  • Exponential backoff: BASE_DELAY * (MULTIPLIER ^ attempt), capped at MAX_DELAY
  • Jitter: ±JITTER_FACTOR of the capped delay
  • Retryable: 429, 500, 502, 503, timeout, connection errors
  • Non-retryable: 401, 403, 404, 422 — raise immediately
  • Idempotency: UUID v4 generated ONCE before first attempt, reused on all retries

Constant Summary collapse

BASE_DELAY =

seconds

1.0
MULTIPLIER =
2.0
MAX_DELAY =

seconds

30.0
JITTER_FACTOR =

±20% of the capped delay

0.20
RETRYABLE_STATUSES =
[429, 500, 502, 503].freeze
NON_RETRYABLE_STATUSES =
[401, 403, 404, 422].freeze
TRACK_PATH =
"/api/v1/track"

Instance Method Summary collapse

Constructor Details

#initialize(api_key:, base_url:, startup_slug:, timeout:, max_retries:) ⇒ HttpClient

Returns a new instance of HttpClient.



29
30
31
32
33
34
35
# File 'lib/risenexa/tracking/http_client.rb', line 29

def initialize(api_key:, base_url:, startup_slug:, timeout:, max_retries:)
  @api_key      = api_key
  @base_url     = base_url
  @startup_slug = startup_slug
  @timeout      = timeout
  @max_retries  = max_retries
end

Instance Method Details

#post(payload) ⇒ Result

POST the event payload to the Risenexa tracking endpoint.

Generates a UUID v4 idempotency anchor before the first attempt and reuses it across all retry attempts.

Parameters:

  • payload (Hash) —

    Event fields (event_type, user_id, and optional fields)

Returns:

  • (Result) —

    on success (HTTP 202)

Raises:



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/risenexa/tracking/http_client.rb', line 51

def post(payload)
  # Use caller-provided event_id if present; otherwise generate a fresh UUID v4.
  # The event_id is generated ONCE before the first attempt and reused on all retries.
  event_id     = payload[:event_id] || SecureRandom.uuid
  full_payload = payload.merge(event_id: event_id)

  attempt = 0

  loop do
    begin
      response = perform_request(full_payload)
    rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED,
           SocketError, Errno::EHOSTUNREACH => e
      if attempt >= @max_retries
        raise ConnectionError.new("Connection failed after #{attempt + 1} attempt(s): #{e.message}", cause: e)
      end

      sleep(calculate_backoff(attempt))
      attempt += 1
      next
    end

    case response.code.to_i
    when 202
      body = parse_json(response.body)
      return Result.new(status_code: 202, event_id: event_id, body: body)
    when *NON_RETRYABLE_STATUSES
      raise_non_retryable!(response)
    when *RETRYABLE_STATUSES
      if attempt >= @max_retries
        status = response.code.to_i
        if status == 429
          raise RateLimitError.new(
            "Rate limit exceeded after #{attempt + 1} attempt(s)",
            retry_after: parse_retry_after(response)
          )
        else
          raise MaxRetriesExceededError.new(
            "Max retries exceeded after #{attempt + 1} attempt(s)",
            last_response: response
          )
        end
      end

      delay = if response.code.to_i == 429
        parse_retry_after(response) || calculate_backoff(attempt)
      else
        calculate_backoff(attempt)
      end

      sleep(delay)
      attempt += 1
    else
      raise Error, "Unexpected response status: #{response.code}"
    end
  end
end