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 (auto-detected, 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
KNOWN_ERROR_CODES =

API error code → human-readable message table (shared across all callers) Server error codes that have a localized message under the "platform.error." i18n key (see locales/en.rb & zh.rb).

%w[
  invalid_proof invalid_signature nonce_replayed timestamp_expired
  license_revoked license_expired device_limit_reached device_revoked
  invalid_license device_not_found contributor_required missing_device_token
  invalid_device_token device_token_revoked device_token_expired owner_user_not_found
].freeze

Instance Method Summary collapse

Constructor Details

#initializePlatformHttpClient

Auto-detects the target host(s):

- When CLACKY_LICENSE_SERVER is set → single host (dev override, no failover)
- Otherwise                   → [PRIMARY_HOST, FALLBACK_HOST]


57
58
59
60
61
62
63
# File 'lib/clacky/platform_http_client.rb', line 57

def initialize
  if (override = ENV["CLACKY_LICENSE_SERVER"]) && !override.empty?
    @hosts = [override]
  else
    @hosts = [PRIMARY_HOST, FALLBACK_HOST]
  end
end

Instance Method Details

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

Send a DELETE request (no body).



91
92
93
# File 'lib/clacky/platform_http_client.rb', line 91

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 }



140
141
142
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
# File 'lib/clacky/platform_http_client.rb', line 140

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 }



81
82
83
# File 'lib/clacky/platform_http_client.rb', line 81

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.



109
110
111
112
113
# File 'lib/clacky/platform_http_client.rb', line 109

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 }



102
103
104
105
106
# File 'lib/clacky/platform_http_client.rb', line 102

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.



86
87
88
# File 'lib/clacky/platform_http_client.rb', line 86

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 }



71
72
73
# File 'lib/clacky/platform_http_client.rb', line 71

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