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 +4 -4
- data/CHANGELOG.md +7 -2
- data/docs/design.md +37 -13
- data/lib/cru_flags/client.rb +14 -2
- data/lib/cru_flags/fetcher.rb +51 -14
- data/lib/cru_flags/railtie.rb +4 -1
- data/lib/cru_flags/version.rb +1 -1
- data/lib/cru_flags.rb +9 -9
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4eab9de685d2ede692ce24cf2b1db8d939b9f316e3ede26c6d4ef4796bee6ffe
|
|
4
|
+
data.tar.gz: 2db33c91a5571087296900d9edf2ec012c397aa6bab028d731bfefde372d2c81
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
321
|
-
app-set
|
|
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(
|
|
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.
|
|
427
|
-
|
|
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)
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
follow-up, not a v0
|
|
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
|
data/lib/cru_flags/client.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/cru_flags/fetcher.rb
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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.
|
|
75
|
-
raise FetchError.new("flag fetch redirected to an invalid URI (scheme #{uri.scheme.inspect}, host #{uri.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
126
|
+
private_class_method :get_following_redirects, :validate_redirect_uri!, :get,
|
|
127
|
+
:read_capped_body, :failed
|
|
91
128
|
end
|
|
92
129
|
end
|
data/lib/cru_flags/railtie.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/cru_flags/version.rb
CHANGED
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
|
|
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
|
-
|
|
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
|
|