kessel-sdk 1.11.0 → 1.12.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: 6f118f8d4a64bb88e16061100917338de50a57456725240592395e4e9469eb18
4
- data.tar.gz: 0cdba6a003c929eb0e111ef832fdc602b13a0dc48dc52ea773522c096408cea8
3
+ metadata.gz: 997a5d14ffc861c340630e331dfd5c34f367ed7f68b9f760363a817180c10711
4
+ data.tar.gz: 6144712b5dec3a5355bc3f7e6a35953a0b4a2c2beeee56b017eebc659c03f3bc
5
5
  SHA512:
6
- metadata.gz: d172c0dc4223a7aca090c58a14ab2c86421383ad1fc0c7931690f576fa1d88130bde656b495455f0fcd91c331d4f2dcb935963a3f9f7a5708640849102d26ddd
7
- data.tar.gz: 1112d3a51e7c23f00371d88adf7cc17e8323e20d4d80663272f7a55c87e52c5b50374582c2c203a75d14954773b0ebb982c4df79553a067edcfc737d44721eb9
6
+ metadata.gz: 7397b29d146033e8d2060af9e25195debd851f052b4c3fad903277bd13b25ed8a1588f5a712bc8952e18fc75315cb79bd32c3a8732c07604f0cf51d2689ba843
7
+ data.tar.gz: 0c3b20634ed5d7529d66ba4b3e06c15e677eddfaf676618eaa4864205d7157a3b57d9cbab91b77de8e3adbbdbb707e255ef3055e986de6064a87b9c284b0e7dc
data/README.md CHANGED
@@ -97,7 +97,8 @@ discovery = fetch_oidc_discovery('https://sso.example.com/auth/realms/my-realm')
97
97
  oauth = OAuth2ClientCredentials.new(
98
98
  client_id: 'my-app',
99
99
  client_secret: 'my-secret',
100
- token_endpoint: discovery.token_endpoint
100
+ token_endpoint: discovery.token_endpoint,
101
+ retry: { max_retries: 3, base_delay: 0.5, max_delay: 2.0, jitter: :full }
101
102
  )
102
103
 
103
104
  # Build the client -- tokens are cached and refreshed automatically
@@ -106,6 +107,13 @@ client = KesselInventoryService::ClientBuilder.new('kessel.example.com:443')
106
107
  .build
107
108
  ```
108
109
 
110
+ Token fetch retries apply only to the token endpoint, not OIDC discovery. By default, network/timeout errors and HTTP
111
+ 429/5xx responses receive four total attempts (initial plus three retries), with full-jitter delay caps of 0.5, 1,
112
+ and 2 seconds and total added sleep below 3.5 seconds. `retry` accepts non-negative integer `max_retries` (0
113
+ disables retries), positive finite-second `base_delay` and `max_delay`, and `jitter: :full` or `:none`; defaults are
114
+ `3`, `0.5`, `2.0`, and `:full`. Invalid retry configuration raises `ArgumentError`. Permanent OAuth/configuration
115
+ failures are not retried. Refresh is synchronous, so a transient SSO failure may delay an RPC.
116
+
109
117
  #### Custom or No Credentials
110
118
 
111
119
  ```ruby
data/lib/kessel/auth.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'grpc'
4
4
  require 'kessel/version'
5
+ require 'socket'
6
+ require 'timeout'
5
7
 
6
8
  module Kessel
7
9
  # OpenID Connect authentication module for Kessel services.
@@ -105,6 +107,7 @@ module Kessel
105
107
  #
106
108
  # # Get current access token (automatically cached and refreshed)
107
109
  # token = oauth.get_token
110
+ # rubocop:disable Metrics/ClassLength
108
111
  class OAuth2ClientCredentials
109
112
  include Kessel::Auth
110
113
 
@@ -113,9 +116,14 @@ module Kessel
113
116
  # @param client_id [String] OIDC client identifier
114
117
  # @param client_secret [String] OIDC client secret
115
118
  # @param token_endpoint [String] OIDC token endpoint URL
119
+ # @param retry [Hash] Optional token-endpoint retry settings. Keys are
120
+ # `max_retries` (non-negative Integer; 0 disables retries), `base_delay`
121
+ # and `max_delay` (positive finite seconds), and `jitter` (`:full` or
122
+ # `:none`). Defaults are 3, 0.5, 2.0, and `:full`, respectively.
116
123
  #
117
124
  # @raise [OAuthDependencyError] if the openid_connect gem is not available
118
125
  # @raise [OAuthAuthenticationError] if authentication fails
126
+ # @raise [ArgumentError] if retry settings are invalid
119
127
  #
120
128
  # @example
121
129
  # oauth = OAuth2ClientCredentials.new(
@@ -123,13 +131,20 @@ module Kessel
123
131
  # client_secret: 'secret',
124
132
  # token_endpoint: 'https://my-domain/auth/realms/my-realm/protocol/openid-connect/token'
125
133
  # )
126
- def initialize(client_id:, client_secret:, token_endpoint:)
134
+ def initialize(client_id:, client_secret:, token_endpoint:, **options)
135
+ validate_retry_options!(options)
136
+ retry_config = normalize_retry_config(options.fetch(:retry, {}))
127
137
  check_dependencies!
128
138
 
129
139
  @client_id = client_id
130
140
  @client_secret = client_secret
131
141
  @token_endpoint = token_endpoint
142
+ @retry_config = retry_config
132
143
  @token_mutex = Mutex.new
144
+ @generation_state_mutex = Mutex.new
145
+ @generation_users = Hash.new(0)
146
+ @generation_failures = {}
147
+ @cached_token_generation = nil
133
148
  @generation = 0
134
149
  end
135
150
 
@@ -147,23 +162,80 @@ module Kessel
147
162
  def get_token(force_refresh: false)
148
163
  return @cached_token if !force_refresh && token_valid?
149
164
 
150
- generation = @generation
165
+ generation = register_generation
151
166
 
152
- @token_mutex.synchronize do
153
- # Another thread already refreshed while we waited on the lock
154
- return @cached_token if @generation != generation && token_valid?
167
+ begin
168
+ @token_mutex.synchronize do
169
+ failure = generation_failure(generation)
155
170
 
156
- @cached_token = refresh
157
- @generation += 1
171
+ # A later successful generation may have recovered after this caller's failure.
172
+ return @cached_token if newer_cached_token?(generation)
173
+ raise failure if failure
174
+
175
+ begin
176
+ token = refresh
177
+ rescue StandardError => e
178
+ record_failure_and_advance(generation, e)
179
+ raise
180
+ end
181
+ cache_token_and_advance(token)
158
182
 
159
- return @cached_token
183
+ @cached_token
184
+ end
160
185
  rescue StandardError => e
161
- raise OAuthAuthenticationError, "Failed to obtain client credentials token: #{e.message}"
186
+ raise OAuthAuthenticationError, "Failed to obtain client credentials token: #{e.message}", cause: e
187
+ ensure
188
+ unregister_generation(generation)
162
189
  end
163
190
  end
164
191
 
165
192
  private
166
193
 
194
+ def register_generation
195
+ @generation_state_mutex.synchronize do
196
+ generation = @generation
197
+ @generation_users[generation] += 1
198
+ generation
199
+ end
200
+ end
201
+
202
+ def generation_failure(generation)
203
+ @generation_state_mutex.synchronize { @generation_failures[generation] }
204
+ end
205
+
206
+ def record_failure_and_advance(generation, error)
207
+ @generation_state_mutex.synchronize do
208
+ @generation_failures[generation] = error
209
+ @generation += 1
210
+ end
211
+ end
212
+
213
+ def advance_generation
214
+ @generation_state_mutex.synchronize { @generation += 1 }
215
+ end
216
+
217
+ def cache_token_and_advance(token)
218
+ @generation_state_mutex.synchronize do
219
+ @cached_token = token
220
+ @generation += 1
221
+ @cached_token_generation = @generation
222
+ end
223
+ end
224
+
225
+ def newer_cached_token?(generation)
226
+ !!(@cached_token_generation && @cached_token_generation > generation && token_valid?)
227
+ end
228
+
229
+ def unregister_generation(generation)
230
+ @generation_state_mutex.synchronize do
231
+ @generation_users[generation] -= 1
232
+ next unless @generation_users[generation].zero?
233
+
234
+ @generation_users.delete(generation)
235
+ @generation_failures.delete(generation)
236
+ end
237
+ end
238
+
167
239
  def refresh
168
240
  client = create_oidc_client
169
241
 
@@ -173,13 +245,117 @@ module Kessel
173
245
  client_secret: @client_secret
174
246
  }
175
247
 
176
- token_data = client.access_token!(request_params)
248
+ token_data = access_token_with_retries(client, request_params)
177
249
  RefreshTokenResponse.new(
178
250
  access_token: token_data.access_token,
179
251
  expires_at: Time.now + (token_data.expires_in || DEFAULT_EXPIRES_IN)
180
252
  ).freeze
181
253
  end
182
254
 
255
+ def access_token_with_retries(client, request_params)
256
+ retry_index = 0
257
+
258
+ begin
259
+ client.access_token!(request_params)
260
+ rescue StandardError => e
261
+ raise unless retryable_token_error?(e) && retry_index < @retry_config[:max_retries]
262
+
263
+ sleep(retry_delay(retry_index))
264
+ retry_index += 1
265
+ retry
266
+ end
267
+ end
268
+
269
+ def validate_retry_options!(options)
270
+ unknown_keys = options.keys - [:retry]
271
+ return if unknown_keys.empty?
272
+
273
+ raise ArgumentError, "unknown keyword: #{unknown_keys.first.inspect}"
274
+ end
275
+
276
+ def normalize_retry_config(retry_options)
277
+ unless retry_options.is_a?(Hash) && retry_options.keys.all?(Symbol)
278
+ raise ArgumentError, 'retry must be a symbol-keyed Hash'
279
+ end
280
+
281
+ unknown_keys = retry_options.keys - %i[max_retries base_delay max_delay jitter]
282
+ raise ArgumentError, "unknown retry option: #{unknown_keys.first.inspect}" unless unknown_keys.empty?
283
+
284
+ config = {
285
+ max_retries: 3,
286
+ base_delay: 0.5,
287
+ max_delay: 2.0,
288
+ jitter: :full
289
+ }.merge(retry_options)
290
+ validate_retry_config_values!(config)
291
+ config.freeze
292
+ end
293
+
294
+ def validate_retry_config_values!(config)
295
+ unless config[:max_retries].is_a?(Integer) && config[:max_retries] >= 0
296
+ raise ArgumentError, 'retry max_retries must be a non-negative Integer'
297
+ end
298
+
299
+ %i[base_delay max_delay].each do |key|
300
+ next if valid_delay?(config[key])
301
+
302
+ raise ArgumentError, "retry #{key} must be a positive finite Integer or Float"
303
+ end
304
+
305
+ return if %i[full none].include?(config[:jitter])
306
+
307
+ raise ArgumentError, 'retry jitter must be :full or :none'
308
+ end
309
+
310
+ def valid_delay?(value)
311
+ (value.is_a?(Integer) || value.is_a?(Float)) && value.positive? &&
312
+ (!value.is_a?(Float) || value.finite?)
313
+ end
314
+
315
+ def retry_delay(retry_index)
316
+ cap = [@retry_config[:max_delay], @retry_config[:base_delay] * (2**retry_index)].min
317
+ return cap if @retry_config[:jitter] == :none
318
+
319
+ rand(cap.to_f)
320
+ end
321
+
322
+ def retryable_token_error?(error)
323
+ retryable_rack_oauth_error?(error) || retryable_transient_error?(error)
324
+ end
325
+
326
+ def retryable_rack_oauth_error?(error)
327
+ return false unless defined?(::Rack::OAuth2::Client::Error)
328
+ return false unless error.is_a?(::Rack::OAuth2::Client::Error)
329
+
330
+ status = error.status
331
+ status == 429 || (status.is_a?(Integer) && status.between?(500, 599))
332
+ end
333
+
334
+ def retryable_transient_error?(error)
335
+ transient_error_classes.any? { |error_class| error.is_a?(error_class) }
336
+ end
337
+
338
+ def transient_error_classes
339
+ %w[
340
+ Faraday::ConnectionFailed
341
+ Faraday::TimeoutError
342
+ Timeout::Error
343
+ SocketError
344
+ EOFError
345
+ Errno::ECONNREFUSED
346
+ Errno::ECONNRESET
347
+ Errno::ETIMEDOUT
348
+ Errno::EHOSTUNREACH
349
+ Errno::ENETUNREACH
350
+ ].map { |name| optional_error_class(name) }.compact
351
+ end
352
+
353
+ def optional_error_class(name)
354
+ Object.const_get(name, false)
355
+ rescue NameError
356
+ nil
357
+ end
358
+
183
359
  # Checks if we have a valid cached token.
184
360
  #
185
361
  # @return [Boolean] true if token exists and not expired
@@ -206,5 +382,6 @@ module Kessel
206
382
  )
207
383
  end
208
384
  end
385
+ # rubocop:enable Metrics/ClassLength
209
386
  end
210
387
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kessel
4
4
  module Inventory
5
- VERSION = '1.11.0'
5
+ VERSION = '1.12.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kessel-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.0
4
+ version: 1.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Project Kessel