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 +7 -0
- data/LICENSE +21 -0
- data/README.md +45 -0
- data/lib/flagbearer/client.rb +166 -0
- data/lib/flagbearer/evaluate.rb +109 -0
- data/lib/flagbearer/exposures.rb +236 -0
- data/lib/flagbearer/http.rb +124 -0
- data/lib/flagbearer/log.rb +31 -0
- data/lib/flagbearer/refresh.rb +291 -0
- data/lib/flagbearer/stream.rb +172 -0
- data/lib/flagbearer/version.rb +5 -0
- data/lib/flagbearer.rb +20 -0
- metadata +52 -0
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
|
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: []
|