forest_admin_datasource_rpc 1.17.0 → 1.18.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: 3eddd5bbee08cd80c2df623bc125cfd1c93b5995c7cba4f64b0b4a53a93f7708
4
- data.tar.gz: 8a57971769da080a8e5a3b030cb7da212665a47054d7fb558d60bf8d0d09b688
3
+ metadata.gz: 9485d38ceffa876811869166eb3b74209f0c1a9807c4a2b3571b1271a552d428
4
+ data.tar.gz: 4dff025fcea11c17531f412b37ae13dfa68485484aa74fd7ccfcf5c3b11fc829
5
5
  SHA512:
6
- metadata.gz: b4b009428c7020b88a55b2673e4f2fc34d11712cb7c710868a3e745f7b1d527d2f2df61e9fbd7a4bf1b70873dc2d5a6358fc85e575f4013f69e9d97b4d3d5ea9
7
- data.tar.gz: f5052e0d5d2e38572dc15730404ab4ef6d2bdfa75b8733aadffa77566e3c7585a3ec8e779d077be6aa106c78ab762b65dae2adb7d245cc7d5569b4c580f4c5a0
6
+ metadata.gz: d0212c1ab3221ddc96ab35dbe789e362108cdb2d48cad36c47b47920545f9d55df998dc55bf94f8f6779f2b8174ca2fcb0e40817d39e7c24f8337ecfc5c9f092
7
+ data.tar.gz: 20246dff2afeae14a3d2e8b7aed1dc64f5179b1254fb3bfe8f9ea3c5e585b312d82a85b04ccd6ba78ae4486a650be63fb53f12f65c6a83bdf9343f46682f8c3d
@@ -36,7 +36,6 @@ admin work on any Ruby application."
36
36
  spec.add_dependency "bigdecimal"
37
37
  spec.add_dependency "csv"
38
38
  spec.add_dependency "faraday", "~> 2.7"
39
- spec.add_dependency "ld-eventsource", "~> 2.2"
40
39
  spec.add_dependency "mutex_m"
41
40
  spec.add_dependency "ostruct"
42
41
  spec.add_dependency "zeitwerk", "~> 2.3"
@@ -56,6 +56,8 @@ module ForestAdminDatasourceRpc
56
56
 
57
57
  # rubocop:disable Metrics/ParameterLists
58
58
  def make_request(endpoint, caller: nil, method: :get, payload: nil, symbolize_keys: false, if_none_match: nil)
59
+ log_request_start(method, endpoint, if_none_match)
60
+
59
61
  client = Faraday.new(url: @api_url) do |faraday|
60
62
  faraday.request :json
61
63
  faraday.response :json, parser_options: { symbolize_names: symbolize_keys }
@@ -75,7 +77,9 @@ module ForestAdminDatasourceRpc
75
77
  headers['forest_caller'] = caller.to_json if caller
76
78
  headers['If-None-Match'] = %("#{if_none_match}") if if_none_match
77
79
 
78
- client.send(method, endpoint, payload, headers)
80
+ response = client.send(method, endpoint, payload, headers)
81
+ log_request_complete(response, endpoint)
82
+ response
79
83
  end
80
84
  # rubocop:enable Metrics/ParameterLists
81
85
 
@@ -91,7 +95,14 @@ module ForestAdminDatasourceRpc
91
95
  end
92
96
 
93
97
  def handle_response_with_etag(response)
94
- return SchemaResponse.new(response.body, extract_etag(response)) if response.success?
98
+ if response.success?
99
+ etag = extract_etag(response)
100
+ ForestAdminAgent::Facades::Container.logger&.log(
101
+ 'Debug',
102
+ "[RPC Client] Schema response received (status: #{response.status}, ETag: #{etag || "none"})"
103
+ )
104
+ return SchemaResponse.new(response.body, etag)
105
+ end
95
106
  return NotModified if response.status == HTTP_NOT_MODIFIED
96
107
 
97
108
  raise_appropriate_error(response)
@@ -108,6 +119,11 @@ module ForestAdminDatasourceRpc
108
119
  url = response.env.url
109
120
  message = error_body[:message] || generate_default_message(status, url)
110
121
 
122
+ ForestAdminAgent::Facades::Container.logger&.log(
123
+ 'Error',
124
+ "[RPC Client] Request failed (status: #{status}, URL: #{url}, message: #{message})"
125
+ )
126
+
111
127
  exception_class = ERROR_STATUS_MAP[status]
112
128
 
113
129
  if exception_class
@@ -143,6 +159,21 @@ module ForestAdminDatasourceRpc
143
159
  name: hash['name'] || hash[:name]
144
160
  }.compact
145
161
  end
162
+
163
+ def log_request_start(method, endpoint, if_none_match)
164
+ etag_info = if_none_match ? " (If-None-Match: #{if_none_match})" : ''
165
+ ForestAdminAgent::Facades::Container.logger&.log(
166
+ 'Debug',
167
+ "[RPC Client] Sending #{method.to_s.upcase} request to #{@api_url}#{endpoint}#{etag_info}"
168
+ )
169
+ end
170
+
171
+ def log_request_complete(response, endpoint)
172
+ ForestAdminAgent::Facades::Container.logger&.log(
173
+ 'Debug',
174
+ "[RPC Client] Response received (status: #{response.status}, endpoint: #{endpoint})"
175
+ )
176
+ end
146
177
  end
147
178
  end
148
179
  end
@@ -0,0 +1,219 @@
1
+ require 'openssl'
2
+ require 'json'
3
+ require 'time'
4
+
5
+ module ForestAdminDatasourceRpc
6
+ module Utils
7
+ class SchemaPollingClient
8
+ attr_reader :closed
9
+
10
+ DEFAULT_POLLING_INTERVAL = 600 # seconds (10 minutes)
11
+ MIN_POLLING_INTERVAL = 1 # seconds (minimum safe interval)
12
+ MAX_POLLING_INTERVAL = 3600 # seconds (1 hour max)
13
+
14
+ def initialize(uri, auth_secret, options = {}, &on_schema_change)
15
+ @uri = uri
16
+ @auth_secret = auth_secret
17
+ @polling_interval = options[:polling_interval] || DEFAULT_POLLING_INTERVAL
18
+ @on_schema_change = on_schema_change
19
+ @closed = false
20
+ @cached_etag = nil
21
+ @polling_thread = nil
22
+ @mutex = Mutex.new
23
+ @connection_attempts = 0
24
+
25
+ # Validate polling interval
26
+ validate_polling_interval!
27
+
28
+ # RPC client for schema fetching with ETag support
29
+ @rpc_client = RpcClient.new(@uri, @auth_secret)
30
+ end
31
+
32
+ def start
33
+ return if @closed
34
+
35
+ @mutex.synchronize do
36
+ return if @polling_thread&.alive?
37
+
38
+ @polling_thread = Thread.new do
39
+ polling_loop
40
+ rescue StandardError => e
41
+ ForestAdminAgent::Facades::Container.logger&.log(
42
+ 'Error',
43
+ "[Schema Polling] Unexpected error in polling loop: #{e.class} - #{e.message}"
44
+ )
45
+ end
46
+ end
47
+
48
+ ForestAdminAgent::Facades::Container.logger&.log(
49
+ 'Info',
50
+ "[Schema Polling] Polling started (interval: #{@polling_interval}s)"
51
+ )
52
+ end
53
+
54
+ def stop
55
+ return if @closed
56
+
57
+ @closed = true
58
+ ForestAdminAgent::Facades::Container.logger&.log('Debug', '[Schema Polling] Stopping polling')
59
+
60
+ @mutex.synchronize do
61
+ if @polling_thread&.alive?
62
+ @polling_thread.kill
63
+ @polling_thread = nil
64
+ end
65
+ end
66
+
67
+ ForestAdminAgent::Facades::Container.logger&.log('Debug', '[Schema Polling] Polling stopped')
68
+ end
69
+
70
+ private
71
+
72
+ def polling_loop
73
+ ForestAdminAgent::Facades::Container.logger&.log(
74
+ 'Debug',
75
+ "[Schema Polling] Starting polling loop (interval: #{@polling_interval}s)"
76
+ )
77
+
78
+ loop do
79
+ break if @closed
80
+
81
+ begin
82
+ check_schema
83
+ rescue StandardError => e
84
+ handle_error(e)
85
+ end
86
+
87
+ # Sleep with interrupt check (check every second for early termination)
88
+ ForestAdminAgent::Facades::Container.logger&.log(
89
+ 'Debug',
90
+ "[Schema Polling] Waiting #{@polling_interval}s before next check (current ETag: #{@cached_etag || "none"})"
91
+ )
92
+ remaining = @polling_interval
93
+ while remaining.positive? && !@closed
94
+ sleep([remaining, 1].min)
95
+ remaining -= 1
96
+ end
97
+ end
98
+ end
99
+
100
+ def check_schema
101
+ @connection_attempts += 1
102
+ log_checking_schema
103
+
104
+ result = @rpc_client.fetch_schema('/forest/rpc-schema', if_none_match: @cached_etag)
105
+ handle_schema_result(result)
106
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
107
+ log_connection_error(e)
108
+ rescue ForestAdminAgent::Http::Exceptions::AuthenticationOpenIdClient => e
109
+ log_authentication_error(e)
110
+ rescue StandardError => e
111
+ log_unexpected_error(e)
112
+ end
113
+
114
+ def handle_error(error)
115
+ ForestAdminAgent::Facades::Container.logger&.log(
116
+ 'Error',
117
+ "[Schema Polling] Error during schema check: #{error.class} - #{error.message}"
118
+ )
119
+ end
120
+
121
+ def trigger_schema_change_callback(schema)
122
+ return unless @on_schema_change
123
+
124
+ ForestAdminAgent::Facades::Container.logger&.log(
125
+ 'Debug',
126
+ '[Schema Polling] Invoking schema change callback'
127
+ )
128
+ begin
129
+ @on_schema_change.call(schema)
130
+ ForestAdminAgent::Facades::Container.logger&.log(
131
+ 'Debug',
132
+ '[Schema Polling] Schema change callback completed successfully'
133
+ )
134
+ rescue StandardError => e
135
+ error_msg = "[Schema Polling] Error in schema change callback: #{e.class} - #{e.message}"
136
+ backtrace = "\n#{e.backtrace&.first(5)&.join("\n")}"
137
+ ForestAdminAgent::Facades::Container.logger&.log('Error', error_msg + backtrace)
138
+ end
139
+ end
140
+
141
+ def log_checking_schema
142
+ etag_info = @cached_etag ? "with ETag: #{@cached_etag}" : 'without ETag (initial fetch)'
143
+ msg = "[Schema Polling] Checking schema from #{@uri}/forest/rpc-schema " \
144
+ "(attempt ##{@connection_attempts}, #{etag_info})"
145
+ ForestAdminAgent::Facades::Container.logger&.log('Debug', msg)
146
+ end
147
+
148
+ def handle_schema_result(result)
149
+ if result == RpcClient::NotModified
150
+ handle_schema_unchanged
151
+ else
152
+ handle_schema_changed(result)
153
+ end
154
+ end
155
+
156
+ def handle_schema_unchanged
157
+ ForestAdminAgent::Facades::Container.logger&.log(
158
+ 'Debug',
159
+ "[Schema Polling] Schema unchanged (HTTP 304 Not Modified), ETag still valid: #{@cached_etag}"
160
+ )
161
+ @connection_attempts = 0
162
+ end
163
+
164
+ def handle_schema_changed(result)
165
+ new_etag = result.etag
166
+ @cached_etag.nil? ? handle_initial_schema(new_etag) : handle_schema_update(result.body, new_etag)
167
+ @connection_attempts = 0
168
+ end
169
+
170
+ def handle_initial_schema(etag)
171
+ @cached_etag = etag
172
+ ForestAdminAgent::Facades::Container.logger&.log(
173
+ 'Debug',
174
+ "[Schema Polling] Initial schema loaded successfully (ETag: #{etag})"
175
+ )
176
+ end
177
+
178
+ def handle_schema_update(schema, etag)
179
+ old_etag = @cached_etag
180
+ @cached_etag = etag
181
+ msg = "[Schema Polling] Schema changed detected (old ETag: #{old_etag}, new ETag: #{etag}), " \
182
+ 'triggering reload callback'
183
+ ForestAdminAgent::Facades::Container.logger&.log('Info', msg)
184
+ trigger_schema_change_callback(schema)
185
+ end
186
+
187
+ def log_connection_error(error)
188
+ ForestAdminAgent::Facades::Container.logger&.log(
189
+ 'Warn',
190
+ "[Schema Polling] Connection error: #{error.class} - #{error.message}"
191
+ )
192
+ end
193
+
194
+ def log_authentication_error(error)
195
+ ForestAdminAgent::Facades::Container.logger&.log(
196
+ 'Error',
197
+ "[Schema Polling] Authentication error: #{error.message}"
198
+ )
199
+ end
200
+
201
+ def log_unexpected_error(error)
202
+ ForestAdminAgent::Facades::Container.logger&.log(
203
+ 'Error',
204
+ "[Schema Polling] Unexpected error: #{error.class} - #{error.message}"
205
+ )
206
+ end
207
+
208
+ def validate_polling_interval!
209
+ if @polling_interval < MIN_POLLING_INTERVAL
210
+ raise ArgumentError,
211
+ "Schema polling interval too short: #{@polling_interval}s (minimum: #{MIN_POLLING_INTERVAL}s)"
212
+ elsif @polling_interval > MAX_POLLING_INTERVAL
213
+ raise ArgumentError,
214
+ "Schema polling interval too long: #{@polling_interval}s (maximum: #{MAX_POLLING_INTERVAL}s)"
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
@@ -2,7 +2,7 @@ module ForestAdminDatasourceRpc
2
2
  class Datasource < ForestAdminDatasourceToolkit::Datasource
3
3
  include ForestAdminDatasourceRpc::Utils
4
4
 
5
- def initialize(options, introspection, sse_client = nil)
5
+ def initialize(options, introspection, schema_polling_client = nil)
6
6
  super()
7
7
 
8
8
  ForestAdminAgent::Facades::Container.logger.log(
@@ -18,7 +18,7 @@ module ForestAdminDatasourceRpc
18
18
  @options = options
19
19
  @charts = introspection[:charts]
20
20
  @rpc_relations = introspection[:rpc_relations]
21
- @sse_client = sse_client
21
+ @schema_polling_client = schema_polling_client
22
22
  @cleaned_up = false
23
23
 
24
24
  native_query_connections = introspection[:native_query_connections] || []
@@ -58,10 +58,10 @@ module ForestAdminDatasourceRpc
58
58
 
59
59
  @cleaned_up = true
60
60
 
61
- if @sse_client
62
- ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] Closing SSE connection...')
63
- @sse_client.close
64
- ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] SSE connection closed')
61
+ if @schema_polling_client
62
+ ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] Stopping schema polling...')
63
+ @schema_polling_client.stop
64
+ ForestAdminAgent::Facades::Container.logger&.log('Info', '[RPCDatasource] Schema polling stopped')
65
65
  end
66
66
  rescue StandardError => e
67
67
  ForestAdminAgent::Facades::Container.logger&.log(
@@ -1,3 +1,3 @@
1
1
  module ForestAdminDatasourceRpc
2
- VERSION = "1.17.0"
2
+ VERSION = "1.18.0"
3
3
  end
@@ -8,20 +8,30 @@ loader.setup
8
8
  module ForestAdminDatasourceRpc
9
9
  class Error < StandardError; end
10
10
 
11
+ # Build a RPC datasource with schema polling enabled.
12
+ #
13
+ # @param options [Hash] Configuration options
14
+ # @option options [String] :uri The URI of the RPC agent
15
+ # @option options [String] :auth_secret The authentication secret (optional, will use cache if not provided)
16
+ # @option options [Integer] :schema_polling_interval Polling interval in seconds (optional)
17
+ # - Default: 600 seconds (10 minutes)
18
+ # - Can be overridden with ENV['SCHEMA_POLLING_INTERVAL']
19
+ # - Valid range: 1-3600 seconds
20
+ # - Priority: options[:schema_polling_interval] > ENV['SCHEMA_POLLING_INTERVAL'] > default
21
+ # - Example: SCHEMA_POLLING_INTERVAL=30 for development (30 seconds)
22
+ #
23
+ # @return [ForestAdminDatasourceRpc::Datasource] The configured datasource with schema polling
11
24
  def self.build(options)
12
25
  uri = options[:uri]
13
26
  auth_secret = options[:auth_secret] || ForestAdminAgent::Facades::Container.cache(:auth_secret)
14
27
  ForestAdminAgent::Facades::Container.logger.log('Info', "Getting schema from RPC agent on #{uri}.")
15
28
 
16
29
  schema = nil
17
- cached_etag = nil
18
30
 
19
31
  begin
20
32
  rpc_client = Utils::RpcClient.new(uri, auth_secret)
21
33
  response = rpc_client.fetch_schema('/forest/rpc-schema')
22
34
  schema = response.body
23
- # Use the ETag header for conditional requests
24
- cached_etag = response.etag
25
35
  rescue Faraday::ConnectionFailed => e
26
36
  ForestAdminAgent::Facades::Container.logger.log(
27
37
  'Error',
@@ -48,27 +58,31 @@ module ForestAdminDatasourceRpc
48
58
  # return empty datasource for not breaking stack
49
59
  ForestAdminDatasourceToolkit::Datasource.new
50
60
  else
51
- sse = Utils::SseClient.new("#{uri}/forest/sse", auth_secret) do
52
- ForestAdminAgent::Facades::Container.logger.log('Info', 'RPC server stopped, checking schema...')
61
+ # Create schema polling client with configurable polling interval
62
+ # Priority: options[:schema_polling_interval] > ENV['SCHEMA_POLLING_INTERVAL'] > default (600)
63
+ polling_interval = if options[:schema_polling_interval]
64
+ options[:schema_polling_interval]
65
+ elsif ENV['SCHEMA_POLLING_INTERVAL']
66
+ ENV['SCHEMA_POLLING_INTERVAL'].to_i
67
+ else
68
+ 600 # 10 minutes by default
69
+ end
53
70
 
54
- # Send If-None-Match header to check if schema has changed (304 optimization)
55
- result = rpc_client.fetch_schema('/forest/rpc-schema', if_none_match: cached_etag)
71
+ polling_options = {
72
+ polling_interval: polling_interval
73
+ }
56
74
 
57
- # If we get NotModified, schema hasn't changed
58
- if result == Utils::RpcClient::NotModified
59
- ForestAdminAgent::Facades::Container.logger.log('Debug', '[RPCDatasource] Schema has not changed (304)')
60
- else
61
- # Schema has changed, update the cached ETag and schema, then reload
62
- cached_etag = result.etag
63
- ForestAdminAgent::Facades::Container.logger.log('Info', '[RPCDatasource] Schema changed, reloading agent...')
64
- ForestAdminAgent::Builder::AgentFactory.instance.reload!
65
- end
75
+ schema_polling = Utils::SchemaPollingClient.new(uri, auth_secret, polling_options) do
76
+ # Callback when schema change is detected
77
+ logger = ForestAdminAgent::Facades::Container.logger
78
+ logger.log('Info', '[RPCDatasource] Schema change detected, reloading agent...')
79
+ ForestAdminAgent::Builder::AgentFactory.instance.reload!
66
80
  end
67
- sse.start
81
+ schema_polling.start
68
82
 
69
- datasource = ForestAdminDatasourceRpc::Datasource.new(options, schema, sse)
83
+ datasource = ForestAdminDatasourceRpc::Datasource.new(options, schema, schema_polling)
70
84
 
71
- # Setup cleanup hooks for proper SSE client shutdown
85
+ # Setup cleanup hooks for proper schema polling client shutdown
72
86
  setup_cleanup_hooks(datasource)
73
87
 
74
88
  datasource
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_admin_datasource_rpc
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matthieu
@@ -67,20 +67,6 @@ dependencies:
67
67
  - - "~>"
68
68
  - !ruby/object:Gem::Version
69
69
  version: '2.7'
70
- - !ruby/object:Gem::Dependency
71
- name: ld-eventsource
72
- requirement: !ruby/object:Gem::Requirement
73
- requirements:
74
- - - "~>"
75
- - !ruby/object:Gem::Version
76
- version: '2.2'
77
- type: :runtime
78
- prerelease: false
79
- version_requirements: !ruby/object:Gem::Requirement
80
- requirements:
81
- - - "~>"
82
- - !ruby/object:Gem::Version
83
- version: '2.2'
84
70
  - !ruby/object:Gem::Dependency
85
71
  name: mutex_m
86
72
  requirement: !ruby/object:Gem::Requirement
@@ -139,7 +125,7 @@ files:
139
125
  - forest_admin_datasource_rpc.gemspec
140
126
  - lib/forest_admin_datasource_rpc.rb
141
127
  - lib/forest_admin_datasource_rpc/Utils/rpc_client.rb
142
- - lib/forest_admin_datasource_rpc/Utils/sse_client.rb
128
+ - lib/forest_admin_datasource_rpc/Utils/schema_polling_client.rb
143
129
  - lib/forest_admin_datasource_rpc/collection.rb
144
130
  - lib/forest_admin_datasource_rpc/datasource.rb
145
131
  - lib/forest_admin_datasource_rpc/version.rb
@@ -1,212 +0,0 @@
1
- require 'openssl'
2
- require 'json'
3
- require 'time'
4
- require 'ld-eventsource'
5
-
6
- module ForestAdminDatasourceRpc
7
- module Utils
8
- class SseClient
9
- attr_reader :closed
10
-
11
- MAX_BACKOFF_DELAY = 30 # seconds
12
- INITIAL_BACKOFF_DELAY = 2 # seconds
13
-
14
- def initialize(uri, auth_secret, &on_rpc_stop)
15
- @uri = uri
16
- @auth_secret = auth_secret
17
- @on_rpc_stop = on_rpc_stop
18
- @client = nil
19
- @closed = false
20
- @connection_attempts = 0
21
- @reconnect_thread = nil
22
- @connecting = false
23
- end
24
-
25
- def start
26
- return if @closed
27
-
28
- attempt_connection
29
- end
30
-
31
- def close
32
- return if @closed
33
-
34
- @closed = true
35
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Closing connection')
36
-
37
- # Stop reconnection thread if running
38
- if @reconnect_thread&.alive?
39
- @reconnect_thread.kill
40
- @reconnect_thread = nil
41
- end
42
-
43
- begin
44
- @client&.close
45
- rescue StandardError => e
46
- ForestAdminAgent::Facades::Container.logger&.log('Debug',
47
- "[SSE Client] Error during close: #{e.message}")
48
- end
49
-
50
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Connection closed')
51
- end
52
-
53
- private
54
-
55
- def attempt_connection
56
- return if @closed
57
- return if @connecting
58
-
59
- @connecting = true
60
- @connection_attempts += 1
61
- timestamp = Time.now.utc.iso8601(3)
62
- signature = generate_signature(timestamp)
63
-
64
- headers = {
65
- 'Accept' => 'text/event-stream',
66
- 'X_TIMESTAMP' => timestamp,
67
- 'X_SIGNATURE' => signature
68
- }
69
-
70
- ForestAdminAgent::Facades::Container.logger&.log(
71
- 'Debug',
72
- "[SSE Client] Connecting to #{@uri} (attempt ##{@connection_attempts})"
73
- )
74
-
75
- begin
76
- # Close existing client if any
77
- begin
78
- @client&.close
79
- rescue StandardError
80
- # Ignore close errors
81
- end
82
-
83
- @client = SSE::Client.new(@uri, headers: headers) do |client|
84
- client.on_event do |event|
85
- handle_event(event)
86
- end
87
-
88
- client.on_error do |err|
89
- handle_error_with_reconnect(err)
90
- end
91
- end
92
-
93
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Connected successfully')
94
- rescue StandardError => e
95
- ForestAdminAgent::Facades::Container.logger&.log(
96
- 'Error',
97
- "[SSE Client] Failed to connect: #{e.class} - #{e.message}"
98
- )
99
- @connecting = false
100
- schedule_reconnect
101
- end
102
- end
103
-
104
- def handle_error_with_reconnect(err)
105
- # Ignore errors when client is intentionally closed
106
- return if @closed
107
-
108
- is_auth_error = false
109
- log_level = 'Warn'
110
-
111
- error_message = case err
112
- when SSE::Errors::HTTPStatusError
113
- # Extract more details from HTTP errors
114
- status = err.respond_to?(:status) ? err.status : 'unknown'
115
- body = err.respond_to?(:body) && !err.body.to_s.strip.empty? ? err.body : 'empty response'
116
- is_auth_error = status.to_s =~ /^(401|403)$/
117
-
118
- # Auth errors during reconnection are expected (server shutdown or credentials expiring)
119
- log_level = 'Debug' if is_auth_error
120
-
121
- "HTTP #{status} - #{body}"
122
- when EOFError, IOError
123
- # Connection lost is expected when server stops
124
- log_level = 'Debug'
125
- "Connection lost: #{err.class}"
126
- when StandardError
127
- "#{err.class} - #{err.message}"
128
- else
129
- err.to_s
130
- end
131
-
132
- ForestAdminAgent::Facades::Container.logger&.log(log_level, "[SSE Client] Error: #{error_message}")
133
-
134
- # Close client immediately to prevent ld-eventsource from reconnecting with stale credentials
135
- begin
136
- @client&.close
137
- rescue StandardError
138
- # Ignore close errors
139
- end
140
-
141
- # Reset connecting flag and schedule reconnection
142
- @connecting = false
143
-
144
- # For auth errors, increase attempt count to get longer backoff
145
- @connection_attempts += 2 if is_auth_error
146
-
147
- schedule_reconnect
148
- end
149
-
150
- def schedule_reconnect
151
- return if @closed
152
- return if @reconnect_thread&.alive?
153
-
154
- @reconnect_thread = Thread.new do
155
- delay = calculate_backoff_delay
156
- ForestAdminAgent::Facades::Container.logger&.log(
157
- 'Debug',
158
- "[SSE Client] Reconnecting in #{delay} seconds..."
159
- )
160
- sleep(delay)
161
- attempt_connection unless @closed
162
- end
163
- end
164
-
165
- def calculate_backoff_delay
166
- # Exponential backoff: 1, 2, 4, 8, 16, 30, 30, ...
167
- delay = INITIAL_BACKOFF_DELAY * (2**[@connection_attempts - 1, 0].max)
168
- [delay, MAX_BACKOFF_DELAY].min
169
- end
170
-
171
- def handle_event(event)
172
- type = event.type.to_s.strip
173
- data = event.data.to_s.strip
174
-
175
- case type
176
- when 'heartbeat'
177
- if @connecting
178
- @connecting = false
179
- @connection_attempts = 0
180
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] Connection stable')
181
- end
182
- when 'RpcServerStop'
183
- ForestAdminAgent::Facades::Container.logger&.log('Debug', '[SSE Client] RpcServerStop received')
184
- handle_rpc_stop
185
- else
186
- ForestAdminAgent::Facades::Container.logger&.log(
187
- 'Debug',
188
- "[SSE Client] Unknown event: #{type} with payload: #{data}"
189
- )
190
- end
191
- rescue StandardError => e
192
- ForestAdminAgent::Facades::Container.logger&.log(
193
- 'Error',
194
- "[SSE Client] Error handling event: #{e.class} - #{e.message}"
195
- )
196
- end
197
-
198
- def handle_rpc_stop
199
- @on_rpc_stop&.call
200
- rescue StandardError => e
201
- ForestAdminAgent::Facades::Container.logger&.log(
202
- 'Error',
203
- "[SSE Client] Error in RPC stop callback: #{e.class} - #{e.message}"
204
- )
205
- end
206
-
207
- def generate_signature(timestamp)
208
- OpenSSL::HMAC.hexdigest('SHA256', @auth_secret, timestamp)
209
- end
210
- end
211
- end
212
- end