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 +4 -4
- data/README.md +9 -1
- data/lib/kessel/auth.rb +187 -10
- data/lib/kessel/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 997a5d14ffc861c340630e331dfd5c34f367ed7f68b9f760363a817180c10711
|
|
4
|
+
data.tar.gz: 6144712b5dec3a5355bc3f7e6a35953a0b4a2c2beeee56b017eebc659c03f3bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
165
|
+
generation = register_generation
|
|
151
166
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
begin
|
|
168
|
+
@token_mutex.synchronize do
|
|
169
|
+
failure = generation_failure(generation)
|
|
155
170
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/kessel/version.rb
CHANGED