cru-flags 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddbe75a3b8dd51184b5c884571040346e4f33e96bd0301e7dc845a290b8bb10b
4
- data.tar.gz: c3bf794d63aaae3a521f9916bd5c00d0c620bdc363b1aa08fd850a3b31a1c136
3
+ metadata.gz: 4eab9de685d2ede692ce24cf2b1db8d939b9f316e3ede26c6d4ef4796bee6ffe
4
+ data.tar.gz: 2db33c91a5571087296900d9edf2ec012c397aa6bab028d731bfefde372d2c81
5
5
  SHA512:
6
- metadata.gz: 9415e6972df83cb2c45d4a7b7de065ad99a47f884f507a057cc7550c96b1c9f4143be31eca001826d34393e0304ebaea4808a39580ec924df6f337a466296f82
7
- data.tar.gz: f2f12796650212518e932577bc789f1bbf25746fbe2aea574654e7ddad8e37f91badfdb73879f46d9dd871a1a556bc477b364d20aa54b195ba749112224277a3
6
+ metadata.gz: 131419c9830fafc66f0a169cf756f2ea0017e635dd877a3b0d50f2227973aa6f6e4462af2dae4bec516b45bee9e5399800a8f72daa331daf439122cca3fea41a
7
+ data.tar.gz: 3fa93d5e3b293f540e8f354b0bf091737e76b19ebdace9ad3a610d7444f41203661f2bf4d384c5162cf084b7149ed519b45a16082eaedb5280b45c93fc86ec3c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.1](https://github.com/CruGlobal/cru-flags-ruby/compare/cru-flags/v0.1.0...cru-flags/v0.1.1) (2026-08-24)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * single-attempt fetches, Client env fallback, streaming body cap, IPv6 hosts ([#7](https://github.com/CruGlobal/cru-flags-ruby/issues/7)) ([f0710b1](https://github.com/CruGlobal/cru-flags-ruby/commit/f0710b1e0ea79ef9e6ee44be972834575127173c))
9
+
3
10
  ## 0.1.0 (2026-08-24)
4
11
 
5
12
 
@@ -11,5 +18,3 @@
11
18
  ### Miscellaneous Chores
12
19
 
13
20
  * release 0.1.0 ([2ed0e67](https://github.com/CruGlobal/cru-flags-ruby/commit/2ed0e67e0ce0441d80ba8d1dc6559a9d8995de17))
14
-
15
- ## Changelog
data/docs/design.md CHANGED
@@ -317,8 +317,15 @@ that, in an initializer:
317
317
  so the value is always already set when we look. Instead the Railtie sets
318
318
  `config.flipper.strict = false` only when `ENV["FLIPPER_STRICT"].nil?` AND
319
319
  the current value equals Flipper's own computed default for this Rails env —
320
- i.e. only when we can prove nobody chose it. `FLIPPER_STRICT=warn` (or any
321
- app-set value) is always honored. Rationale: local dev and brand-new
320
+ i.e. only when we cannot tell that anybody chose it. `FLIPPER_STRICT` is
321
+ always honored when set. An app-set `config.flipper.strict` is honored too,
322
+ **except when it happens to equal the environment's own flipper default**
323
+ (`:warn` in development, `false` elsewhere) — that case is
324
+ indistinguishable from nobody setting it, and is quieted. Reading the value
325
+ is the only signal available; distinguishing it would mean hooking the
326
+ assignment itself, which is not worth the coupling for a case whose
327
+ observable effect is the value the app asked for anyway in every
328
+ environment but development. Rationale: local dev and brand-new
322
329
  projects legitimately have no flag document, and `Flipper.add` is never
323
330
  called by app code (flags are born in the service), so dev-mode `:warn`
324
331
  would fire on every check.
@@ -421,17 +428,26 @@ One tick:
421
428
  1. Build a `Net::HTTP::Get` with `Accept: application/json`, a `User-Agent`
422
429
  of `cru-flags-ruby/<version>`, and `If-None-Match: <etag>` when an ETag is
423
430
  stored.
424
- 2. One-shot `Net::HTTP.start(host, port, use_ssl:, open_timeout: t,
431
+ 2. One-shot `Net::HTTP.start(hostname, port, use_ssl:, open_timeout: t,
425
432
  read_timeout: t, write_timeout: t)` — no keep-alive, no connection
426
- pooling; the right trade for one request per 30 seconds. **No retries
427
- within a tick** — the next tick is the retry. Redirects are followed to a
433
+ pooling; the right trade for one request per 30 seconds. `URI#hostname`,
434
+ not `URI#host`: the latter keeps the brackets an IPv6 literal carries in
435
+ a URL (`[::1]`), which `getaddrinfo` cannot resolve. **No retries
436
+ within a tick** — the next tick is the retry, and Net::HTTP's own
437
+ `max_retries` (default 1) is set to 0 explicitly, since its default would
438
+ silently re-issue the failed GET inside the same tick. Redirects are followed to a
428
439
  fixed limit of **3** (Net::HTTP does not follow them itself; the siblings
429
440
  inherit auto-follow from `fetch`/`urllib`). Ruby-first hardening the
430
441
  siblings may want to adopt: each redirect hop's merged URI is
431
442
  re-validated (http/https scheme, non-empty host) before it is followed,
432
- and the response body is capped at `MAX_BODY_BYTES` (1 MiB) before
433
- parsing, both failing the tick rather than handing an attacker-controlled
434
- value to `Net::HTTP` or `JSON.parse`.
443
+ and the response body is capped at `MAX_BODY_BYTES` (1 MiB) **as it
444
+ streams** read chunk by chunk with a running byte count, ending the
445
+ read the moment the cap is passed — so an oversized or endless body never
446
+ reaches the heap whole. A `200` that overruns fails the tick (a truncated
447
+ document must never be parsed); on any other status the body is only ever
448
+ an excerpt or unused, so the overrun just ends the read and the status's
449
+ own outcome stands. Both guards fail the tick rather than handing an
450
+ attacker-controlled value to `Net::HTTP` or `JSON.parse`.
435
451
  3. Outcomes:
436
452
 
437
453
  | Outcome | Action | Health |
@@ -504,11 +520,19 @@ re-running the suite on 3.2 / 3.3 / 3.4.
504
520
  path derivation turns the hyphenated name into `lib/cru/flags/version.rb`
505
521
  and would silently bump a file that doesn't exist).
506
522
  - `required_ruby_version = ">= 3.2"` — the fleet floor.
507
- - Runtime dependency: **`flipper` (`~> 1.4`) only.** The client core uses
508
- stdlib exclusively; the Flipper adapter and Railtie are the gem's reason to
509
- have its one dependency, and every consumer already carries it. (If a
510
- flipper-less consumer ever appears, splitting a zero-dep core gem out is a
511
- follow-up, not a v0 concern.)
523
+ - Runtime dependencies: **`flipper` (`~> 1.4`), plus a floor on the stdlib
524
+ gem `json` (`>= 2.4`).** The client core uses stdlib exclusively; the
525
+ Flipper adapter and Railtie are the gem's reason to have `flipper`, and
526
+ every consumer already carries it. (If a flipper-less consumer ever
527
+ appears, splitting a zero-dep core gem out is a follow-up, not a v0
528
+ concern.) The `json` entry is a **version floor on a default gem, not a
529
+ new dependency** — but it must not be removed: `JSON.parse(..., freeze:
530
+ true)` is what makes the published document deep-frozen, and json below
531
+ 2.4 **silently ignores** `freeze:` rather than erroring. On an older json
532
+ the snapshot would quietly become mutable and §6's frozen-snapshot
533
+ invariant — the whole reason readers can dig into the live document
534
+ without a lock — would be false with no symptom until something mutated
535
+ it.
512
536
  - Publishing via **RubyGems Trusted Publishing** (OIDC) from
513
537
  `.github/workflows/release.yml` — the PyPI pattern; no API key in the
514
538
  repo. First publish uses RubyGems' pending-publisher flow since the gem
@@ -11,13 +11,23 @@ module CruFlags
11
11
  VALID_SCHEMES = %w[http https].freeze
12
12
  MODES = %w[background on-demand].freeze
13
13
 
14
+ # The one place CRU_FLAGS_URL is read and normalized (design doc §3:
15
+ # `url: nil` means "read CRU_FLAGS_URL on first use"). It lives on the
16
+ # Client, not on the module singleton, so a directly-constructed Client
17
+ # honors the documented contract instead of being silently inert; the
18
+ # module singleton gets the same normalization by construction.
19
+ def self.url_from_env
20
+ value = ENV[ENV_VAR].to_s.strip
21
+ value.empty? ? nil : value
22
+ end
23
+
14
24
  def initialize(url: nil, poll_seconds: 30.0, fetch_timeout: 2.0,
15
25
  on_error: nil, refresh_mode: nil)
16
26
  @poll_seconds = positive(poll_seconds, 30.0)
17
27
  @fetch_timeout = positive(fetch_timeout, 2.0)
18
28
  @on_error = on_error || default_on_error
19
29
  @refresh_mode = resolve_mode(refresh_mode)
20
- @url = url
30
+ @url = url || self.class.url_from_env
21
31
  @document = nil
22
32
  @etag = nil
23
33
  @healthy = true
@@ -294,7 +304,9 @@ module CruFlags
294
304
  return if @url.nil?
295
305
  uri = URI(@url.to_s)
296
306
  invalid_scheme = !VALID_SCHEMES.include?(uri.scheme)
297
- no_host = uri.host.to_s.empty?
307
+ # hostname, matching the fetcher: the unbracketed form is what actually
308
+ # gets resolved, so it is what "has a host" must be judged on.
309
+ no_host = uri.hostname.to_s.empty?
298
310
  if invalid_scheme || no_host
299
311
  reason = invalid_scheme ? "scheme #{uri.scheme.inspect} is not http(s)" : "has no host"
300
312
  @url = nil
@@ -9,6 +9,12 @@ module CruFlags
9
9
  module Fetcher
10
10
  Outcome = Struct.new(:kind, :document, :etag, :error)
11
11
 
12
+ # The parts of a Net::HTTPResponse this module uses, captured while the
13
+ # connection is still open. The body is read (and size-capped) inside the
14
+ # request block rather than buffered by Net::HTTP, so nothing here holds
15
+ # a live socket.
16
+ Response = Struct.new(:code, :body, :etag, :location)
17
+
12
18
  REDIRECT_LIMIT = 3
13
19
  REDIRECT_CODES = %w[301 302 303 307 308].freeze
14
20
  VALID_SCHEMES = %w[http https].freeze
@@ -21,14 +27,10 @@ module CruFlags
21
27
  response = get_following_redirects(URI(url), etag:, timeout:)
22
28
  case response.code
23
29
  when "200"
24
- body = response.body.to_s
25
- if body.bytesize > MAX_BODY_BYTES
26
- failed(FetchError.new("flag fetch body exceeds #{MAX_BODY_BYTES} bytes (#{body.bytesize})",
27
- code: :parse))
28
- else
29
- document = Document.parse(body)
30
- Outcome.new(kind: :document, document:, etag: response["etag"])
31
- end
30
+ # The MAX_BODY_BYTES cap already tripped during the read if it was
31
+ # going to (see read_capped_body); reaching here means the body fits.
32
+ document = Document.parse(response.body.to_s)
33
+ Outcome.new(kind: :document, document:, etag: response.etag)
32
34
  when "304" then Outcome.new(kind: :not_modified)
33
35
  when "404" then Outcome.new(kind: :missing)
34
36
  else
@@ -61,7 +63,7 @@ module CruFlags
61
63
  raise FetchError.new("flag fetch exceeded #{REDIRECT_LIMIT} redirects",
62
64
  code: :http, status: Integer(response.code))
63
65
  end
64
- uri = uri.merge(response["location"].to_s)
66
+ uri = uri.merge(response.location.to_s)
65
67
  validate_redirect_uri!(uri)
66
68
  end
67
69
  end
@@ -71,8 +73,8 @@ module CruFlags
71
73
  # which would otherwise raise something less legible than a plain :failed
72
74
  # :network outcome.
73
75
  def validate_redirect_uri!(uri)
74
- return if VALID_SCHEMES.include?(uri.scheme) && !uri.host.to_s.empty?
75
- raise FetchError.new("flag fetch redirected to an invalid URI (scheme #{uri.scheme.inspect}, host #{uri.host.inspect})",
76
+ return if VALID_SCHEMES.include?(uri.scheme) && !uri.hostname.to_s.empty?
77
+ raise FetchError.new("flag fetch redirected to an invalid URI (scheme #{uri.scheme.inspect}, host #{uri.hostname.inspect})",
76
78
  code: :network)
77
79
  end
78
80
 
@@ -80,13 +82,48 @@ module CruFlags
80
82
  headers = {"Accept" => "application/json",
81
83
  "User-Agent" => "cru-flags-ruby/#{VERSION}"}
82
84
  headers["If-None-Match"] = etag if etag
83
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
85
+ # hostname, not host: URI#host keeps the brackets an IPv6 literal must
86
+ # carry inside a URL ("[::1]"), which getaddrinfo cannot resolve.
87
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https",
84
88
  open_timeout: timeout, read_timeout: timeout, write_timeout: timeout) do |http|
85
- http.get(uri.request_uri, headers)
89
+ # Design doc §8: no retries within a tick — the next tick is the
90
+ # retry. Net::HTTP's default max_retries of 1 silently re-issues a
91
+ # failed idempotent GET, doubling both the blocking bound the client
92
+ # promises and the load a struggling flag service sees.
93
+ http.max_retries = 0
94
+ http.request(Net::HTTP::Get.new(uri.request_uri, headers)) do |response|
95
+ return Response.new(code: response.code, etag: response["etag"],
96
+ location: response["location"], body: read_capped_body(response))
97
+ end
98
+ end
99
+ end
100
+
101
+ # Design doc §8's 1 MiB cap, enforced WHILE the body streams in rather
102
+ # than after Net::HTTP has already buffered it: reading stops the moment
103
+ # the cap is passed, so an oversized (or endless) body never reaches the
104
+ # heap whole. A 304 — and any other bodyless response — yields nothing
105
+ # and comes back as "".
106
+ #
107
+ # Only a 200 turns the overrun into the tick's failure: that body IS the
108
+ # document, and a truncated document must never be parsed. Every other
109
+ # status uses the body as at most a BODY_EXCERPT-sized error excerpt, or
110
+ # ignores it entirely, so overrunning there just ends the read rather
111
+ # than relabeling a legible HTTP outcome as a size error.
112
+ def read_capped_body(response)
113
+ body = +""
114
+ response.read_body do |chunk|
115
+ body << chunk
116
+ next if body.bytesize <= MAX_BODY_BYTES
117
+ if response.code == "200"
118
+ raise FetchError.new("flag fetch body exceeds #{MAX_BODY_BYTES} bytes", code: :parse)
119
+ end
120
+ break
86
121
  end
122
+ body
87
123
  end
88
124
 
89
125
  def failed(error) = Outcome.new(kind: :failed, error:)
90
- private_class_method :get_following_redirects, :validate_redirect_uri!, :get, :failed
126
+ private_class_method :get_following_redirects, :validate_redirect_uri!, :get,
127
+ :read_capped_body, :failed
91
128
  end
92
129
  end
@@ -22,7 +22,10 @@ module CruFlags
22
22
  # (:warn in development, false elsewhere) — BEFORE any gem initializer —
23
23
  # so "did the app set it" cannot be read off the config value alone. We
24
24
  # quiet strict only when the env var is unset AND the value still equals
25
- # Flipper's own computed default, i.e. only when nobody chose it.
25
+ # Flipper's own computed default. An app that explicitly sets the value
26
+ # its environment already defaults to is therefore indistinguishable
27
+ # from an app that set nothing, and gets quieted; the value is the only
28
+ # signal available without hooking the assignment (design doc §5.1).
26
29
  def self.quiet_strict?(current:, env_var:, rails_env:)
27
30
  return false unless env_var.nil?
28
31
  flipper_default = (rails_env == "development") ? :warn : false
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CruFlags
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/cru_flags.rb CHANGED
@@ -17,8 +17,11 @@ module CruFlags
17
17
  @flipper_adapter = nil
18
18
 
19
19
  class << self
20
+ # The env read happens inside Client (Client.url_from_env), so the
21
+ # singleton and a hand-rolled `Client.new` share one normalization
22
+ # rather than two copies that can drift.
20
23
  def client
21
- @client || @client_mutex.synchronize { @client ||= Client.new(url: url_from_env) }
24
+ @client || @client_mutex.synchronize { @client ||= Client.new }
22
25
  end
23
26
 
24
27
  def enabled?(name) = client.enabled?(name)
@@ -29,7 +32,11 @@ module CruFlags
29
32
 
30
33
  def refresh(force: false) = client.refresh(force:)
31
34
 
32
- def close = client.close
35
+ # Nil-safe on the ivar, like reset!: going through the memoizing reader
36
+ # would BUILD (and validate) a singleton just to close it, leaving a
37
+ # permanently-closed client memoized — and emitting the invalid-URL
38
+ # warning at shutdown for an app that never used flags at all.
39
+ def close = @client&.close
33
40
 
34
41
  # The read-only Flipper adapter (design doc §5), memoized and bound to
35
42
  # the singleton client. `flipper` is required lazily here (not at the
@@ -52,13 +59,6 @@ module CruFlags
52
59
  end
53
60
  @flipper_adapter_mutex.synchronize { @flipper_adapter = nil }
54
61
  end
55
-
56
- private
57
-
58
- def url_from_env
59
- value = ENV[ENV_VAR].to_s.strip
60
- value.empty? ? nil : value
61
- end
62
62
  end
63
63
  end
64
64
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cru-flags
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Cru