flagbearer 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4e2bc8382cbedca12c994ca288f3f2ba43f4a0210bfb311fe6a0382e073f8e36
4
+ data.tar.gz: 375896df1ebe6ba4bb33a5e991510672d01aa8bdba3915619a0d02df6376a655
5
+ SHA512:
6
+ metadata.gz: d020d5f29ac73a5d37a58ed04692c2afde7b2f899626a58d8b7572f2c115de446af6466bc4722984ebdc408691f9225cf1d0917a1ba0395ca9dcfb51e00af117
7
+ data.tar.gz: fd7a0f62af25ba695a687d878b7fb3f3a8947f9463356aa573cce0b93b0b7143bad8859afe17b137148a3cf4131a4bebdf4081ac64f6a3d2d6cc5d3a63d420ba
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Flagbearer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # flagbearer for Ruby
2
+
3
+ Ruby SDK for Flagbearer. Downloads an environment's ruleset and evaluates flags
4
+ locally for each user, without making a network request for every flag check.
5
+ Requires Ruby 3.2 or later and has no runtime dependencies.
6
+
7
+ ```sh
8
+ bundle add flagbearer
9
+ ```
10
+
11
+ ## Docs
12
+
13
+ Keys, syncing at startup, per-request checks, forking servers, shutdown and
14
+ exposures are covered at
15
+ [docs.flagbearer.dev/sdks/ruby](https://docs.flagbearer.dev/sdks/ruby).
16
+
17
+ ## Development
18
+
19
+ From this directory, with Ruby 3.2 or later first on `PATH`:
20
+
21
+ ```sh
22
+ bundle install
23
+ bundle exec rake test
24
+ ```
25
+
26
+ `bun run test:sdk` does the same, and finds Homebrew's `ruby@3.4` even when the
27
+ macOS system Ruby comes first on `PATH`; set `RUBY_BIN` to use another Ruby.
28
+ The tests include the shared evaluation fixtures in
29
+ [`fixtures/evaluation.json`](../fixtures/evaluation.json),
30
+ which the other SDKs also run, and fork tests that use a real `Process.fork`.
31
+ Flagbearer's integration tests run this SDK against the real backend through
32
+ [`testing/servers/ruby-server.rb`](../testing/servers/ruby-server.rb).
33
+
34
+ ## Releasing
35
+
36
+ Bump `VERSION` in `lib/flagbearer/version.rb`, merge, then build and push the
37
+ gem from this directory with a RubyGems account that owns `flagbearer` (MFA is
38
+ required):
39
+
40
+ ```sh
41
+ gem build flagbearer.gemspec
42
+ gem push flagbearer-0.1.0.gem
43
+ ```
44
+
45
+ RubyGems versions can't be reused, so bump the version for every release.
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagbearer
4
+ # Checks flags against the rules in memory. Create one per process with
5
+ # Flagbearer.init and keep it for the life of the process. Thread-safe.
6
+ class Client
7
+ def initialize(key, url: DEFAULT_URL, debug: false, app_version: nil, timings: Timings.default)
8
+ raise ArgumentError, "A nonempty server SDK key is required" unless
9
+ key.is_a?(String) && !key.empty? && !key.match?(/[[:space:]]/)
10
+
11
+ @endpoint = HTTP::Endpoint.new(url)
12
+ @key = key
13
+ @headers = { "Authorization" => "Bearer #{key}" }.freeze
14
+ @app_version = app_version
15
+ @timings = timings
16
+ @debug = DebugLog.new(debug)
17
+ @ruleset = nil
18
+ @active = nil
19
+ @closed = false
20
+ @lock = Mutex.new
21
+ start
22
+ end
23
+
24
+ # Waits for the latest rules. Raises Flagbearer::Error when the request fails
25
+ # or the timeout passes; the client keeps retrying in the background either way.
26
+ def sync(timeout: nil)
27
+ check_fork
28
+ @coordinator.sync(timeout: timeout)
29
+ end
30
+
31
+ # Checks a flag using only the rules in memory. Never raises or blocks on the
32
+ # network. +user+ is a Hash with :key and :email, as symbols or strings.
33
+ def enabled?(flag, user = {}, default: false)
34
+ check_fork
35
+ fields = user_fields(user)
36
+ ruleset = @ruleset
37
+ version = ruleset&.version
38
+ details = begin
39
+ Evaluate.evaluate(ruleset&.flags&.[](flag), fields, version, default)
40
+ rescue StandardError
41
+ Evaluate::Evaluation.new(default, "fallback", version)
42
+ end
43
+ @reporter.record(flag, fields, details)
44
+ details.value
45
+ end
46
+
47
+ # Uploads pending exposure reports. Raises Flagbearer::Error when an upload fails.
48
+ def flush
49
+ check_fork
50
+ @reporter.flush
51
+ end
52
+
53
+ # Stops streaming and background threads, then uploads pending exposures.
54
+ # Safe to call more than once. Rules in memory stay usable.
55
+ def close
56
+ @lock.synchronize do
57
+ return if @closed
58
+
59
+ @closed = true
60
+ if Process.pid != @pid
61
+ # A forked child that never restarted: its parent owns these checks.
62
+ @reporter.abandon
63
+ return
64
+ end
65
+ end
66
+ @coordinator.close
67
+ @stream.close
68
+ abort(@active)
69
+ begin
70
+ @reporter.close
71
+ rescue StandardError
72
+ nil # shutdown continues if the last upload fails
73
+ end
74
+ nil
75
+ end
76
+
77
+ # Restarts background work in a forked child. The client also does this on
78
+ # its own the first time it's used after a fork; call it from a hook like
79
+ # Puma's on_worker_boot to start streaming before the first request.
80
+ def postfork
81
+ @lock.synchronize do
82
+ return if @closed || Process.pid == @pid
83
+
84
+ @debug.call("Restarting after fork")
85
+ # The parent's threads don't exist here, and its sockets are still the
86
+ # parent's, so its objects are dropped rather than closed. The child
87
+ # keeps the rules and starts with no pending checks: the parent reports
88
+ # its own.
89
+ @active = nil
90
+ start
91
+ end
92
+ nil
93
+ end
94
+
95
+ private
96
+
97
+ def start
98
+ @pid = Process.pid
99
+ connected = false
100
+ @reporter = ExposureReporter.new(
101
+ endpoint: @endpoint, key: @key, app_version: @app_version, timings: @timings
102
+ )
103
+ stream = nil
104
+ coordinator = Coordinator.new(
105
+ fetch: -> { fetch },
106
+ apply: ->(ruleset) { @ruleset = ruleset },
107
+ on_permanent_failure: -> { stream&.close },
108
+ timings: @timings, debug: @debug, version: @ruleset&.version
109
+ )
110
+ stream = VersionStream.new(
111
+ endpoint: @endpoint, headers: @headers, timings: @timings, debug: @debug,
112
+ on_version: ->(version) { coordinator.on_version(version) },
113
+ on_open: lambda {
114
+ # After a reconnect, refetch in case an announcement was missed.
115
+ coordinator.streaming(true, refresh: connected)
116
+ connected = true
117
+ },
118
+ on_disconnect: -> { coordinator.streaming(false) }
119
+ )
120
+ @coordinator = coordinator
121
+ @stream = stream
122
+ coordinator.refresh_in_background
123
+ end
124
+
125
+ def check_fork
126
+ postfork if Process.pid != @pid
127
+ end
128
+
129
+ def user_fields(user)
130
+ return { key: nil, email: nil } if user.nil?
131
+ # Anything else gets the default, and its check is counted as lost.
132
+ return { key: user, email: nil } unless user.is_a?(Hash)
133
+
134
+ { key: user.fetch(:key) { user["key"] }, email: user.fetch(:email) { user["email"] } }
135
+ end
136
+
137
+ def abort(http)
138
+ http&.finish if http&.started?
139
+ rescue IOError, SystemCallError
140
+ nil
141
+ end
142
+
143
+ def fetch
144
+ current = @ruleset
145
+ headers = @headers.dup
146
+ headers["If-None-Match"] = %("#{current.version}") if current
147
+ request = Net::HTTP::Get.new(@endpoint.path("/v1/ruleset"), headers)
148
+ response = begin
149
+ HTTP.request(@endpoint, request, timeout: @timings.request_timeout,
150
+ on_connection: ->(http) { @active = http })
151
+ ensure
152
+ @active = nil
153
+ end
154
+ return nil if response.code.to_i == 304
155
+
156
+ HTTP.check_status(response)
157
+ ruleset = Evaluate.parse_ruleset(response.body.to_s)
158
+ raise Error.new("Invalid ruleset response") unless ruleset
159
+ if current && ruleset.environment_id != current.environment_id
160
+ raise Error.new("Ruleset environment changed unexpectedly")
161
+ end
162
+
163
+ ruleset
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Flagbearer
7
+ # Pure evaluation: the same rules every Flagbearer SDK follows.
8
+ module Evaluate
9
+ MAX_SAFE_INTEGER = (2**53) - 1
10
+ # Like JavaScript's trim(): Unicode whitespace and the BOM.
11
+ TRIM = /\A[[:space:]\uFEFF]+|[[:space:]\uFEFF]+\z/
12
+
13
+ FlagRule = Struct.new(:key, :enabled, :rollout_bps, :override_emails)
14
+ Ruleset = Struct.new(:environment_id, :version, :flags)
15
+ Evaluation = Struct.new(:value, :reason, :version)
16
+
17
+ module_function
18
+
19
+ # A user's place in a flag's rollout, 0-9999. Below rollout_bps means on.
20
+ def bucket(flag_key, user_key)
21
+ Digest::SHA256.hexdigest(utf8(flag_key) + ":".b + utf8(user_key)).to_i(16) % 10_000
22
+ end
23
+
24
+ # The UTF-8 bytes of a string. Binary strings, like HTTP bodies, are taken
25
+ # to be UTF-8 already; other encodings are converted.
26
+ def utf8(text)
27
+ text.encoding == Encoding::BINARY ? text.b : text.encode(Encoding::UTF_8).b
28
+ end
29
+
30
+ def text(body) = utf8(body.to_s).force_encoding(Encoding::UTF_8)
31
+
32
+ def normalize_email(email)
33
+ text(email).scrub("\uFFFD").gsub(TRIM, "").downcase
34
+ end
35
+
36
+ def evaluate(flag, user, version, default)
37
+ return Evaluation.new(default, "fallback", version) if flag.nil?
38
+ return Evaluation.new(false, "off", version) unless flag.enabled
39
+
40
+ email = user[:email]
41
+ raise TypeError, "email must be a String" unless email.nil? || email.is_a?(String)
42
+
43
+ email = email.nil? ? "" : normalize_email(email)
44
+ if !email.empty? && flag.override_emails.any? { |item| normalize_email(item) == email }
45
+ return Evaluation.new(true, "tester", version)
46
+ end
47
+
48
+ key = user[:key]
49
+ raise TypeError, "key must be a String" unless key.nil? || key.is_a?(String)
50
+
51
+ if key.nil? || key.empty?
52
+ return Evaluation.new(default, "fallback", version) unless [0, 10_000].include?(flag.rollout_bps)
53
+
54
+ return Evaluation.new(flag.rollout_bps == 10_000, "rollout", version)
55
+ end
56
+
57
+ Evaluation.new(bucket(flag.key, key) < flag.rollout_bps, "rollout", version)
58
+ end
59
+
60
+ # JSON numbers are doubles, so 42.0 is the same version as 42.
61
+ def integer(value)
62
+ return value if value.is_a?(Integer)
63
+ return value.to_i if value.is_a?(Float) && value.finite? && value == value.floor
64
+
65
+ nil
66
+ end
67
+
68
+ def version(value)
69
+ number = integer(value)
70
+ number if number && number >= 0 && number <= MAX_SAFE_INTEGER
71
+ end
72
+
73
+ # The ruleset in a GET /v1/ruleset body, or nil if any of it is invalid.
74
+ def parse_ruleset(body)
75
+ value = JSON.parse(text(body))
76
+ return nil unless value.is_a?(Hash)
77
+
78
+ environment_id = value["environmentId"]
79
+ number = version(value["version"])
80
+ flags = value["flags"]
81
+ return nil unless environment_id.is_a?(String) && number && flags.is_a?(Array)
82
+
83
+ rules = {}
84
+ flags.each do |item|
85
+ rule = parse_rule(item)
86
+ return nil unless rule
87
+
88
+ rules[rule.key] = rule
89
+ end
90
+ Ruleset.new(environment_id, number, rules.freeze).freeze
91
+ rescue JSON::ParserError
92
+ nil
93
+ end
94
+
95
+ def parse_rule(item)
96
+ return nil unless item.is_a?(Hash)
97
+
98
+ key = item["key"]
99
+ enabled = item["enabled"]
100
+ rollout = integer(item["rolloutBps"])
101
+ emails = item["overrideEmails"]
102
+ return nil unless key.is_a?(String) && [true, false].include?(enabled)
103
+ return nil unless rollout && rollout.between?(0, 10_000)
104
+ return nil unless emails.is_a?(Array) && emails.all?(String)
105
+
106
+ FlagRule.new(key, enabled, rollout, emails.freeze).freeze
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Flagbearer
7
+ # Counts flag checks and uploads them in the background.
8
+ class ExposureReporter
9
+ WINDOW_MS = 30_000
10
+ MAX_RECORDS = 1000
11
+ BATCH_SIZE = 100
12
+ MAX_CHECK_COUNT = 1_000_000_000
13
+
14
+ # One upload, fixed when sealed, so a retry sends exactly the same body and
15
+ # the server can recognize it by its batch ID.
16
+ Batch = Struct.new(:id, :user, :events)
17
+
18
+ def initialize(endpoint:, key:, app_version:, timings:)
19
+ @endpoint = endpoint
20
+ @key = key
21
+ @app_version = app_version && !app_version.empty? ? app_version[0, 100] : nil
22
+ @timeout = timings.exposure_timeout
23
+ @reporter_id = SecureRandom.uuid
24
+ @lock = Mutex.new
25
+ @send_lock = Mutex.new # one upload at a time; flush waits for the one in progress
26
+ @wake = ConditionVariable.new
27
+ @active = {}
28
+ @sealed = []
29
+ @sealed_events = 0
30
+ @dropped = 0
31
+ @reported_dropped = 0
32
+ @retry_at = 0.0
33
+ @failures = 0
34
+ @closed = false
35
+ @thread = Thread.new { run(timings.exposure_flush_interval) }
36
+ @thread.name = "flagbearer-exposures"
37
+ @thread.report_on_exception = false
38
+ end
39
+
40
+ def record(flag, user, details)
41
+ @lock.synchronize do
42
+ return if @closed
43
+
44
+ key = user[:key]
45
+ if !flag.is_a?(String) || flag.empty? || HTTP.js_length(flag) > 256 ||
46
+ !(key.nil? || key.is_a?(String)) || (key && HTTP.js_length(key) > 256)
47
+ @dropped += 1
48
+ return
49
+ end
50
+ now = (Time.now.to_r * 1000).floor
51
+ bucket = iso(now / WINDOW_MS * WINDOW_MS)
52
+ raw_email = user[:email]
53
+ email = if raw_email.is_a?(String) && !raw_email.empty? && HTTP.js_length(raw_email) <= 254
54
+ normalized = Evaluate.normalize_email(raw_email)
55
+ normalized.empty? ? nil : normalized
56
+ end
57
+ user_key = key.nil? || key.empty? ? nil : key
58
+ identity = [flag, user_key, email, details.value, details.reason, details.version, bucket]
59
+ existing = @active[identity]
60
+ if existing
61
+ if existing[:event]["checkCount"] < MAX_CHECK_COUNT
62
+ existing[:event]["checkCount"] += 1
63
+ existing[:event]["lastCheckedAt"] = iso(now)
64
+ else
65
+ @dropped += 1
66
+ end
67
+ return
68
+ end
69
+ if @active.size + @sealed_events >= MAX_RECORDS
70
+ @dropped += 1
71
+ return
72
+ end
73
+ who = {}
74
+ who["key"] = user_key if user_key
75
+ who["email"] = email if email
76
+ @active[identity] = {
77
+ user: who,
78
+ event: {
79
+ "flagKey" => flag, "result" => details.value, "reason" => details.reason,
80
+ "configurationVersion" => details.version, "bucket" => bucket, "checkCount" => 1,
81
+ "firstCheckedAt" => iso(now), "lastCheckedAt" => iso(now)
82
+ }
83
+ }
84
+ end
85
+ rescue StandardError
86
+ @lock.synchronize { @dropped += 1 }
87
+ end
88
+
89
+ # Uploads every pending check. Raises when an upload fails.
90
+ def flush
91
+ @send_lock.synchronize do
92
+ send_all
93
+ rescue StandardError
94
+ @lock.synchronize do
95
+ @failures += 1
96
+ delay = [60.0, 2.0**[@failures, 6].min].min
97
+ @retry_at = [@retry_at, monotonic + delay].max
98
+ end
99
+ raise
100
+ end
101
+ end
102
+
103
+ def close
104
+ @lock.synchronize do
105
+ return if @closed
106
+
107
+ @closed = true
108
+ @wake.broadcast
109
+ end
110
+ flush
111
+ end
112
+
113
+ # Stops the timer without uploading: for a forked child, whose parent still
114
+ # owns these checks.
115
+ def abandon
116
+ @lock.synchronize do
117
+ @closed = true
118
+ @wake.broadcast
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
125
+
126
+ def iso(milliseconds)
127
+ Time.at(Rational(milliseconds, 1000)).utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
128
+ end
129
+
130
+ def run(interval)
131
+ loop do
132
+ @lock.synchronize do
133
+ return if @closed
134
+
135
+ @wake.wait(@lock, HTTP.jitter(interval, 0.1))
136
+ return if @closed
137
+ end
138
+ due = @lock.synchronize { monotonic >= @retry_at }
139
+ next unless due
140
+
141
+ begin
142
+ flush
143
+ rescue StandardError
144
+ nil # background uploads retry later
145
+ end
146
+ end
147
+ end
148
+
149
+ # Expects @lock held. A batch never mixes users.
150
+ def seal
151
+ open = {}
152
+ @active.each_value do |item|
153
+ batch = open[item[:user]]
154
+ if batch.nil? || batch.events.size >= BATCH_SIZE
155
+ batch = Batch.new(SecureRandom.uuid, item[:user], [])
156
+ open[item[:user]] = batch
157
+ @sealed << batch
158
+ end
159
+ batch.events << item[:event]
160
+ @sealed_events += 1
161
+ end
162
+ @active = {}
163
+ end
164
+
165
+ def settle(batch)
166
+ @lock.synchronize do
167
+ if @sealed.first.equal?(batch)
168
+ @sealed.shift
169
+ @sealed_events -= batch.events.size
170
+ end
171
+ end
172
+ end
173
+
174
+ def send_all
175
+ @lock.synchronize { seal }
176
+ loop do
177
+ batch, dropped = @lock.synchronize do
178
+ next nil if @sealed.empty? && @dropped <= @reported_dropped
179
+
180
+ # Only lost checks to report: an empty batch carries the count.
181
+ [@sealed.first || Batch.new(SecureRandom.uuid, {}, []), @dropped]
182
+ end
183
+ break if batch.nil?
184
+
185
+ checks = batch.events.sum { |event| event["checkCount"] }
186
+ body = {
187
+ "batchId" => batch.id, "reporterId" => @reporter_id, "user" => batch.user,
188
+ "events" => batch.events, "droppedChecks" => dropped,
189
+ "sdk" => "ruby", "sdkVersion" => VERSION
190
+ }
191
+ body["appVersion"] = @app_version if @app_version
192
+ request = Net::HTTP::Post.new(@endpoint.path("/v1/exposures"), {
193
+ "Authorization" => "Bearer #{@key}", "Content-Type" => "application/json"
194
+ })
195
+ request.body = JSON.generate(body)
196
+ response = HTTP.request(@endpoint, request, timeout: @timeout)
197
+ status = response.code.to_i
198
+ unless status.between?(200, 299)
199
+ refused = status.between?(400, 499) && status != 429
200
+ @lock.synchronize do
201
+ wait = HTTP.retry_after(response["Retry-After"])
202
+ @retry_at = [@retry_at, monotonic + [wait, 3600].min].max if wait.positive?
203
+ if refused
204
+ # The server won't take this batch, so retrying can't help.
205
+ @dropped += checks
206
+ @retry_at = monotonic + 60
207
+ end
208
+ end
209
+ settle(batch) if refused
210
+ raise Error.new("Exposure upload failed (#{status})", retryable: !refused)
211
+ end
212
+ rejected = receipt(response.body, checks, batch.events.size)
213
+ @lock.synchronize do
214
+ @dropped += rejected
215
+ @reported_dropped = [@reported_dropped, dropped].max
216
+ end
217
+ settle(batch)
218
+ end
219
+ @lock.synchronize { @failures = 0 }
220
+ end
221
+
222
+ def receipt(body, checks, events)
223
+ parsed = JSON.parse(Evaluate.text(body))
224
+ accepted = parsed.is_a?(Hash) ? parsed["accepted"] : nil
225
+ rejected = parsed.is_a?(Hash) ? parsed["rejectedChecks"] : nil
226
+ if accepted.is_a?(Integer) && accepted.between?(0, events) &&
227
+ rejected.is_a?(Integer) && rejected.between?(0, checks)
228
+ return rejected
229
+ end
230
+
231
+ raise Error.new("Invalid exposure upload receipt", retryable: true)
232
+ rescue JSON::ParserError
233
+ raise Error.new("Invalid exposure upload receipt", retryable: true)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Flagbearer
9
+ # A failed request or an unusable response. Retryable failures back off and
10
+ # try again; permanent ones stop automatic refreshes until sync is called.
11
+ class Error < StandardError
12
+ attr_reader :retry_after
13
+
14
+ def initialize(message, retryable: false, retry_after: 0.0)
15
+ super(message)
16
+ @retryable = retryable
17
+ @retry_after = retry_after
18
+ end
19
+
20
+ def retryable? = @retryable
21
+ def permanent? = !@retryable
22
+ end
23
+
24
+ # Raised by sync after close.
25
+ class ClosedError < StandardError
26
+ def initialize(message = "Flagbearer client is closed") = super
27
+ end
28
+
29
+ # Network plumbing shared by the ruleset, stream and exposure requests.
30
+ module HTTP
31
+ MAX_RETRY = 30.0
32
+
33
+ module_function
34
+
35
+ # Seconds to wait from a Retry-After header, given in seconds or as an HTTP date.
36
+ def retry_after(header, now = Time.now)
37
+ return 0.0 if header.nil? || header.empty?
38
+
39
+ seconds = Float(header, exception: false)
40
+ return seconds.finite? && seconds.positive? ? seconds : 0.0 if seconds
41
+
42
+ [Time.httpdate(header) - now, 0.0].max
43
+ rescue ArgumentError
44
+ 0.0
45
+ end
46
+
47
+ def check_status(response)
48
+ status = response.code.to_i
49
+ return if status.between?(200, 299)
50
+
51
+ # A 3xx lands here too: Net::HTTP never follows redirects.
52
+ raise Error.new(
53
+ "Flagbearer request failed: #{status}",
54
+ retryable: [408, 429].include?(status) || status >= 500,
55
+ retry_after: retry_after(response["Retry-After"])
56
+ )
57
+ end
58
+
59
+ def backoff(attempt, base: 1.0, cap: MAX_RETRY)
60
+ delay = [cap, base * (2**[attempt, 5].min)].min
61
+ [cap, delay * (0.8 + (rand * 0.4))].min
62
+ end
63
+
64
+ def jitter(interval, spread)
65
+ interval * (1 - spread + (rand * 2 * spread))
66
+ end
67
+
68
+ # UTF-16 code units, as JavaScript and the server's limits count them.
69
+ def js_length(text)
70
+ Evaluate.text(text).encode("UTF-16LE").bytesize / 2
71
+ end
72
+
73
+ # One SDK server address.
74
+ class Endpoint
75
+ attr_reader :uri
76
+
77
+ def initialize(url)
78
+ @uri = URI.parse(url.to_s)
79
+ raise ArgumentError, "Flagbearer url must use HTTP or HTTPS" unless
80
+ %w[http https].include?(@uri.scheme) && @uri.host && !@uri.host.empty?
81
+
82
+ @prefix = @uri.path.to_s.chomp("/")
83
+ rescue URI::InvalidURIError
84
+ raise ArgumentError, "Flagbearer url must use HTTP or HTTPS"
85
+ end
86
+
87
+ def path(route) = "#{@prefix}#{route}"
88
+
89
+ def connection(open_timeout:, read_timeout:)
90
+ http = Net::HTTP.new(@uri.host, @uri.port)
91
+ http.use_ssl = @uri.scheme == "https"
92
+ http.open_timeout = open_timeout
93
+ http.read_timeout = read_timeout
94
+ http.write_timeout = read_timeout
95
+ http.keep_alive_timeout = 0
96
+ http
97
+ end
98
+ end
99
+
100
+ NETWORK_ERRORS = [
101
+ IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
102
+ Net::HTTPBadResponse, Net::ProtocolError, EOFError
103
+ ].freeze
104
+
105
+ # Sends one request and reads the whole response. Network errors are retryable.
106
+ # on_connection receives the started connection, so close can abort it.
107
+ def request(endpoint, request, timeout:, on_connection: nil)
108
+ http = endpoint.connection(open_timeout: timeout, read_timeout: timeout)
109
+ http.start
110
+ on_connection&.call(http)
111
+ response = http.request(request)
112
+ response.body
113
+ response
114
+ rescue *NETWORK_ERRORS => e
115
+ raise Error.new("Flagbearer request failed: #{e.message}", retryable: true)
116
+ ensure
117
+ begin
118
+ http&.finish if http&.started?
119
+ rescue IOError
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagbearer
4
+ # Diagnostics for debug: true. Never logs keys, user data, flag names or rules.
5
+ class DebugLog
6
+ def initialize(enabled, io: $stderr)
7
+ @enabled = enabled
8
+ @io = io
9
+ end
10
+
11
+ def call(message, **details)
12
+ return unless @enabled
13
+
14
+ if details.empty?
15
+ @io.puts("[Flagbearer] #{message}")
16
+ else
17
+ # Same shape as the Node SDK's console.debug output: { version: 42 }.
18
+ fields = details.map { |name, value| "#{name}: #{value.nil? ? 'undefined' : value}" }
19
+ @io.puts("[Flagbearer] #{message} { #{fields.join(', ')} }")
20
+ end
21
+ rescue StandardError
22
+ nil
23
+ end
24
+ end
25
+
26
+ def self.warn_log(message)
27
+ $stderr.puts("Flagbearer: #{message}")
28
+ rescue StandardError
29
+ nil
30
+ end
31
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagbearer
4
+ # Every delay the client uses, in seconds. Tests shorten them.
5
+ Timings = Struct.new(
6
+ :request_timeout, :poll_interval, :retry_base, :max_retry,
7
+ :max_version_refresh_delay, :stream_connect_timeout, :stream_idle_timeout,
8
+ :exposure_flush_interval, :exposure_timeout,
9
+ keyword_init: true
10
+ ) do
11
+ def self.default
12
+ new(
13
+ request_timeout: 5.0, poll_interval: 30.0, retry_base: 1.0, max_retry: 30.0,
14
+ max_version_refresh_delay: 2.0, stream_connect_timeout: 5.0, stream_idle_timeout: 45.0,
15
+ exposure_flush_interval: 30.0, exposure_timeout: 10.0
16
+ )
17
+ end
18
+ end
19
+
20
+ # Decides when to fetch the ruleset. One worker thread makes every fetch, so
21
+ # there is never more than one in flight.
22
+ class Coordinator
23
+ def initialize(fetch:, apply:, on_permanent_failure:, timings:, debug:, version: nil)
24
+ @fetch = fetch
25
+ @apply = apply
26
+ @on_permanent_failure = on_permanent_failure
27
+ @timings = timings
28
+ @debug = debug
29
+ @lock = Mutex.new
30
+ @wake = ConditionVariable.new
31
+
32
+ @stopped = false
33
+ @blocked = false
34
+ @streaming = false
35
+ @version = version
36
+ @wanted = nil
37
+ @attempt = 0
38
+ @not_before = 0.0 # nothing is fetched before this: backoff or a server's Retry-After
39
+ @retry_at = nil
40
+ @poll_at = nil
41
+ @version_at = nil
42
+ @requested = false
43
+ @in_flight = false
44
+ @waiters = []
45
+
46
+ @lock.synchronize { ensure_polling }
47
+ @thread = Thread.new { run }
48
+ @thread.name = "flagbearer-refresh"
49
+ @thread.report_on_exception = false
50
+ end
51
+
52
+ # Waits for a fetch. Joins one already in flight.
53
+ def sync(timeout: nil)
54
+ waiter = Thread::Queue.new
55
+ @lock.synchronize do
56
+ raise ClosedError if @stopped
57
+
58
+ @blocked = false
59
+ ensure_polling
60
+ @waiters << waiter
61
+ if @in_flight
62
+ @debug.call("Joining active refresh")
63
+ else
64
+ @retry_at = nil
65
+ @requested = true
66
+ end
67
+ @wake.broadcast
68
+ end
69
+ result = waiter.pop(timeout: timeout)
70
+ raise Error.new("Flagbearer sync timed out", retryable: true) if result.nil?
71
+ raise result unless result == :ok
72
+
73
+ nil
74
+ end
75
+
76
+ def refresh_in_background
77
+ @lock.synchronize { background }
78
+ end
79
+
80
+ def on_version(version)
81
+ @lock.synchronize do
82
+ next if @stopped
83
+
84
+ @debug.call("Stream version received", version: version, currentVersion: @version)
85
+ @wanted = [@wanted || 0, version].max
86
+ if behind? && @version_at.nil? && !@in_flight
87
+ @version_at = now + (rand * @timings.max_version_refresh_delay)
88
+ @wake.broadcast
89
+ end
90
+ end
91
+ end
92
+
93
+ def streaming(connected, refresh: true)
94
+ @lock.synchronize do
95
+ next if @stopped
96
+
97
+ if @streaming != connected
98
+ @debug.call(connected ? "Stream connected" : "Stream disconnected; using polling")
99
+ end
100
+ @streaming = connected
101
+ if connected
102
+ clear_polling
103
+ background if refresh
104
+ else
105
+ ensure_polling
106
+ end
107
+ @wake.broadcast
108
+ end
109
+ end
110
+
111
+ def close
112
+ waiters = @lock.synchronize do
113
+ return if @stopped
114
+
115
+ @stopped = true
116
+ @debug.call("Coordinator closed")
117
+ @wake.broadcast
118
+ taken = @waiters
119
+ @waiters = []
120
+ taken
121
+ end
122
+ waiters.each { |waiter| waiter << ClosedError.new }
123
+ end
124
+
125
+ private
126
+
127
+ # The helpers below expect @lock to be held.
128
+
129
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
130
+
131
+ def behind? = !@wanted.nil? && (@version.nil? || @version < @wanted)
132
+
133
+ def background
134
+ return if @stopped || @blocked || @retry_at || @in_flight
135
+
136
+ @requested = true
137
+ @wake.broadcast
138
+ end
139
+
140
+ def ensure_polling
141
+ return if @stopped || @blocked || @streaming || @poll_at
142
+
143
+ interval = HTTP.jitter(@timings.poll_interval, 0.1)
144
+ @debug.call("Polling scheduled", intervalMs: (interval * 1000).round)
145
+ @poll_at = now + interval
146
+ end
147
+
148
+ def clear_polling
149
+ @debug.call("Polling disabled") if @poll_at
150
+ @poll_at = nil
151
+ end
152
+
153
+ def schedule_retry(error)
154
+ delay = HTTP.backoff(@attempt, base: @timings.retry_base, cap: @timings.max_retry)
155
+ @attempt += 1
156
+ delay = [delay, error.retry_after].max if error.is_a?(Error)
157
+ @not_before = now + delay
158
+ @retry_at = @not_before
159
+ @debug.call("Refresh retry scheduled", delayMs: (delay * 1000).round, attempt: @attempt)
160
+ end
161
+
162
+ # Seconds until the worker has something to do, or nil if never.
163
+ def next_due(at)
164
+ return [@not_before - at, 0].max if @requested
165
+
166
+ times = [@retry_at, @poll_at, @version_at].compact
167
+ times.empty? ? nil : [times.min - at, 0].max
168
+ end
169
+
170
+ # Turns any timer that has fired into a fetch request.
171
+ def take_due(at)
172
+ if @version_at && @version_at <= at
173
+ @version_at = nil
174
+ background if behind?
175
+ end
176
+ if @retry_at && @retry_at <= at
177
+ @retry_at = nil
178
+ background
179
+ end
180
+ if @poll_at && @poll_at <= at
181
+ @poll_at = nil
182
+ background
183
+ ensure_polling
184
+ end
185
+ @requested && at >= @not_before
186
+ end
187
+
188
+ def run
189
+ loop do
190
+ waiters = @lock.synchronize do
191
+ until @stopped
192
+ at = now
193
+ break if take_due(at)
194
+
195
+ @wake.wait(@lock, next_due(at))
196
+ end
197
+ return if @stopped
198
+
199
+ @requested = false
200
+ @in_flight = true
201
+ @version_at = nil
202
+ taken = @waiters
203
+ @waiters = []
204
+ taken
205
+ end
206
+
207
+ error = nil
208
+ begin
209
+ refresh
210
+ rescue StandardError => e
211
+ error = e
212
+ end
213
+
214
+ stopped = @lock.synchronize do
215
+ @in_flight = false
216
+ # Sync calls that arrived during this fetch join it.
217
+ waiters.concat(@waiters)
218
+ @waiters = []
219
+ @stopped
220
+ end
221
+ waiters.each { |waiter| waiter << (error || :ok) }
222
+ Flagbearer.warn_log("sync failed: #{error.message}") if error && !stopped && waiters.empty?
223
+ end
224
+ end
225
+
226
+ # One refresh, repeated while a stream announcement is still ahead of us.
227
+ def refresh
228
+ loop do
229
+ @debug.call("Refresh started")
230
+ begin
231
+ result = @fetch.call
232
+ rescue StandardError => e
233
+ permanent = @lock.synchronize do
234
+ raise if @stopped
235
+
236
+ if e.is_a?(Error) && e.permanent?
237
+ @debug.call("Automatic refresh stopped after permanent failure")
238
+ @blocked = true
239
+ @streaming = false
240
+ @retry_at = nil
241
+ clear_polling
242
+ true
243
+ else
244
+ schedule_retry(e)
245
+ false
246
+ end
247
+ end
248
+ @on_permanent_failure.call if permanent
249
+ raise
250
+ end
251
+
252
+ again = @lock.synchronize do
253
+ next false if @stopped
254
+
255
+ if result.nil?
256
+ @debug.call("Snapshot unchanged", version: @version)
257
+ if behind?
258
+ # The backend is still behind: retry later rather than spin.
259
+ schedule_retry(nil)
260
+ else
261
+ @attempt = 0
262
+ @not_before = 0.0
263
+ end
264
+ next false
265
+ end
266
+
267
+ previous = @version
268
+ if previous.nil? || result.version >= previous
269
+ @version = result.version
270
+ @apply.call(result)
271
+ @debug.call("Snapshot applied", version: result.version, previousVersion: previous)
272
+ else
273
+ @debug.call("Older snapshot ignored", version: result.version, currentVersion: previous)
274
+ end
275
+
276
+ if !behind?
277
+ @attempt = 0
278
+ @not_before = 0.0
279
+ false
280
+ elsif previous && result.version <= previous
281
+ schedule_retry(nil)
282
+ false
283
+ else
284
+ true # An announcement arrived during the request: fetch again now.
285
+ end
286
+ end
287
+ return unless again
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Flagbearer
6
+ # Server-Sent Events, fed text in arbitrary pieces. Calls on_version for each
7
+ # well-formed version event and ignores everything else.
8
+ class EventParser
9
+ MAX_EVENT_LENGTH = 65_536
10
+
11
+ def initialize(&on_version)
12
+ @on_version = on_version
13
+ @buffer = "".b
14
+ @event = "message"
15
+ @data = +""
16
+ end
17
+
18
+ # Works on bytes: CR and LF never occur inside a multi-byte UTF-8 character,
19
+ # so a character split across reads is whole again by the time its line ends.
20
+ def feed(chunk)
21
+ @buffer << chunk.b
22
+ start = 0
23
+ while (index = @buffer.index(/[\r\n]/n, start))
24
+ # A CR at the end might be the first half of a CRLF in the next piece.
25
+ break if @buffer.getbyte(index) == 13 && index == @buffer.bytesize - 1
26
+
27
+ line(@buffer.byteslice(start, index - start).force_encoding(Encoding::UTF_8).scrub("\uFFFD"))
28
+ index += 1 if @buffer.getbyte(index) == 13 && @buffer.getbyte(index + 1) == 10
29
+ start = index + 1
30
+ end
31
+ @buffer = @buffer.byteslice(start..)
32
+ raise ArgumentError, "Flagbearer stream line too large" if @buffer.bytesize > MAX_EVENT_LENGTH
33
+ end
34
+
35
+ private
36
+
37
+ def line(text)
38
+ if text.empty?
39
+ dispatch
40
+ return
41
+ end
42
+ return if text.start_with?(":")
43
+
44
+ field, separator, content = text.partition(":")
45
+ content = "" if separator.empty?
46
+ content = content[1..] if content.start_with?(" ")
47
+ case field
48
+ when "event" then @event = content
49
+ when "data"
50
+ @data << content << "\n"
51
+ raise ArgumentError, "Flagbearer stream event too large" if @data.length > MAX_EVENT_LENGTH
52
+ end
53
+ end
54
+
55
+ def dispatch
56
+ if @event == "version" && !@data.empty?
57
+ parsed = begin
58
+ JSON.parse(@data.chomp("\n"))
59
+ rescue JSON::ParserError
60
+ nil
61
+ end
62
+ version = parsed.is_a?(Hash) ? Evaluate.version(parsed["version"]) : nil
63
+ @on_version.call(version) if version
64
+ end
65
+ @event = "message"
66
+ @data = +""
67
+ end
68
+ end
69
+
70
+ # Holds GET /v1/stream open on its own thread and reconnects with backoff.
71
+ class VersionStream
72
+ def initialize(endpoint:, headers:, timings:, debug:, on_version:, on_open:, on_disconnect:)
73
+ @endpoint = endpoint
74
+ @headers = headers.merge("Accept" => "text/event-stream")
75
+ @timings = timings
76
+ @debug = debug
77
+ @on_version = on_version
78
+ @on_open = on_open
79
+ @on_disconnect = on_disconnect
80
+ @lock = Mutex.new
81
+ @wake = ConditionVariable.new
82
+ @closed = false
83
+ @http = nil
84
+ @attempts = 0
85
+ @thread = Thread.new { run }
86
+ @thread.name = "flagbearer-stream"
87
+ @thread.report_on_exception = false
88
+ end
89
+
90
+ attr_reader :thread
91
+
92
+ def close
93
+ http = @lock.synchronize do
94
+ return if @closed
95
+
96
+ @closed = true
97
+ @wake.broadcast
98
+ @http
99
+ end
100
+ @debug.call("Stream closed")
101
+ abort(http)
102
+ end
103
+
104
+ private
105
+
106
+ def closed? = @lock.synchronize { @closed }
107
+
108
+ # Closing the socket from another thread wakes a read blocked on it.
109
+ def abort(http)
110
+ http&.finish if http&.started?
111
+ rescue IOError, SystemCallError
112
+ nil
113
+ end
114
+
115
+ def run
116
+ until closed?
117
+ failure = connect
118
+ return if closed?
119
+
120
+ @on_disconnect.call
121
+ if failure.is_a?(Error) && failure.permanent?
122
+ # Polling remains available; the next ruleset request also checks the key.
123
+ Flagbearer.warn_log("stream stopped; using polling: #{failure.message}")
124
+ return
125
+ end
126
+ delay = HTTP.backoff(@attempts, base: @timings.retry_base, cap: @timings.max_retry)
127
+ @attempts += 1
128
+ delay = [delay, failure.retry_after].max if failure.is_a?(Error)
129
+ @debug.call("Stream reconnect scheduled", delayMs: (delay * 1000).round)
130
+ @lock.synchronize { @wake.wait(@lock, delay) unless @closed }
131
+ end
132
+ end
133
+
134
+ def connect
135
+ @debug.call("Stream connecting")
136
+ http = @endpoint.connection(
137
+ open_timeout: @timings.stream_connect_timeout,
138
+ read_timeout: @timings.stream_connect_timeout
139
+ )
140
+ http.start
141
+ @lock.synchronize do
142
+ return nil if @closed
143
+
144
+ @http = http
145
+ end
146
+ request = Net::HTTP::Get.new(@endpoint.path("/v1/stream"), @headers)
147
+ failure = nil
148
+ http.request(request) do |response|
149
+ HTTP.check_status(response)
150
+ type = response["Content-Type"].to_s.split(";").first.to_s.strip.downcase
151
+ raise Error.new("Invalid Flagbearer stream response") unless type == "text/event-stream"
152
+ return nil if closed?
153
+
154
+ # From here the timeout is the idle limit: heartbeats arrive every 15 seconds.
155
+ http.read_timeout = @timings.stream_idle_timeout
156
+ @on_open.call
157
+
158
+ parser = EventParser.new { |version| @on_version.call(version) }
159
+ response.read_body do |chunk|
160
+ @attempts = 0
161
+ parser.feed(chunk)
162
+ end
163
+ end
164
+ failure
165
+ rescue StandardError => e
166
+ e
167
+ ensure
168
+ @lock.synchronize { @http = nil }
169
+ abort(http)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flagbearer
4
+ VERSION = "0.1.0"
5
+ end
data/lib/flagbearer.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "flagbearer/version"
4
+ require_relative "flagbearer/log"
5
+ require_relative "flagbearer/http"
6
+ require_relative "flagbearer/evaluate"
7
+ require_relative "flagbearer/refresh"
8
+ require_relative "flagbearer/stream"
9
+ require_relative "flagbearer/exposures"
10
+ require_relative "flagbearer/client"
11
+
12
+ # The Flagbearer server SDK: download an environment's rules once, check flags locally.
13
+ module Flagbearer
14
+ DEFAULT_URL = "https://sdk.flagbearer.dev"
15
+
16
+ # Starts a client: fetches the rules and opens the stream in the background.
17
+ def self.init(key, url: DEFAULT_URL, debug: false, app_version: nil)
18
+ Client.new(key, url: url, debug: debug, app_version: app_version)
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flagbearer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Flagbearer
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'Check Flagbearer feature flags locally on the server: rules stream in,
13
+ checks never wait on the network.'
14
+ executables: []
15
+ extensions: []
16
+ extra_rdoc_files: []
17
+ files:
18
+ - LICENSE
19
+ - README.md
20
+ - lib/flagbearer.rb
21
+ - lib/flagbearer/client.rb
22
+ - lib/flagbearer/evaluate.rb
23
+ - lib/flagbearer/exposures.rb
24
+ - lib/flagbearer/http.rb
25
+ - lib/flagbearer/log.rb
26
+ - lib/flagbearer/refresh.rb
27
+ - lib/flagbearer/stream.rb
28
+ - lib/flagbearer/version.rb
29
+ homepage: https://flagbearer.dev
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ documentation_uri: https://docs.flagbearer.dev/sdks/ruby
34
+ rubygems_mfa_required: 'true'
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '3.2'
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.6.9
50
+ specification_version: 4
51
+ summary: Flagbearer Ruby SDK
52
+ test_files: []