Class: Clacky::PlatformHttpClient

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/platform_http_client.rb

Overview

PlatformHttpClient provides a resilient HTTP client for all calls to the OpenClacky platform API (www.openclacky.com and its fallback domain).

Features:

- Automatic retry with exponential back-off on transient failures
- Transparent domain failover: if the primary domain times out or returns a
  5xx error, the request is automatically retried against the fallback domain
- Unified large-file download entry point (#download_file) that reuses the
  same primary → fallback failover policy as API calls
- Override via CLACKY_LICENSE_SERVER env var (used in development)

Usage:

client = Clacky::PlatformHttpClient.new
result = client.post("/api/v1/licenses/activate", payload)
# result => { success: true, data: {...} }
#        or { success: false, error: "...", data: {} }

Defined Under Namespace

Classes: RetryableNetworkError

Constant Summary collapse

PRIMARY_HOST =

Primary CDN-accelerated endpoint

"https://www.openclacky.com"
FALLBACK_HOST =

Direct fallback — bypasses EdgeOne, used when the primary times out

"https://openclacky.up.railway.app"
ATTEMPTS_PER_HOST =

Number of attempts per domain (1 = no retry within the same domain)

2
INITIAL_BACKOFF =

Initial back-off between retries within the same domain (seconds)

0.5
OPEN_TIMEOUT =

Connection / read timeouts (seconds) for API calls

8
READ_TIMEOUT =
15
DOWNLOAD_READ_TIMEOUT =

Read timeout for streaming large file downloads (seconds)

120
DOWNLOAD_MAX_REDIRECTS =

Max HTTP redirects followed by #download_file per host attempt

10
API_ERROR_MESSAGES =

API error code → human-readable message table (shared across all callers)

{
  "invalid_proof"        => "Invalid license key — please check and try again.",
  "invalid_signature"    => "Invalid request signature.",
  "nonce_replayed"       => "Duplicate request detected. Please try again.",
  "timestamp_expired"    => "System clock is out of sync. Please adjust your time settings.",
  "license_revoked"      => "This license has been revoked. Please contact support.",
  "license_expired"      => "This license has expired. Please renew to continue.",
  "device_limit_reached" => "Device limit reached for this license.",
  "device_revoked"       => "This device has been revoked from the license.",
  "invalid_license"      => "License key not found. Please verify the key.",
  "device_not_found"     => "Device not registered. Please re-activate."
}.freeze

Instance Method Summary collapse

Constructor Details

#initialize(base_url: nil) ⇒ PlatformHttpClient

Returns a new instance of PlatformHttpClient.

Parameters:

  • base_url (String, nil) (defaults to: nil)

    Override the primary host (e.g. for local dev). When set, the fallback domain is disabled — only the override URL is used.



59
60
61
62
63
64
65
66
# File 'lib/clacky/platform_http_client.rb', line 59

def initialize(base_url: nil)
  if base_url
    # Developer / test override: single host, no failover
    @hosts   = [base_url]
  else
    @hosts = [PRIMARY_HOST, FALLBACK_HOST]
  end
end

Instance Method Details

#delete(path, headers: {}) ⇒ Object

Send a DELETE request (no body).



94
95
96
# File 'lib/clacky/platform_http_client.rb', line 94

def delete(path, headers: {})
  request_with_failover(:delete, path, nil, headers)
end

#download_file(url, dest, read_timeout: DOWNLOAD_READ_TIMEOUT) ⇒ Hash

Stream a remote URL to a local file path, with automatic primary → fallback host failover.

This is the unified entry point for all large-file downloads (brand skill ZIPs, platform-hosted assets, etc.). Callers should NOT build their own Net::HTTP loops — failover, retry, redirects, and timeouts are handled here.

Host failover policy:

- If +url+'s host matches PRIMARY_HOST and the request fails with a
  retryable error (timeout, connection reset, SSL, 5xx), the URL is
  rewritten to FALLBACK_HOST (same path/query) and retried.
- Both hosts serve the same Rails backend and share +secret_key_base+,
  so ActiveStorage signed_ids resolve identically on either.
- Third-party hosts (e.g. S3 presigned URLs reached via redirect) are
  fetched as-is without host rewriting.

Each host gets ATTEMPTS_PER_HOST attempts with exponential back-off. Up to DOWNLOAD_MAX_REDIRECTS redirects are followed per attempt.

Parameters:

  • url (String)

    Full URL to download

  • dest (String)

    Local path to write the response body into. The file is written atomically (temp path + rename) so a failed download cannot leave a half-written file.

  • read_timeout (Integer) (defaults to: DOWNLOAD_READ_TIMEOUT)

    Override read timeout (seconds)

Returns:

  • (Hash)

    { success: Boolean, bytes: Integer, error: String }



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/clacky/platform_http_client.rb', line 143

def download_file(url, dest, read_timeout: DOWNLOAD_READ_TIMEOUT)
  candidate_urls = [url]
  # Only auto-add a fallback candidate when the URL is on our primary host.
  # External hosts (S3, CDNs, user-provided URLs) are fetched as-is.
  if primary_host_url?(url)
    candidate_urls << swap_to_fallback_host(url)
  end

  last_error = nil
  FileUtils.mkdir_p(File.dirname(dest))
  tmp_dest = "#{dest}.part"

  candidate_urls.each_with_index do |candidate, host_index|
    ATTEMPTS_PER_HOST.times do |attempt|
      begin
        bytes = stream_download(candidate, tmp_dest, read_timeout: read_timeout)
        File.rename(tmp_dest, dest)
        return { success: true, bytes: bytes, error: nil }
      rescue RetryableNetworkError => e
        last_error = e
        backoff    = INITIAL_BACKOFF * (2**attempt)
        Clacky::Logger.debug(
          "[PlatformHTTP] DOWNLOAD #{candidate} attempt #{attempt + 1} failed: " \
          "#{e.message} — retrying in #{backoff}s"
        )
        sleep(backoff)
      end
    end

    if host_index + 1 < candidate_urls.size
      Clacky::Logger.debug(
        "[PlatformHTTP] Primary host exhausted for download, switching to fallback: " \
        "#{candidate_urls[host_index + 1]}"
      )
    end
  end

  FileUtils.rm_f(tmp_dest)
  { success: false, bytes: 0, error: "Download failed: #{last_error&.message || "unknown"}" }
end

#get(path, headers: {}) ⇒ Hash

Send a GET request and return a normalised result hash. Query string parameters should be appended to path by the caller.

Parameters:

  • path (String)

    API path with optional query string

  • headers (Hash) (defaults to: {})

    Additional HTTP headers (optional)

Returns:

  • (Hash)

    { success: Boolean, data: Hash, error: String }



84
85
86
# File 'lib/clacky/platform_http_client.rb', line 84

def get(path, headers: {})
  request_with_failover(:get, path, nil, headers)
end

#multipart_patch(path, body_bytes, boundary, read_timeout: READ_TIMEOUT) ⇒ Object

Send a multipart/form-data PATCH. Same contract as #multipart_post.



112
113
114
115
116
# File 'lib/clacky/platform_http_client.rb', line 112

def multipart_patch(path, body_bytes, boundary, read_timeout: READ_TIMEOUT)
  headers = { "Content-Type" => "multipart/form-data; boundary=#{boundary}" }
  request_with_failover(:multipart_patch, path, body_bytes, headers,
                        read_timeout_override: read_timeout)
end

#multipart_post(path, body_bytes, boundary, read_timeout: READ_TIMEOUT) ⇒ Hash

Send a multipart/form-data POST.

Parameters:

  • path (String)

    API path

  • body_bytes (String)

    Pre-built binary multipart body

  • boundary (String)

    Multipart boundary string (without leading –)

  • read_timeout (Integer) (defaults to: READ_TIMEOUT)

    Override read timeout (uploads may be slow)

Returns:

  • (Hash)

    { success: Boolean, data: Hash, error: String }



105
106
107
108
109
# File 'lib/clacky/platform_http_client.rb', line 105

def multipart_post(path, body_bytes, boundary, read_timeout: READ_TIMEOUT)
  headers = { "Content-Type" => "multipart/form-data; boundary=#{boundary}" }
  request_with_failover(:multipart_post, path, body_bytes, headers,
                        read_timeout_override: read_timeout)
end

#patch(path, payload, headers: {}) ⇒ Object

Send a PATCH request. Same contract as #post.



89
90
91
# File 'lib/clacky/platform_http_client.rb', line 89

def patch(path, payload, headers: {})
  request_with_failover(:patch, path, payload, headers)
end

#post(path, payload, headers: {}) ⇒ Hash

Send a POST request with a JSON body and return a normalised result hash.

Parameters:

  • path (String)

    API path, e.g. “/api/v1/licenses/activate”

  • payload (Hash)

    Request body (will be JSON-encoded)

  • headers (Hash) (defaults to: {})

    Additional HTTP headers (optional)

Returns:

  • (Hash)

    { success: Boolean, data: Hash, error: String }



74
75
76
# File 'lib/clacky/platform_http_client.rb', line 74

def post(path, payload, headers: {})
  request_with_failover(:post, path, payload, headers)
end