feat-sdk 0.1.0 → 0.2.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: 8f64fac3df2e3ba9d2dc9db569472dd8329bea2d4fb4f2117d44e65a45d9e652
4
- data.tar.gz: 4d60e7877bc7df301aa90a96bd19b11c211d6941387ffe834793c84db04ad353
3
+ metadata.gz: 96fb093819af9d5ad6b3caf1e2d7dbe8bb54615c72c96d44c54a50e90a5b2ec4
4
+ data.tar.gz: 62b5a48dfc8bd4917567aff1c43ca75907b9f474f3ae60500b73d53b58dd57c2
5
5
  SHA512:
6
- metadata.gz: 2a990fc8a48b6176329fc664001c314b37fd5523122d91481700f804df7ec0d75f9705167eced27696627de0bfa25b895e0950b7aa69dd2d73785d917b7175b3
7
- data.tar.gz: 8bbbed51ad214a26652743a15f036ea1936f431d74606c634f63282bfaaaee67310606e9110748a46385b9fa5786e441a6cd3690666a146c924ba4324420a1f2
6
+ metadata.gz: 8cc1f3d77862b0fa9adde4d8c5a766bc2ab17e040cd791054c8fbde8199e9764067412e48d451283c8a3d47c6484cb317b83aefb63efda48a1249e65eca2859a
7
+ data.tar.gz: c6f7fac4513cbcdcdcd605d8a326ce858638a2a8da6f9bed01f834f696c5d792323471fe6d77ec631ec671307da82bd54b6fa3587374f66cded36c75919d794f
data/README.md CHANGED
@@ -1,6 +1,14 @@
1
- # feat-sdk
1
+ <p align="center">
2
+ <a href="https://feat.so">
3
+ <img src="https://feat.so/logo/wordmark.png" alt="feat.so" width="320" />
4
+ </a>
5
+ </p>
2
6
 
3
- Server-side Ruby SDK for [feat](https://feat.so) feature flags. Local flag evaluation against a polled datafile. Standard library only - no gem dependencies.
7
+ ---
8
+
9
+ # feat Ruby SDK
10
+
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.
4
12
 
5
13
  ## Install
6
14
 
@@ -22,7 +30,7 @@ require "feat"
22
30
 
23
31
  client = Feat::Client.new(
24
32
  api_key: ENV.fetch("FEAT_SERVER_KEY"),
25
- data_plane_url: "https://data.feat.so",
33
+ url: "https://data-01.feat.so", # optional; this is the default
26
34
  )
27
35
  client.start
28
36
 
@@ -43,9 +51,30 @@ Use a **server** API key (`feat_sdk_...`).
43
51
  ## How it works
44
52
 
45
53
  - Fetches a per-environment datafile and keeps it in memory.
46
- - 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.
47
61
  - Evaluation runs in-process: no per-flag network call.
48
- - 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.
49
78
 
50
79
  ## License
51
80
 
data/lib/feat/client.rb CHANGED
@@ -1,39 +1,80 @@
1
1
  require "json"
2
2
  require "net/http"
3
+ require "socket"
3
4
  require "uri"
5
+ require_relative "version"
6
+ require_relative "sse"
7
+ require_relative "streaming"
4
8
 
5
9
  module Feat
6
- # 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.
7
15
  class Client
16
+ include InterruptibleSleep
17
+
18
+ DEFAULT_URL = "https://data-01.feat.so"
8
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
9
23
  MIN_POLL_INTERVAL = 5.0
10
24
  MAX_DATAFILE_BYTES = 10 * 1024 * 1024
11
-
12
- def initialize(api_key:, data_plane_url:, poll_interval: DEFAULT_POLL_INTERVAL, http_client: nil)
25
+ OPEN_TIMEOUT_SECONDS = 3
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
32
+ RETRYABLE_CONNECT_ERRORS = [
33
+ Net::OpenTimeout,
34
+ Errno::ETIMEDOUT,
35
+ Errno::ECONNREFUSED,
36
+ Errno::EHOSTUNREACH,
37
+ Errno::ENETUNREACH,
38
+ ].freeze
39
+
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)
13
43
  raise ArgumentError, "api_key is required" if api_key.nil? || api_key.empty?
14
- raise ArgumentError, "data_plane_url is required" if data_plane_url.nil? || data_plane_url.empty?
15
44
 
16
- assert_https_url!(data_plane_url)
17
-
18
- @api_key = api_key
19
- @data_plane_url = data_plane_url.chomp("/")
20
- @poll_interval = [poll_interval.to_f, MIN_POLL_INTERVAL].max
21
- @http_client = http_client
22
- @datafile = nil
23
- @etag = nil
24
- @mutex = Mutex.new
25
- @stop = false
26
- @thread = nil
45
+ assert_https_url!(url)
46
+
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
27
60
  end
28
61
 
29
- # Blocking initial fetch; spawns a background poller thread.
62
+ # Blocking initial fetch; spawns the background poller (and stream).
30
63
  def start
31
64
  refresh
65
+ @streaming&.start
32
66
  @thread ||= Thread.new { poll_loop }
67
+ self
33
68
  end
34
69
 
35
70
  def close
36
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
37
78
  end
38
79
 
39
80
  def refresh
@@ -77,14 +118,29 @@ module Feat
77
118
  uri = URI.parse(url)
78
119
  return if uri.scheme == "https"
79
120
  return if uri.scheme == "http" && %w[localhost 127.0.0.1].include?(uri.host)
80
- 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)"
81
122
  rescue URI::InvalidURIError
82
- 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
+ )
83
139
  end
84
140
 
85
141
  def poll_loop
86
142
  until @stop
87
- sleep @poll_interval
143
+ interruptible_sleep(@poll_interval) { @stop }
88
144
  break if @stop
89
145
 
90
146
  begin
@@ -96,14 +152,13 @@ module Feat
96
152
  end
97
153
 
98
154
  def fetch_once
99
- uri = URI.parse("#{@data_plane_url}/sdk/v1/datafile")
155
+ uri = URI.parse("#{@url}/sdk/v1/datafile")
100
156
  req = Net::HTTP::Get.new(uri)
101
157
  req["Authorization"] = "Bearer #{@api_key}"
158
+ req["User-Agent"] = "feat-sdk-ruby/#{Feat::VERSION}"
102
159
  @mutex.synchronize { req["If-None-Match"] = @etag if @etag }
103
160
 
104
- res = (@http_client || Net::HTTP).start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
105
- http.request(req)
106
- end
161
+ res = with_http_connection(uri) { |http| http.request(req) }
107
162
 
108
163
  case res.code.to_i
109
164
  when 304, 404
@@ -113,16 +168,87 @@ module Feat
113
168
  raise "datafile exceeds maximum allowed size" if length && length > MAX_DATAFILE_BYTES
114
169
  body = res.body
115
170
  raise "datafile exceeds maximum allowed size" if body.bytesize > MAX_DATAFILE_BYTES
116
- data = JSON.parse(body)
117
- new_etag = res["ETag"]
118
- @mutex.synchronize do
119
- @datafile = Datafile.from_json(data)
120
- @etag = new_etag if new_etag
121
- end
122
- true
171
+ store_datafile(JSON.parse(body), res["ETag"])
123
172
  else
124
173
  raise "feat: fetch datafile failed: #{res.code}"
125
174
  end
126
175
  end
176
+
177
+ # Adopt a parsed datafile only if its version is strictly newer than the
178
+ # one in memory. Shared by the poll and stream paths and guarded by the
179
+ # same mutex the evaluator reads through. Returns true when adopted.
180
+ def store_datafile(parsed, etag)
181
+ candidate = Datafile.from_json(parsed)
182
+ @mutex.synchronize do
183
+ current_version = @datafile&.version
184
+ new_version = candidate.version
185
+ return false if new_version && current_version && new_version <= current_version
186
+
187
+ @datafile = candidate
188
+ @etag = etag if etag
189
+ end
190
+ true
191
+ end
192
+
193
+ # Net::HTTP doesn't iterate getaddrinfo results on connect failure
194
+ # (Ruby 3.3 has no Happy Eyeballs); ipaddr= lets us pin each attempt
195
+ # to a specific IP while keeping the hostname for SNI.
196
+ def with_http_connection(uri, &block)
197
+ if @http_client
198
+ return @http_client.start(
199
+ uri.host, uri.port,
200
+ use_ssl: uri.scheme == "https",
201
+ open_timeout: OPEN_TIMEOUT_SECONDS,
202
+ read_timeout: READ_TIMEOUT_SECONDS,
203
+ ) { |h| block.call(h) }
204
+ end
205
+
206
+ sticky = @mutex.synchronize { @sticky_ip }
207
+ if sticky
208
+ begin
209
+ return attempt_request(uri, sticky, &block)
210
+ rescue *RETRYABLE_CONNECT_ERRORS
211
+ @mutex.synchronize { @sticky_ip = nil if @sticky_ip == sticky }
212
+ end
213
+ end
214
+
215
+ addresses = resolve_addresses(uri.host)
216
+ if addresses.empty?
217
+ return Net::HTTP.start(
218
+ uri.host, uri.port,
219
+ use_ssl: uri.scheme == "https",
220
+ open_timeout: OPEN_TIMEOUT_SECONDS,
221
+ read_timeout: READ_TIMEOUT_SECONDS,
222
+ ) { |h| block.call(h) }
223
+ end
224
+
225
+ last_error = nil
226
+ addresses.each do |ip|
227
+ next if ip == sticky
228
+ begin
229
+ result = attempt_request(uri, ip, &block)
230
+ @mutex.synchronize { @sticky_ip = ip }
231
+ return result
232
+ rescue *RETRYABLE_CONNECT_ERRORS => e
233
+ last_error = e
234
+ end
235
+ end
236
+ raise last_error
237
+ end
238
+
239
+ def attempt_request(uri, ip, &block)
240
+ http = Net::HTTP.new(uri.host, uri.port)
241
+ http.ipaddr = ip
242
+ http.use_ssl = (uri.scheme == "https")
243
+ http.open_timeout = OPEN_TIMEOUT_SECONDS
244
+ http.read_timeout = READ_TIMEOUT_SECONDS
245
+ http.start { |h| block.call(h) }
246
+ end
247
+
248
+ def resolve_addresses(host)
249
+ Addrinfo.getaddrinfo(host, nil, nil, :STREAM).map(&:ip_address).uniq
250
+ rescue StandardError
251
+ []
252
+ end
127
253
  end
128
254
  end
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,253 @@
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. Runs on
91
+ # its own thread, reconnects with exponential backoff, and stops cleanly.
92
+ class StreamingClient
93
+ include InterruptibleSleep
94
+
95
+ PATH = "/sdk/v1/datafile/stream".freeze
96
+ DEFAULT_INITIAL_BACKOFF = 1.0
97
+ DEFAULT_MAX_BACKOFF = 60.0
98
+ # A connection must stay open at least this long before we treat it as
99
+ # healthy and reset backoff. The server seeds a `put` on every
100
+ # (re)connect, so "an event arrived" alone does not prove the connection
101
+ # is stable: a server that seeds then immediately drops would otherwise
102
+ # pin us to a fixed ~1s reconnect cadence forever.
103
+ DEFAULT_MIN_UPTIME = 5.0
104
+ # HTTP statuses that will never succeed with this key/origin, so we stop
105
+ # retrying: 401 (invalid/revoked/expired key), 403 (origin not allowed).
106
+ TERMINAL_STREAM_CODES = [401, 403].freeze
107
+ JOIN_TIMEOUT_SECONDS = 5
108
+
109
+ def initialize(url:, api_key:, transport:, on_put:, on_error: nil,
110
+ initial_backoff: DEFAULT_INITIAL_BACKOFF,
111
+ max_backoff: DEFAULT_MAX_BACKOFF,
112
+ min_uptime: DEFAULT_MIN_UPTIME,
113
+ max_event_bytes: SSEParser::MAX_EVENT_BYTES)
114
+ @url = url.chomp("/")
115
+ @api_key = api_key
116
+ @transport = transport
117
+ @on_put = on_put
118
+ @on_error = on_error
119
+ @initial_backoff = initial_backoff
120
+ @max_backoff = max_backoff
121
+ @min_uptime = min_uptime
122
+ @max_event_bytes = max_event_bytes
123
+ @mutex = Mutex.new
124
+ @conn = nil
125
+ @thread = nil
126
+ @stop = false
127
+ end
128
+
129
+ def start
130
+ @thread ||= Thread.new { run_loop }
131
+ self
132
+ end
133
+
134
+ # Signal shutdown, abort any in-flight read, and join the thread.
135
+ def stop
136
+ @stop = true
137
+ current = @mutex.synchronize { @conn }
138
+ begin
139
+ current&.close
140
+ rescue StandardError
141
+ nil
142
+ end
143
+ @thread&.join(JOIN_TIMEOUT_SECONDS)
144
+ @thread = nil
145
+ self
146
+ end
147
+
148
+ private
149
+
150
+ def run_loop
151
+ backoff = @initial_backoff
152
+ until @stop
153
+ started_at = monotonic
154
+ terminal = false
155
+ begin
156
+ stream_once
157
+ rescue StreamError => e
158
+ notify_error(e)
159
+ terminal = terminal_stream_error?(e)
160
+ rescue StandardError => e
161
+ notify_error(e)
162
+ end
163
+ break if @stop || terminal
164
+
165
+ # Only reset backoff for a connection that proved itself by staying
166
+ # open past the minimum uptime, not merely because the seeded `put`
167
+ # was received.
168
+ backoff = @initial_backoff if (monotonic - started_at) >= @min_uptime
169
+ # Equal jitter spreads reconnects so a fleet does not stampede a
170
+ # restarting relay. The un-jittered backoff feeds the next doubling.
171
+ interruptible_sleep(apply_jitter(backoff)) { @stop }
172
+ backoff = next_backoff(backoff)
173
+ end
174
+ end
175
+
176
+ def terminal_stream_error?(error)
177
+ error.is_a?(StreamError) && TERMINAL_STREAM_CODES.include?(error.code)
178
+ end
179
+
180
+ # Equal jitter: sleep half the window plus a random slice of the other
181
+ # half, keeping the delay within [backoff/2, backoff].
182
+ def apply_jitter(backoff)
183
+ half = backoff / 2.0
184
+ half + (rand * half)
185
+ end
186
+
187
+ def next_backoff(backoff)
188
+ [backoff * 2, @max_backoff].min
189
+ end
190
+
191
+ def stream_once
192
+ uri = URI.parse("#{@url}#{PATH}")
193
+ conn = @transport.connect(uri: uri, headers: stream_headers)
194
+ @mutex.synchronize { @conn = conn }
195
+
196
+ parser = SSEParser.new(max_event_bytes: @max_event_bytes)
197
+ conn.each_chunk do |chunk|
198
+ break if @stop
199
+
200
+ parser.feed(chunk) { |event| handle_event(event) }
201
+ end
202
+ ensure
203
+ closing = @mutex.synchronize do
204
+ held = @conn
205
+ @conn = nil
206
+ held
207
+ end
208
+ begin
209
+ closing&.close
210
+ rescue StandardError
211
+ nil
212
+ end
213
+ end
214
+
215
+ def handle_event(event)
216
+ return unless event[:event] == "put"
217
+
218
+ raw = event[:data]
219
+ return if raw.nil? || raw.empty?
220
+
221
+ parsed =
222
+ begin
223
+ JSON.parse(raw)
224
+ rescue JSON::ParserError
225
+ return
226
+ end
227
+
228
+ @on_put.call(parsed)
229
+ rescue StandardError => e
230
+ notify_error(e)
231
+ end
232
+
233
+ # We deliberately do not send a Last-Event-ID header to resume. The
234
+ # server reseeds the full datafile on every (re)connect and ignores any
235
+ # resume cursor, and store_datafile is version-guarded so a re-pushed
236
+ # snapshot is a no-op. The parsed event id is therefore left unused:
237
+ # tracking it for resume would be dead code.
238
+ def stream_headers
239
+ {
240
+ "Authorization" => "Bearer #{@api_key}",
241
+ "User-Agent" => "feat-sdk-ruby/#{Feat::VERSION}",
242
+ "Accept" => "text/event-stream",
243
+ "Cache-Control" => "no-cache",
244
+ }
245
+ end
246
+
247
+ def notify_error(error)
248
+ @on_error&.call(error)
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ end
253
+ end
data/lib/feat/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Feat
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.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,18 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: feat-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - feat HQ
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-27 00:00:00.000000000 Z
11
12
  dependencies: []
12
13
  description: Server-side Ruby SDK for feat. Polls a per-environment datafile and evaluates
13
14
  flags locally with no per-flag network call. Stdlib only.
14
15
  email:
15
- - engineering@feat.so
16
+ - support@feat.so
16
17
  executables: []
17
18
  extensions: []
18
19
  extra_rdoc_files: []
@@ -27,6 +28,8 @@ files:
27
28
  - lib/feat/eval.rb
28
29
  - lib/feat/operators.rb
29
30
  - lib/feat/segments.rb
31
+ - lib/feat/sse.rb
32
+ - lib/feat/streaming.rb
30
33
  - lib/feat/version.rb
31
34
  homepage: https://feat.so
32
35
  licenses:
@@ -36,6 +39,7 @@ metadata:
36
39
  source_code_uri: https://github.com/feathq/ruby-sdk
37
40
  bug_tracker_uri: https://github.com/feathq/ruby-sdk/issues
38
41
  changelog_uri: https://github.com/feathq/ruby-sdk/releases
42
+ post_install_message:
39
43
  rdoc_options: []
40
44
  require_paths:
41
45
  - lib
@@ -50,7 +54,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
50
54
  - !ruby/object:Gem::Version
51
55
  version: '0'
52
56
  requirements: []
53
- rubygems_version: 3.6.9
57
+ rubygems_version: 3.5.22
58
+ signing_key:
54
59
  specification_version: 4
55
60
  summary: feat feature-flag SDK for Ruby (server-side, local evaluation)
56
61
  test_files: []