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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50c482a000c1e2ef525db051f580656d302cfbc0cb983c0241507615cd3d6b87
4
- data.tar.gz: 1e52811533d9ba5d42805e2a9b665b626b984a1d30df61dfaa97fdacc90fe2f1
3
+ metadata.gz: 4dfb1cd9905d46890db88dea9411a6dc3755208286fbbec6565d8742459d5008
4
+ data.tar.gz: 63c030396f376ff362b3d0d5b7c07a7cc926b4dd5141c9516988dbcb87a3419f
5
5
  SHA512:
6
- metadata.gz: 40b5b6c2ab9fa985ac32c17bf39b4606c8e38a271e0e526383d2c7e1824348107f526d67350cc06dd60e5c4fd186a5df0ff41bad20f12353d1ddbf5cb3de583b
7
- data.tar.gz: c41a41b359e9cd68664189fa9271b99346f67fbd4fa6286d56ec116d9cfeabf3788b16d68ad9c20bb5eb4e65f14a0760c05bacb06fc2fcd607435bb751fae213
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 polled datafile. Standard library only - no gem dependencies.
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
- data_plane_url: "https://data.feat.so",
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
- - Polls every 30 seconds by default. ETag-aware via `If-None-Match`.
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
- - A background thread handles polling; `close` stops it cleanly.
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
- # Polling HTTP client. Uses stdlib only - zero gem dependencies.
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:, data_plane_url:, poll_interval: DEFAULT_POLL_INTERVAL, http_client: nil)
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!(data_plane_url)
45
+ assert_https_url!(url)
28
46
 
29
- @api_key = api_key
30
- @data_plane_url = data_plane_url.chomp("/")
31
- @poll_interval = [poll_interval.to_f, MIN_POLL_INTERVAL].max
32
- @http_client = http_client
33
- @datafile = nil
34
- @etag = nil
35
- @mutex = Mutex.new
36
- @stop = false
37
- @thread = nil
38
- @sticky_ip = nil
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 a background poller thread.
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, "data_plane_url must use https:// (http://localhost allowed for tests)"
121
+ raise ArgumentError, "url must use https:// (http://localhost allowed for tests)"
93
122
  rescue URI::InvalidURIError
94
- raise ArgumentError, "data_plane_url is not a valid URL"
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
- sleep @poll_interval
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("#{@data_plane_url}/sdk/v1/datafile")
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
- data = JSON.parse(body)
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
@@ -1,3 +1,3 @@
1
1
  module Feat
2
- VERSION = "0.1.1".freeze
2
+ VERSION = "0.3.0".freeze
3
3
  end
data/lib/feat.rb CHANGED
@@ -5,6 +5,8 @@ require "feat/bucketing"
5
5
  require "feat/operators"
6
6
  require "feat/segments"
7
7
  require "feat/eval"
8
+ require "feat/sse"
9
+ require "feat/streaming"
8
10
  require "feat/client"
9
11
 
10
12
  # Feat - feature-flag SDK for Ruby.
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.1.1
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-04 00:00:00.000000000 Z
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: