feat-sdk 0.1.1 → 0.3.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 +4 -4
- data/README.md +25 -4
- data/lib/feat/client.rb +107 -26
- data/lib/feat/datafile.rb +30 -0
- data/lib/feat/sse.rb +88 -0
- data/lib/feat/streaming.rb +264 -0
- data/lib/feat/version.rb +1 -1
- data/lib/feat.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4dfb1cd9905d46890db88dea9411a6dc3755208286fbbec6565d8742459d5008
|
|
4
|
+
data.tar.gz: 63c030396f376ff362b3d0d5b7c07a7cc926b4dd5141c9516988dbcb87a3419f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 347f444e5f8354f9b4b4d8957c812bde443a502b7c6cc54623df05983abe332cafd77f37ffebbb01d8cd8e8940d280a8004068e16403398721b2bb6bcc2ec4c6
|
|
7
|
+
data.tar.gz: e192084e64f93ff51ab4e14aa4384ec0954be13f6e4996c00371185deee30a9fe67b718d3065a07b64d2bd15dd98a4bfcf0677bb54e67a0959015ad542504fee
|
data/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
# feat Ruby SDK
|
|
10
10
|
|
|
11
|
-
Server-side Ruby SDK for [feat](https://feat.so) feature flags. Local flag evaluation against a
|
|
11
|
+
Server-side Ruby SDK for [feat](https://feat.so) feature flags. Local flag evaluation against a live-streamed datafile. Standard library only - no gem dependencies.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -30,7 +30,7 @@ require "feat"
|
|
|
30
30
|
|
|
31
31
|
client = Feat::Client.new(
|
|
32
32
|
api_key: ENV.fetch("FEAT_SERVER_KEY"),
|
|
33
|
-
|
|
33
|
+
url: "https://data-01.feat.so", # optional; this is the default
|
|
34
34
|
)
|
|
35
35
|
client.start
|
|
36
36
|
|
|
@@ -51,9 +51,30 @@ Use a **server** API key (`feat_sdk_...`).
|
|
|
51
51
|
## How it works
|
|
52
52
|
|
|
53
53
|
- Fetches a per-environment datafile and keeps it in memory.
|
|
54
|
-
-
|
|
54
|
+
- Streams updates over Server-Sent Events: a background thread holds an open
|
|
55
|
+
connection and applies each new datafile the instant it changes. Updates are
|
|
56
|
+
version-ordered (an older or equal datafile is never adopted) and guarded by a
|
|
57
|
+
mutex shared with the evaluator.
|
|
58
|
+
- Keeps a slow background poll (every 5 minutes by default) as a safety net.
|
|
59
|
+
ETag-aware via `If-None-Match`. If the stream drops, it reconnects with
|
|
60
|
+
exponential backoff while the poll keeps the datafile fresh.
|
|
55
61
|
- Evaluation runs in-process: no per-flag network call.
|
|
56
|
-
-
|
|
62
|
+
- `close` stops the stream and poll threads cleanly.
|
|
63
|
+
|
|
64
|
+
### Streaming options
|
|
65
|
+
|
|
66
|
+
Streaming is on by default. To disable it and rely on polling alone:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
client = Feat::Client.new(
|
|
70
|
+
api_key: ENV.fetch("FEAT_SERVER_KEY"),
|
|
71
|
+
streaming: false, # poll-only mode
|
|
72
|
+
poll_interval: 30, # seconds (used when streaming is off)
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
When streaming is on, `safety_poll_interval:` (default 300 seconds) controls the
|
|
77
|
+
backstop poll cadence.
|
|
57
78
|
|
|
58
79
|
## License
|
|
59
80
|
|
data/lib/feat/client.rb
CHANGED
|
@@ -3,15 +3,32 @@ require "net/http"
|
|
|
3
3
|
require "socket"
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "version"
|
|
6
|
+
require_relative "sse"
|
|
7
|
+
require_relative "streaming"
|
|
6
8
|
|
|
7
9
|
module Feat
|
|
8
|
-
#
|
|
10
|
+
# HTTP client. Uses stdlib only - zero gem dependencies.
|
|
11
|
+
#
|
|
12
|
+
# By default the client streams datafile updates over Server-Sent Events
|
|
13
|
+
# and keeps a slow background poll as a safety net. Disable streaming with
|
|
14
|
+
# `streaming: false` to fall back to polling alone.
|
|
9
15
|
class Client
|
|
16
|
+
include InterruptibleSleep
|
|
17
|
+
|
|
18
|
+
DEFAULT_URL = "https://data-01.feat.so"
|
|
10
19
|
DEFAULT_POLL_INTERVAL = 30.0
|
|
20
|
+
# When streaming carries updates, the poll is a backstop only and runs
|
|
21
|
+
# far less often.
|
|
22
|
+
DEFAULT_SAFETY_POLL_INTERVAL = 300.0
|
|
11
23
|
MIN_POLL_INTERVAL = 5.0
|
|
12
24
|
MAX_DATAFILE_BYTES = 10 * 1024 * 1024
|
|
13
25
|
OPEN_TIMEOUT_SECONDS = 3
|
|
14
26
|
READ_TIMEOUT_SECONDS = 10
|
|
27
|
+
# Long-lived stream read: heartbeat comments keep it well under this.
|
|
28
|
+
STREAM_READ_TIMEOUT_SECONDS = 90
|
|
29
|
+
# Bound on how long #close waits for the poll thread to unwind, so a
|
|
30
|
+
# blocked fetch cannot make shutdown hang indefinitely.
|
|
31
|
+
POLL_JOIN_TIMEOUT_SECONDS = 5
|
|
15
32
|
RETRYABLE_CONNECT_ERRORS = [
|
|
16
33
|
Net::OpenTimeout,
|
|
17
34
|
Errno::ETIMEDOUT,
|
|
@@ -20,32 +37,44 @@ module Feat
|
|
|
20
37
|
Errno::ENETUNREACH,
|
|
21
38
|
].freeze
|
|
22
39
|
|
|
23
|
-
def initialize(api_key:,
|
|
40
|
+
def initialize(api_key:, url: DEFAULT_URL, poll_interval: DEFAULT_POLL_INTERVAL,
|
|
41
|
+
streaming: true, safety_poll_interval: DEFAULT_SAFETY_POLL_INTERVAL,
|
|
42
|
+
http_client: nil, stream_transport: nil)
|
|
24
43
|
raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
|
|
25
|
-
raise ArgumentError, "data_plane_url is required" if data_plane_url.nil? || data_plane_url.empty?
|
|
26
44
|
|
|
27
|
-
assert_https_url!(
|
|
45
|
+
assert_https_url!(url)
|
|
28
46
|
|
|
29
|
-
@api_key
|
|
30
|
-
@
|
|
31
|
-
@
|
|
32
|
-
|
|
33
|
-
@
|
|
34
|
-
@
|
|
35
|
-
@
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
@
|
|
47
|
+
@api_key = api_key
|
|
48
|
+
@url = url.chomp("/")
|
|
49
|
+
@streaming_enabled = streaming
|
|
50
|
+
base_interval = streaming ? safety_poll_interval : poll_interval
|
|
51
|
+
@poll_interval = [base_interval.to_f, MIN_POLL_INTERVAL].max
|
|
52
|
+
@http_client = http_client
|
|
53
|
+
@datafile = nil
|
|
54
|
+
@etag = nil
|
|
55
|
+
@mutex = Mutex.new
|
|
56
|
+
@stop = false
|
|
57
|
+
@thread = nil
|
|
58
|
+
@sticky_ip = nil
|
|
59
|
+
@streaming = build_streaming_client(stream_transport) if @streaming_enabled
|
|
39
60
|
end
|
|
40
61
|
|
|
41
|
-
# Blocking initial fetch; spawns
|
|
62
|
+
# Blocking initial fetch; spawns the background poller (and stream).
|
|
42
63
|
def start
|
|
43
64
|
refresh
|
|
65
|
+
@streaming&.start
|
|
44
66
|
@thread ||= Thread.new { poll_loop }
|
|
67
|
+
self
|
|
45
68
|
end
|
|
46
69
|
|
|
47
70
|
def close
|
|
48
71
|
@stop = true
|
|
72
|
+
@streaming&.stop
|
|
73
|
+
# Join the poll thread (bounded) so a fetch in flight is waited out and
|
|
74
|
+
# @thread is cleared, leaving the client cleanly restartable.
|
|
75
|
+
@thread&.join(POLL_JOIN_TIMEOUT_SECONDS)
|
|
76
|
+
@thread = nil
|
|
77
|
+
self
|
|
49
78
|
end
|
|
50
79
|
|
|
51
80
|
def refresh
|
|
@@ -89,14 +118,56 @@ module Feat
|
|
|
89
118
|
uri = URI.parse(url)
|
|
90
119
|
return if uri.scheme == "https"
|
|
91
120
|
return if uri.scheme == "http" && %w[localhost 127.0.0.1].include?(uri.host)
|
|
92
|
-
raise ArgumentError, "
|
|
121
|
+
raise ArgumentError, "url must use https:// (http://localhost allowed for tests)"
|
|
93
122
|
rescue URI::InvalidURIError
|
|
94
|
-
raise ArgumentError, "
|
|
123
|
+
raise ArgumentError, "url is not a valid URL"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Builds the streaming client; its `put` handler runs through the same
|
|
127
|
+
# version-guarded store as polling, so an older frame never wins.
|
|
128
|
+
def build_streaming_client(transport)
|
|
129
|
+
transport ||= NetHTTPStreamTransport.new(
|
|
130
|
+
open_timeout: OPEN_TIMEOUT_SECONDS,
|
|
131
|
+
read_timeout: STREAM_READ_TIMEOUT_SECONDS,
|
|
132
|
+
)
|
|
133
|
+
StreamingClient.new(
|
|
134
|
+
url: @url,
|
|
135
|
+
api_key: @api_key,
|
|
136
|
+
transport: transport,
|
|
137
|
+
on_put: ->(parsed) { store_datafile(parsed, parsed["etag"]) },
|
|
138
|
+
on_patch: ->(parsed) { apply_patch(parsed) },
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Apply a streamed `patch` delta. Version-gated: the delta is merged only
|
|
143
|
+
# when the in-memory datafile's version equals the patch's +from+, so the
|
|
144
|
+
# result is exactly the +to+ snapshot. On any gap or mismatch the patch is
|
|
145
|
+
# ignored - a reconnect reseeds a full `put` and the safety poll backstops.
|
|
146
|
+
# Runs under the same mutex as store_datafile, so a subsequent evaluation
|
|
147
|
+
# sees the merged delta atomically. Returns true when applied.
|
|
148
|
+
def apply_patch(patch)
|
|
149
|
+
from = patch["from"]
|
|
150
|
+
to = patch["to"]
|
|
151
|
+
# Reject malformed or out-of-order deltas before touching the datafile:
|
|
152
|
+
# both bounds must be integers and the patch must move strictly forward.
|
|
153
|
+
# Without the `to > from` guard a backward `to` would roll the in-memory
|
|
154
|
+
# version backward and break version ordering.
|
|
155
|
+
return false unless from.is_a?(Integer) && to.is_a?(Integer) && to > from
|
|
156
|
+
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
current = @datafile
|
|
159
|
+
return false if current.nil?
|
|
160
|
+
return false unless current.version == from
|
|
161
|
+
|
|
162
|
+
@datafile = Datafile.merge_patch(current, patch)
|
|
163
|
+
@etag = patch["etag"] if patch["etag"]
|
|
164
|
+
end
|
|
165
|
+
true
|
|
95
166
|
end
|
|
96
167
|
|
|
97
168
|
def poll_loop
|
|
98
169
|
until @stop
|
|
99
|
-
|
|
170
|
+
interruptible_sleep(@poll_interval) { @stop }
|
|
100
171
|
break if @stop
|
|
101
172
|
|
|
102
173
|
begin
|
|
@@ -108,7 +179,7 @@ module Feat
|
|
|
108
179
|
end
|
|
109
180
|
|
|
110
181
|
def fetch_once
|
|
111
|
-
uri = URI.parse("#{@
|
|
182
|
+
uri = URI.parse("#{@url}/sdk/v1/datafile")
|
|
112
183
|
req = Net::HTTP::Get.new(uri)
|
|
113
184
|
req["Authorization"] = "Bearer #{@api_key}"
|
|
114
185
|
req["User-Agent"] = "feat-sdk-ruby/#{Feat::VERSION}"
|
|
@@ -124,18 +195,28 @@ module Feat
|
|
|
124
195
|
raise "datafile exceeds maximum allowed size" if length && length > MAX_DATAFILE_BYTES
|
|
125
196
|
body = res.body
|
|
126
197
|
raise "datafile exceeds maximum allowed size" if body.bytesize > MAX_DATAFILE_BYTES
|
|
127
|
-
|
|
128
|
-
new_etag = res["ETag"]
|
|
129
|
-
@mutex.synchronize do
|
|
130
|
-
@datafile = Datafile.from_json(data)
|
|
131
|
-
@etag = new_etag if new_etag
|
|
132
|
-
end
|
|
133
|
-
true
|
|
198
|
+
store_datafile(JSON.parse(body), res["ETag"])
|
|
134
199
|
else
|
|
135
200
|
raise "feat: fetch datafile failed: #{res.code}"
|
|
136
201
|
end
|
|
137
202
|
end
|
|
138
203
|
|
|
204
|
+
# Adopt a parsed datafile only if its version is strictly newer than the
|
|
205
|
+
# one in memory. Shared by the poll and stream paths and guarded by the
|
|
206
|
+
# same mutex the evaluator reads through. Returns true when adopted.
|
|
207
|
+
def store_datafile(parsed, etag)
|
|
208
|
+
candidate = Datafile.from_json(parsed)
|
|
209
|
+
@mutex.synchronize do
|
|
210
|
+
current_version = @datafile&.version
|
|
211
|
+
new_version = candidate.version
|
|
212
|
+
return false if new_version && current_version && new_version <= current_version
|
|
213
|
+
|
|
214
|
+
@datafile = candidate
|
|
215
|
+
@etag = etag if etag
|
|
216
|
+
end
|
|
217
|
+
true
|
|
218
|
+
end
|
|
219
|
+
|
|
139
220
|
# Net::HTTP doesn't iterate getaddrinfo results on connect failure
|
|
140
221
|
# (Ruby 3.3 has no Happy Eyeballs); ipaddr= lets us pin each attempt
|
|
141
222
|
# to a specific IP while keeping the hostname for SNI.
|
data/lib/feat/datafile.rb
CHANGED
|
@@ -49,6 +49,36 @@ module Feat
|
|
|
49
49
|
)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Merge a patch delta onto an existing File and return a new File. Pure:
|
|
53
|
+
# +current+ is not mutated. Added or changed flags/segments are built
|
|
54
|
+
# from their wire objects and override by key; removed keys are dropped.
|
|
55
|
+
# version, etag, and generatedAt advance to the patch's values (etag and
|
|
56
|
+
# generatedAt fall back to the current ones when the patch omits them).
|
|
57
|
+
# Raises if a flag or segment object is malformed; the caller treats that
|
|
58
|
+
# as a no-op and ignores the patch.
|
|
59
|
+
def self.merge_patch(current, patch)
|
|
60
|
+
flags = current.flags.dup
|
|
61
|
+
(patch["flags"] || {}).each { |k, v| flags[k] = build_flag(v) }
|
|
62
|
+
(patch["removedFlags"] || []).each { |k| flags.delete(k) }
|
|
63
|
+
|
|
64
|
+
segments = current.segments.dup
|
|
65
|
+
(patch["segments"] || {}).each { |k, v| segments[k] = build_segment(v) }
|
|
66
|
+
(patch["removedSegments"] || []).each { |k| segments.delete(k) }
|
|
67
|
+
|
|
68
|
+
File.new(
|
|
69
|
+
schemaVersion: current.schemaVersion,
|
|
70
|
+
envId: current.envId,
|
|
71
|
+
envKey: current.envKey,
|
|
72
|
+
projectId: current.projectId,
|
|
73
|
+
version: patch["to"],
|
|
74
|
+
etag: patch["etag"] || current.etag,
|
|
75
|
+
generatedAt: patch["generatedAt"] || current.generatedAt,
|
|
76
|
+
flags: flags,
|
|
77
|
+
segments: segments,
|
|
78
|
+
contextKinds: current.contextKinds
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
52
82
|
def self.build_flag(d)
|
|
53
83
|
FlagSpec.new(
|
|
54
84
|
id: d["id"],
|
data/lib/feat/sse.rb
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Feat
|
|
2
|
+
# Raised when a single SSE event (its in-flight buffer plus accumulated
|
|
3
|
+
# data) grows past the configured byte cap. Aborts the connection rather
|
|
4
|
+
# than letting a missing newline or a giant data field exhaust memory.
|
|
5
|
+
class SSEOverflowError < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Incremental Server-Sent Events parser. Pure: it does no IO.
|
|
8
|
+
#
|
|
9
|
+
# Feed raw response bytes with #feed; the parser buffers, splits on
|
|
10
|
+
# newline boundaries, and yields one Hash per dispatched event:
|
|
11
|
+
#
|
|
12
|
+
# { event: "put", data: "<json>", id: "42" }
|
|
13
|
+
#
|
|
14
|
+
# Per the SSE wire format:
|
|
15
|
+
# - "field: value" lines set the event/data/id of the pending event;
|
|
16
|
+
# a single leading space after the colon is stripped from the value.
|
|
17
|
+
# - "data:" lines accumulate and are joined with "\n".
|
|
18
|
+
# - A blank line dispatches the pending event.
|
|
19
|
+
# - Lines starting with ":" are comments (heartbeats) and are ignored.
|
|
20
|
+
class SSEParser
|
|
21
|
+
# Upper bound on the bytes held for one in-progress event. Mirrors
|
|
22
|
+
# Client::MAX_DATAFILE_BYTES so the stream path is bounded the same way
|
|
23
|
+
# the poll path is.
|
|
24
|
+
MAX_EVENT_BYTES = 10 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
def initialize(max_event_bytes: MAX_EVENT_BYTES)
|
|
27
|
+
@max_event_bytes = max_event_bytes
|
|
28
|
+
@buffer = +""
|
|
29
|
+
reset_event
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Append a chunk of bytes and yield each fully-formed event.
|
|
33
|
+
def feed(chunk)
|
|
34
|
+
@buffer << chunk
|
|
35
|
+
while (idx = @buffer.index("\n"))
|
|
36
|
+
line = @buffer.slice!(0, idx + 1)
|
|
37
|
+
# String#chomp strips a trailing "\r\n", "\n", or "\r".
|
|
38
|
+
process_line(line.chomp) { |event| yield event }
|
|
39
|
+
end
|
|
40
|
+
# A line that never terminates, or a single oversized data field, must
|
|
41
|
+
# not grow the buffers without bound. Abort once past the cap.
|
|
42
|
+
guard_size!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def guard_size!
|
|
48
|
+
buffered = @buffer.bytesize
|
|
49
|
+
@data.each { |segment| buffered += segment.bytesize }
|
|
50
|
+
return if buffered <= @max_event_bytes
|
|
51
|
+
|
|
52
|
+
raise SSEOverflowError, "SSE event exceeds #{@max_event_bytes} bytes"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def process_line(line)
|
|
56
|
+
if line.empty?
|
|
57
|
+
dispatch { |event| yield event }
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
return if line.start_with?(":") # heartbeat / comment
|
|
61
|
+
|
|
62
|
+
field, sep, value = line.partition(":")
|
|
63
|
+
# A bare "field" with no colon is ignored (no value to set).
|
|
64
|
+
return if sep.empty?
|
|
65
|
+
|
|
66
|
+
value = value[1..] if value.start_with?(" ")
|
|
67
|
+
case field
|
|
68
|
+
when "event" then @event = value
|
|
69
|
+
when "data" then @data << value
|
|
70
|
+
when "id" then @id = value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def dispatch
|
|
75
|
+
# Nothing buffered between two blank lines -> nothing to emit.
|
|
76
|
+
return if @event.nil? && @data.empty? && @id.nil?
|
|
77
|
+
|
|
78
|
+
yield({ event: @event || "message", data: @data.join("\n"), id: @id })
|
|
79
|
+
reset_event
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reset_event
|
|
83
|
+
@event = nil
|
|
84
|
+
@data = []
|
|
85
|
+
@id = nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "sse"
|
|
6
|
+
|
|
7
|
+
module Feat
|
|
8
|
+
# Raised when the stream endpoint answers with a non-200 status. +code+
|
|
9
|
+
# carries the HTTP status so the run loop can tell terminal auth failures
|
|
10
|
+
# (401 invalid/revoked/expired key, 403 origin not allowed) apart from
|
|
11
|
+
# retryable ones (429 rate limit, 5xx).
|
|
12
|
+
class StreamError < StandardError
|
|
13
|
+
attr_reader :code
|
|
14
|
+
|
|
15
|
+
def initialize(message, code: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@code = code
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sleeps that wake promptly when a stop flag flips, so background threads
|
|
22
|
+
# shut down without waiting out a full interval.
|
|
23
|
+
module InterruptibleSleep
|
|
24
|
+
SLEEP_GRANULARITY = 0.1
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def interruptible_sleep(seconds, &stop)
|
|
29
|
+
deadline = monotonic + seconds
|
|
30
|
+
loop do
|
|
31
|
+
return if stop.call
|
|
32
|
+
|
|
33
|
+
remaining = deadline - monotonic
|
|
34
|
+
return if remaining <= 0
|
|
35
|
+
|
|
36
|
+
sleep([SLEEP_GRANULARITY, remaining].min)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def monotonic
|
|
41
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Default SSE transport built on Net::HTTP streaming reads. Injectable so
|
|
46
|
+
# tests can supply a fake that yields canned chunks.
|
|
47
|
+
class NetHTTPStreamTransport
|
|
48
|
+
def initialize(open_timeout:, read_timeout:)
|
|
49
|
+
@open_timeout = open_timeout
|
|
50
|
+
@read_timeout = read_timeout
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Open a streaming GET and return a connection handle.
|
|
54
|
+
def connect(uri:, headers:)
|
|
55
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
56
|
+
http.use_ssl = (uri.scheme == "https")
|
|
57
|
+
http.open_timeout = @open_timeout
|
|
58
|
+
http.read_timeout = @read_timeout
|
|
59
|
+
http.start
|
|
60
|
+
Connection.new(http, uri, headers)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Wraps a started Net::HTTP connection. #each_chunk blocks while the
|
|
64
|
+
# server holds the stream open; #close aborts it from another thread.
|
|
65
|
+
class Connection
|
|
66
|
+
def initialize(http, uri, headers)
|
|
67
|
+
@http = http
|
|
68
|
+
@request = Net::HTTP::Get.new(uri)
|
|
69
|
+
headers.each { |name, value| @request[name] = value }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def each_chunk
|
|
73
|
+
@http.request(@request) do |response|
|
|
74
|
+
code = response.code.to_i
|
|
75
|
+
raise StreamError.new("datafile stream failed: HTTP #{code}", code: code) unless code == 200
|
|
76
|
+
|
|
77
|
+
response.read_body { |chunk| yield chunk }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def close
|
|
82
|
+
@http.finish if @http.started?
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Holds a long-lived SSE connection to the datafile stream endpoint and
|
|
90
|
+
# invokes +on_put+ with the parsed datafile for every `put` frame and
|
|
91
|
+
# +on_patch+ with the parsed delta for every `patch` frame. Runs on its
|
|
92
|
+
# own thread, reconnects with exponential backoff, and stops cleanly.
|
|
93
|
+
class StreamingClient
|
|
94
|
+
include InterruptibleSleep
|
|
95
|
+
|
|
96
|
+
PATH = "/sdk/v1/datafile/stream".freeze
|
|
97
|
+
DEFAULT_INITIAL_BACKOFF = 1.0
|
|
98
|
+
DEFAULT_MAX_BACKOFF = 60.0
|
|
99
|
+
# A connection must stay open at least this long before we treat it as
|
|
100
|
+
# healthy and reset backoff. The server seeds a `put` on every
|
|
101
|
+
# (re)connect, so "an event arrived" alone does not prove the connection
|
|
102
|
+
# is stable: a server that seeds then immediately drops would otherwise
|
|
103
|
+
# pin us to a fixed ~1s reconnect cadence forever.
|
|
104
|
+
DEFAULT_MIN_UPTIME = 5.0
|
|
105
|
+
# HTTP statuses that will never succeed with this key/origin, so we stop
|
|
106
|
+
# retrying: 401 (invalid/revoked/expired key), 403 (origin not allowed).
|
|
107
|
+
TERMINAL_STREAM_CODES = [401, 403].freeze
|
|
108
|
+
JOIN_TIMEOUT_SECONDS = 5
|
|
109
|
+
|
|
110
|
+
def initialize(url:, api_key:, transport:, on_put:, on_patch: nil, on_error: nil,
|
|
111
|
+
initial_backoff: DEFAULT_INITIAL_BACKOFF,
|
|
112
|
+
max_backoff: DEFAULT_MAX_BACKOFF,
|
|
113
|
+
min_uptime: DEFAULT_MIN_UPTIME,
|
|
114
|
+
max_event_bytes: SSEParser::MAX_EVENT_BYTES)
|
|
115
|
+
@url = url.chomp("/")
|
|
116
|
+
@api_key = api_key
|
|
117
|
+
@transport = transport
|
|
118
|
+
@on_put = on_put
|
|
119
|
+
@on_patch = on_patch
|
|
120
|
+
@on_error = on_error
|
|
121
|
+
@initial_backoff = initial_backoff
|
|
122
|
+
@max_backoff = max_backoff
|
|
123
|
+
@min_uptime = min_uptime
|
|
124
|
+
@max_event_bytes = max_event_bytes
|
|
125
|
+
@mutex = Mutex.new
|
|
126
|
+
@conn = nil
|
|
127
|
+
@thread = nil
|
|
128
|
+
@stop = false
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def start
|
|
132
|
+
@thread ||= Thread.new { run_loop }
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Signal shutdown, abort any in-flight read, and join the thread.
|
|
137
|
+
def stop
|
|
138
|
+
@stop = true
|
|
139
|
+
current = @mutex.synchronize { @conn }
|
|
140
|
+
begin
|
|
141
|
+
current&.close
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
@thread&.join(JOIN_TIMEOUT_SECONDS)
|
|
146
|
+
@thread = nil
|
|
147
|
+
self
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def run_loop
|
|
153
|
+
backoff = @initial_backoff
|
|
154
|
+
until @stop
|
|
155
|
+
started_at = monotonic
|
|
156
|
+
terminal = false
|
|
157
|
+
begin
|
|
158
|
+
stream_once
|
|
159
|
+
rescue StreamError => e
|
|
160
|
+
notify_error(e)
|
|
161
|
+
terminal = terminal_stream_error?(e)
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
notify_error(e)
|
|
164
|
+
end
|
|
165
|
+
break if @stop || terminal
|
|
166
|
+
|
|
167
|
+
# Only reset backoff for a connection that proved itself by staying
|
|
168
|
+
# open past the minimum uptime, not merely because the seeded `put`
|
|
169
|
+
# was received.
|
|
170
|
+
backoff = @initial_backoff if (monotonic - started_at) >= @min_uptime
|
|
171
|
+
# Equal jitter spreads reconnects so a fleet does not stampede a
|
|
172
|
+
# restarting relay. The un-jittered backoff feeds the next doubling.
|
|
173
|
+
interruptible_sleep(apply_jitter(backoff)) { @stop }
|
|
174
|
+
backoff = next_backoff(backoff)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def terminal_stream_error?(error)
|
|
179
|
+
error.is_a?(StreamError) && TERMINAL_STREAM_CODES.include?(error.code)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Equal jitter: sleep half the window plus a random slice of the other
|
|
183
|
+
# half, keeping the delay within [backoff/2, backoff].
|
|
184
|
+
def apply_jitter(backoff)
|
|
185
|
+
half = backoff / 2.0
|
|
186
|
+
half + (rand * half)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def next_backoff(backoff)
|
|
190
|
+
[backoff * 2, @max_backoff].min
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def stream_once
|
|
194
|
+
uri = URI.parse("#{@url}#{PATH}")
|
|
195
|
+
conn = @transport.connect(uri: uri, headers: stream_headers)
|
|
196
|
+
@mutex.synchronize { @conn = conn }
|
|
197
|
+
|
|
198
|
+
parser = SSEParser.new(max_event_bytes: @max_event_bytes)
|
|
199
|
+
conn.each_chunk do |chunk|
|
|
200
|
+
break if @stop
|
|
201
|
+
|
|
202
|
+
parser.feed(chunk) { |event| handle_event(event) }
|
|
203
|
+
end
|
|
204
|
+
ensure
|
|
205
|
+
closing = @mutex.synchronize do
|
|
206
|
+
held = @conn
|
|
207
|
+
@conn = nil
|
|
208
|
+
held
|
|
209
|
+
end
|
|
210
|
+
begin
|
|
211
|
+
closing&.close
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def handle_event(event)
|
|
218
|
+
case event[:event]
|
|
219
|
+
when "put" then with_parsed_payload(event) { |parsed| @on_put.call(parsed) }
|
|
220
|
+
when "patch" then with_parsed_payload(event) { |parsed| @on_patch&.call(parsed) }
|
|
221
|
+
end
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
notify_error(e)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Decode the event's JSON data field and hand it to the block. A missing,
|
|
227
|
+
# empty, or unparseable payload is dropped silently (the next frame or a
|
|
228
|
+
# reconnect recovers); any error the block raises bubbles to the caller's
|
|
229
|
+
# rescue and is surfaced via on_error.
|
|
230
|
+
def with_parsed_payload(event)
|
|
231
|
+
raw = event[:data]
|
|
232
|
+
return if raw.nil? || raw.empty?
|
|
233
|
+
|
|
234
|
+
parsed =
|
|
235
|
+
begin
|
|
236
|
+
JSON.parse(raw)
|
|
237
|
+
rescue JSON::ParserError
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
yield parsed
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# We deliberately do not send a Last-Event-ID header to resume. The
|
|
245
|
+
# server reseeds the full datafile on every (re)connect and ignores any
|
|
246
|
+
# resume cursor, and store_datafile is version-guarded so a re-pushed
|
|
247
|
+
# snapshot is a no-op. The parsed event id is therefore left unused:
|
|
248
|
+
# tracking it for resume would be dead code.
|
|
249
|
+
def stream_headers
|
|
250
|
+
{
|
|
251
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
252
|
+
"User-Agent" => "feat-sdk-ruby/#{Feat::VERSION}",
|
|
253
|
+
"Accept" => "text/event-stream",
|
|
254
|
+
"Cache-Control" => "no-cache",
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def notify_error(error)
|
|
259
|
+
@on_error&.call(error)
|
|
260
|
+
rescue StandardError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
data/lib/feat/version.rb
CHANGED
data/lib/feat.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: feat-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- feat HQ
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Server-side Ruby SDK for feat. Polls a per-environment datafile and evaluates
|
|
14
14
|
flags locally with no per-flag network call. Stdlib only.
|
|
@@ -28,6 +28,8 @@ files:
|
|
|
28
28
|
- lib/feat/eval.rb
|
|
29
29
|
- lib/feat/operators.rb
|
|
30
30
|
- lib/feat/segments.rb
|
|
31
|
+
- lib/feat/sse.rb
|
|
32
|
+
- lib/feat/streaming.rb
|
|
31
33
|
- lib/feat/version.rb
|
|
32
34
|
homepage: https://feat.so
|
|
33
35
|
licenses:
|