Class: Basecamp::Config

Inherits:
Object
  • Object
show all
Defined in:
lib/basecamp/config.rb

Overview

Configuration for the Basecamp API client.

Examples:

Creating config with defaults

config = Basecamp::Config.new

Creating config with custom values

config = Basecamp::Config.new(
  base_url: "https://3.basecampapi.com",
  timeout: 60,
  max_retries: 3
)

Loading config from environment

config = Basecamp::Config.from_env

Constant Summary collapse

DEFAULT_BASE_URL =

Default values

"https://3.basecampapi.com"
DEFAULT_TIMEOUT =
30
DEFAULT_MAX_RETRIES =
3
DEFAULT_BASE_DELAY =
1.0
DEFAULT_MAX_JITTER =
0.1
DEFAULT_MAX_PAGES =
10_000
MAX_BACKOFF_DELAY =

Ceiling on the backoff term (SPEC §7, "Backoff Ceiling"), in seconds. Jitter is added after the clamp, so the longest single backoff sleep is this plus max_jitter.

30.0

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES, base_delay: DEFAULT_BASE_DELAY, max_jitter: DEFAULT_MAX_JITTER, max_pages: DEFAULT_MAX_PAGES) ⇒ Config

Creates a new configuration with the given options.

Parameters:

  • base_url (String) (defaults to: DEFAULT_BASE_URL)

    API base URL

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT)

    request timeout in seconds

  • max_retries (Integer) (defaults to: DEFAULT_MAX_RETRIES)

    total request attempts for GET requests, including the initial request

  • base_delay (Float) (defaults to: DEFAULT_BASE_DELAY)

    initial backoff delay

  • max_jitter (Float) (defaults to: DEFAULT_MAX_JITTER)

    maximum jitter

  • max_pages (Integer) (defaults to: DEFAULT_MAX_PAGES)

    maximum pages to fetch



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/basecamp/config.rb', line 125

def initialize(
  base_url: DEFAULT_BASE_URL,
  timeout: DEFAULT_TIMEOUT,
  max_retries: DEFAULT_MAX_RETRIES,
  base_delay: DEFAULT_BASE_DELAY,
  max_jitter: DEFAULT_MAX_JITTER,
  max_pages: DEFAULT_MAX_PAGES
)
  @base_url = normalize_url(base_url)
  @timeout = timeout
  @max_retries = max_retries
  @base_delay = base_delay
  @max_jitter = max_jitter
  @max_pages = max_pages

  unless @base_url == normalize_url(DEFAULT_BASE_URL) || localhost?(@base_url)
    Basecamp::Security.require_https!(@base_url, "base URL")
  end
  validate!
end

Instance Attribute Details

#base_delayFloat

Returns initial backoff delay in seconds.

Returns:

  • (Float)

    initial backoff delay in seconds



32
33
34
# File 'lib/basecamp/config.rb', line 32

def base_delay
  @base_delay
end

#base_urlString

Returns API base URL.

Returns:

  • (String)

    API base URL



22
23
24
# File 'lib/basecamp/config.rb', line 22

def base_url
  @base_url
end

#max_jitterFloat

Returns maximum jitter to add to delays in seconds.

Returns:

  • (Float)

    maximum jitter to add to delays in seconds



35
36
37
# File 'lib/basecamp/config.rb', line 35

def max_jitter
  @max_jitter
end

#max_pagesInteger

Returns maximum pages to fetch in paginated requests.

Returns:

  • (Integer)

    maximum pages to fetch in paginated requests



38
39
40
# File 'lib/basecamp/config.rb', line 38

def max_pages
  @max_pages
end

#max_retriesInteger

Returns total request attempts for GET requests, including the initial request (0 sends no requests at all and raises).

Returns:

  • (Integer)

    total request attempts for GET requests, including the initial request (0 sends no requests at all and raises)



29
30
31
# File 'lib/basecamp/config.rb', line 29

def max_retries
  @max_retries
end

#timeoutInteger

Returns request timeout in seconds.

Returns:

  • (Integer)

    request timeout in seconds



25
26
27
# File 'lib/basecamp/config.rb', line 25

def timeout
  @timeout
end

Class Method Details

.from_envConfig

Creates a Config from environment variables.

Environment variables:

  • BASECAMP_BASE_URL: API base URL
  • BASECAMP_TIMEOUT: Request timeout in seconds
  • BASECAMP_MAX_RETRIES: Total request attempts for GET requests, including the initial request

Returns:



154
155
156
157
158
159
160
# File 'lib/basecamp/config.rb', line 154

def self.from_env
  new(
    base_url: ENV.fetch("BASECAMP_BASE_URL", DEFAULT_BASE_URL),
    timeout: ENV.fetch("BASECAMP_TIMEOUT", DEFAULT_TIMEOUT).to_i,
    max_retries: ENV.fetch("BASECAMP_MAX_RETRIES", DEFAULT_MAX_RETRIES).to_i
  )
end

.from_file(path) ⇒ Config

Loads configuration from a JSON file, with environment overrides.

Parameters:

  • path (String)

    path to JSON config file

Returns:



166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/basecamp/config.rb', line 166

def self.from_file(path)
  data = JSON.parse(File.read(path))
  config = new(
    base_url: data["base_url"] || DEFAULT_BASE_URL,
    timeout: data["timeout"] || DEFAULT_TIMEOUT,
    max_retries: data["max_retries"] || DEFAULT_MAX_RETRIES
  )
  config.load_from_env
  config
rescue Errno::ENOENT
  from_env
end

.global_config_dirString

Returns the default global config directory.

Returns:

  • (String)


192
193
194
195
# File 'lib/basecamp/config.rb', line 192

def self.global_config_dir
  config_dir = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
  File.join(config_dir, "basecamp")
end

.saturating_backoff(base_delay, attempt) ⇒ Float

Exponential backoff for a 1-based attempt, saturating at MAX_BACKOFF_DELAY.

The clamp is load-bearing rather than defensive. Ruby's ** promotes instead of overflowing, so base_delay * (2**(attempt - 1)) on a long failure streak coerces to Float::INFINITY — and sleep(Float::INFINITY) never returns. A retry that never happens is not backoff.

The exponent is compared against the point where the term reaches the ceiling before the power is evaluated, so no intermediate leaves the Float range and the term saturates AT the ceiling for every positive base — the same contract Go, Kotlin and Swift get from comparing their multiplier against MAX_BACKOFF_DELAY / base before multiplying.

Below that point the term is scaled with Math.ldexp, which computes base * 2**e directly. 2**e would be an unbounded Integer that coerces to Float::INFINITY long before the product leaves the Float range, which is what forced the fixed cap this replaces.

Parameters:

  • base_delay (Float)

    initial backoff delay in seconds

  • attempt (Integer)

    1-based attempt number

Returns:

  • (Float)

    the backoff term in seconds



104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/basecamp/config.rb', line 104

def self.saturating_backoff(base_delay, attempt)
  if base_delay <= 0
    0.0
  else
    exponent = [ attempt - 1, 0 ].max
    if exponent >= saturating_exponent(base_delay)
      MAX_BACKOFF_DELAY
    else
      [ Math.ldexp(base_delay, exponent), MAX_BACKOFF_DELAY ].min.to_f
    end
  end
end

.saturating_exponent(base_delay) ⇒ Integer

Smallest exponent e with base_delay * 2**e >= MAX_BACKOFF_DELAY.

Derived from the configured base rather than assumed. A fixed exponent cap plus a trailing min(..., MAX_BACKOFF_DELAY) looks equivalent and is not: for a small enough base the capped product never reaches the ceiling, so the delay plateaus below it forever. At base_delay = 1e-30 a cap of 64 pins every attempt from 65 on at ~1.84e-11s — a tight retry loop, which is the failure SPEC §7's ceiling exists to prevent, not an instance of it.

Computed in the LOG domain rather than as MAX_BACKOFF_DELAY / base_delay. That ratio coerces to Float::INFINITY for any base below ~1.67e-307, and falling back to a fixed 1023 then saturates early: base_delay = 1e-307 reaches only ~8.99s at exponent 1023, so returning the 30s ceiling there overstates the specified term instead of tracking it. The log form has no such cliff, so the numeric backstop is gone entirely rather than merely made rarer.

Parameters:

  • base_delay (Float)

    initial backoff delay in seconds, strictly positive

Returns:

  • (Integer)

    the exponent at which the term reaches the ceiling



72
73
74
75
76
77
78
79
80
81
# File 'lib/basecamp/config.rb', line 72

def self.saturating_exponent(base_delay)
  # log2 is correctly rounded but the subtraction is not, so the estimate can
  # land one either side of the true boundary. Both corrections are bounded
  # and evaluate the term with Math.ldexp, which scales directly and never
  # forms 2**e.
  exponent = [ (Math.log2(MAX_BACKOFF_DELAY) - Math.log2(base_delay)).ceil, 0 ].max
  exponent -= 1 while exponent > 0 && Math.ldexp(base_delay, exponent - 1) >= MAX_BACKOFF_DELAY
  exponent += 1 while Math.ldexp(base_delay, exponent) < MAX_BACKOFF_DELAY
  exponent
end

Instance Method Details

#load_from_envself

Loads environment variable overrides into this config.

Returns:

  • (self)


181
182
183
184
185
186
187
188
# File 'lib/basecamp/config.rb', line 181

def load_from_env
  @base_url = normalize_url(ENV["BASECAMP_BASE_URL"]) if ENV["BASECAMP_BASE_URL"]
  @timeout = ENV["BASECAMP_TIMEOUT"].to_i if ENV["BASECAMP_TIMEOUT"]
  @max_retries = ENV["BASECAMP_MAX_RETRIES"].to_i if ENV["BASECAMP_MAX_RETRIES"]
  Basecamp::Security.require_https!(@base_url, "base URL") unless localhost?(@base_url)
  validate!
  self
end