Class: Unitpost::HttpClient

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

Overview

HTTP transport — the one place that talks to the Unitpost API.

Hand-written (not generated): auth, the required User-Agent, idempotency, query building, JSON encode/decode, and turning a non-2xx response into a typed Unitpost::Error. Resource methods (resources.rb) call through here.

Constant Summary collapse

SDK_VERSION =
Unitpost::VERSION
DEFAULT_BASE_URL =

The live API host. Matches the OpenAPI servers.url and the app's own brand.ts FALLBACK_APP_ORIGIN. SINGLE HOST: the API lives on the same origin as the app (www.unitpost.com) under /api/v1 — no api. subdomain. Override per-client with base_url / UNITPOST_BASE_URL.

"https://www.unitpost.com"

Instance Method Summary collapse

Constructor Details

#initialize(api_key: nil, base_url: nil, timeout: 30, headers: {}, adapter: nil, stubs: nil, max_retries: 2, retry_base_delay: 0.5, max_retry_delay: 20.0, sleeper: nil) ⇒ HttpClient

Returns a new instance of HttpClient.



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/unitpost/http_client.rb', line 23

def initialize(api_key: nil, base_url: nil, timeout: 30, headers: {}, adapter: nil, stubs: nil,
               max_retries: 2, retry_base_delay: 0.5, max_retry_delay: 20.0, sleeper: nil)
  key = api_key || ENV.fetch("UNITPOST_API_KEY", nil)
  if key.nil? || key.empty?
    raise Unitpost::Error.new(
      code: "missing_api_key",
      message: "No API key provided. Pass Unitpost::Client.new(api_key:) or set the UNITPOST_API_KEY environment variable. Create a key at https://www.unitpost.com → Settings → API keys. Full reference: https://www.unitpost.com/docs",
      status: 0
    )
  end
  @api_key = key
  base = (base_url || ENV["UNITPOST_BASE_URL"] || DEFAULT_BASE_URL).sub(%r{/\z}, "")
  @prefix = "/api/#{Unitpost::Generated::API_VERSION}"
  @extra_headers = headers
  # Automatic retry policy for transient failures (429, 5xx, timeouts,
  # network errors). max_retries: 0 disables it. See SECURITY.md §6.
  @max_retries = [max_retries, 0].max
  @retry_base_delay = retry_base_delay
  @max_retry_delay = max_retry_delay
  @sleeper = sleeper || ->(seconds) { sleep(seconds) }

  @conn = Faraday.new(url: base) do |f|
    f.options.timeout = timeout
    if stubs
      f.adapter(:test, stubs)
    else
      f.adapter(adapter || Faraday.default_adapter)
    end
  end
end

Instance Method Details

#request(http_method, path, query: nil, body: nil, idempotency_key: nil) ⇒ Object

Make a request, retrying transient failures (429, 5xx, timeouts, network errors) with backoff. A 429/503 Retry-After is honored (clamped to max_retry_delay); otherwise exponential backoff with full jitter. 4xx (other than 429) is never retried. A request is only retried when it's safe to replay: a GET, or a write carrying an Idempotency-Key (deduped server-side). A keyless write is never auto-retried, so a lost response can't double-send.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/unitpost/http_client.rb', line 61

def request(http_method, path, query: nil, body: nil, idempotency_key: nil)
  retry_safe = http_method == "GET" || !idempotency_key.nil?
  attempt = 0
  loop do
    result, retry_after = attempt_request(
      http_method, path, query: query, body: body, idempotency_key: idempotency_key
    )
    err = result.error
    retryable = !err.nil? && (err.status.zero? || retryable_status?(err.status))
    return result if err.nil? || !retryable || !retry_safe || attempt >= @max_retries

    @sleeper.call(backoff_delay(attempt, retry_after))
    attempt += 1
  end
end