noresponse 3.0.0 → 3.0.2

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: 9ba556406e80e6bb7f20aeee106cc215b9872a1fbd1816663d6da76af9c1d568
4
- data.tar.gz: 3c1214f820469af0cdc955ad85c6526edb30144fdf937f4a41f896f47b0b84cc
3
+ metadata.gz: 8dbddc428af2abf21f8a4b8ddd05893dd6a6dfb1442e446454bb551c459b2c85
4
+ data.tar.gz: 63266d751cd6fa41cc373ee00ac9d922209cd1697d427bb1743f66814560aec8
5
5
  SHA512:
6
- metadata.gz: 0023bb0cc6f1a40b655291291b9f06a478216cf395cde6e734497fbde1b3f304dac4e9fee9f9e9a9f2c779a013cf07b543bec5dbb27a09a90077a275cd763ff0
7
- data.tar.gz: 79758d27c28ddf6630ac6e7e33db49011e4b19f56c1e272fe85d3d32f67a806f246d16dfa5a459121c1f69463d5a8cab4bdd31b2b3b0c70485caa0196db162dc
6
+ metadata.gz: c25b1cd1fee728f641ae47576c4d3e3b3b9cc2a7ba003b279571d9a26f80b7882a17f2ef187c04d19f5fb89425e2fd611c15843102abf7c9a5769a834cb0bedb
7
+ data.tar.gz: 4edd9499d6d165345fb87dbb745f560897fa8ca914ed6964763aa6ce53aff29ad9d6da160f540e807ecedebcbdb030a0af610f13d87d8793a1438dd523518b5d
@@ -173,13 +173,19 @@ module NoResponse
173
173
  # user before deleting the refresh tokens, and act reports BAN. Unblock
174
174
  # the user in the dashboard to undo it. Any other refusal, such as 403
175
175
  # insufficient_scope, raises.
176
+ #
177
+ # A 429 from the Management API is waited out and retried: Retry-After,
178
+ # else X-RateLimit-Reset, else 0.25 s doubling, at most 4 retries a call
179
+ # and 3 s of waiting a revoke or ban. Past that the 429 raises as
180
+ # ProviderActionError. An http_client may return [status, body, headers]
181
+ # so the headers are honoured; without them the backoff applies.
176
182
  def auth0(token:, domain: nil, base_url: nil, http_client: nil)
177
183
  raise ArgumentError, 'auth0 needs token' if token.to_s.empty?
178
184
 
179
185
  base = base_url || (domain.to_s.empty? ? nil : "https://#{domain}/api/v2")
180
186
  raise ArgumentError, 'auth0 needs domain or base_url' if base.nil?
181
187
 
182
- Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client))
188
+ Auth0.new(Caller.new('auth0', base, "Bearer #{token}", http_client, retry_rate_limits: true))
183
189
  end
184
190
 
185
191
  # A provider for an app with its own login, or for trying the handler
@@ -195,16 +201,62 @@ module NoResponse
195
201
  end
196
202
 
197
203
  # One provider HTTP client: base URL, Authorization header, error mapping.
204
+ #
205
+ # Rate limits. Auth0 answers 429 when the Management API rate limit is
206
+ # hit, and a 429 means it did not act, so the call is safe to send again,
207
+ # a DELETE included. One revoke or ban waits at most RATE_LIMIT_BUDGET
208
+ # seconds in all, and each call retries at most RATE_LIMIT_RETRIES times:
209
+ # it waits Retry-After seconds, else until X-RateLimit-Reset (Unix
210
+ # seconds), else 0.25, 0.5, 1 and 2 s. A wait that would pass the budget is
211
+ # not started: the 429 raises at once as ProviderActionError, the webhook
212
+ # answers 5xx and NoResponse delivers it again later. The budget keeps the
213
+ # handler inside the 5 s a delivery has to be answered.
198
214
  class Caller
199
- def initialize(name, base_url, authorization, http_client)
215
+ RATE_LIMIT_RETRIES = 4
216
+ RATE_LIMIT_BUDGET = 3.0
217
+ RATE_LIMIT_BACKOFF = 0.25
218
+
219
+ def initialize(name, base_url, authorization, http_client, retry_rate_limits: false)
200
220
  @name = name
201
221
  @base = base_url.to_s.sub(%r{/+\z}, '')
202
222
  @authorization = authorization
203
223
  @http_client = http_client
224
+ @retry_rate_limits = retry_rate_limits
225
+ @waited = 0.0
204
226
  end
205
227
 
206
228
  attr_reader :name
207
229
 
230
+ # A caller with the same settings and its own rate-limit budget, for one action.
231
+ def fresh
232
+ Caller.new(@name, @base, @authorization, @http_client, retry_rate_limits: @retry_rate_limits)
233
+ end
234
+
235
+ # How long a 429 asks to wait, in seconds.
236
+ def self.rate_limit_wait(headers, retry_index)
237
+ after = number(header(headers, 'retry-after'))
238
+ return after if after && after >= 0
239
+
240
+ reset = number(header(headers, 'x-ratelimit-reset'))
241
+ return [0.0, reset - Time.now.to_f].max if reset
242
+
243
+ RATE_LIMIT_BACKOFF * (2**retry_index)
244
+ end
245
+
246
+ def self.header(headers, name)
247
+ (headers || {}).each { |key, value| return Array(value).first.to_s.strip if key.to_s.downcase == name }
248
+ nil
249
+ end
250
+
251
+ def self.number(text)
252
+ return nil if text.nil? || text.empty?
253
+
254
+ value = Float(text)
255
+ value.finite? ? value : nil
256
+ rescue ArgumentError
257
+ nil
258
+ end
259
+
208
260
  def call(method, path, query: nil, body: nil, allow_404: false)
209
261
  uri = URI.parse(@base + path)
210
262
  uri.query = URI.encode_www_form(query) if query && !query.empty?
@@ -214,7 +266,7 @@ module NoResponse
214
266
  headers['Content-Type'] = 'application/json'
215
267
  payload = JSON.generate(body)
216
268
  end
217
- status, text = perform(method, uri, headers, payload)
269
+ status, text = send_waiting_out_rate_limits(method, uri, headers, payload)
218
270
  return nil if allow_404 && status == 404
219
271
  unless status.between?(200, 299)
220
272
  raise ProviderActionError.new(@name, status, "#{@name} #{method} #{path} answered #{status}", error_code(text))
@@ -230,6 +282,21 @@ module NoResponse
230
282
 
231
283
  private
232
284
 
285
+ def send_waiting_out_rate_limits(method, uri, headers, payload)
286
+ retry_index = 0
287
+ loop do
288
+ status, text, response_headers = perform(method, uri, headers, payload)
289
+ return [status, text] unless status == 429 && @retry_rate_limits && retry_index < RATE_LIMIT_RETRIES
290
+
291
+ wait = Caller.rate_limit_wait(response_headers, retry_index)
292
+ return [status, text] if @waited + wait > RATE_LIMIT_BUDGET
293
+
294
+ @waited += wait
295
+ sleep(wait)
296
+ retry_index += 1
297
+ end
298
+ end
299
+
233
300
  # The error code in a provider's error body: Auth0's errorCode, or a top-level code.
234
301
  def error_code(text)
235
302
  body = JSON.parse(text.to_s)
@@ -251,7 +318,7 @@ module NoResponse
251
318
  request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri, headers)
252
319
  request.body = payload unless payload.nil?
253
320
  response = http.start { |connection| connection.request(request) }
254
- [response.code.to_i, response.body.to_s]
321
+ [response.code.to_i, response.body.to_s, response.to_hash]
255
322
  end
256
323
  end
257
324
 
@@ -349,10 +416,11 @@ module NoResponse
349
416
 
350
417
  def ban(user_id)
351
418
  user = Segment.encode(user_id)
352
- block(user)
419
+ call = @call.fresh
420
+ block(call, user)
353
421
  # The block already ends a session the plan cannot delete.
354
- delete_sessions(user)
355
- delete_refresh_tokens(user_id, user)
422
+ delete_sessions(call, user)
423
+ delete_refresh_tokens(call, user_id, user)
356
424
  nil
357
425
  end
358
426
 
@@ -370,10 +438,11 @@ module NoResponse
370
438
 
371
439
  def revoke(user_id)
372
440
  user = Segment.encode(user_id)
373
- ended = delete_sessions(user)
441
+ call = @call.fresh
442
+ ended = delete_sessions(call, user)
374
443
  # Without the sessions API a login session survives; blocking is the only way to end it.
375
- block(user) unless ended
376
- delete_refresh_tokens(user_id, user)
444
+ block(call, user) unless ended
445
+ delete_refresh_tokens(call, user_id, user)
377
446
  ended ? REVOKE_SESSIONS : BAN
378
447
  end
379
448
 
@@ -382,13 +451,13 @@ module NoResponse
382
451
  error.status == 403 && error.code == 'feature_not_enabled'
383
452
  end
384
453
 
385
- def block(user)
386
- @call.call('PATCH', "/users/#{user}", body: { 'blocked' => true }, allow_404: true)
454
+ def block(call, user)
455
+ call.call('PATCH', "/users/#{user}", body: { 'blocked' => true }, allow_404: true)
387
456
  end
388
457
 
389
458
  # Ends every session; false when the plan has no sessions API.
390
- def delete_sessions(user)
391
- @call.call('DELETE', "/users/#{user}/sessions", allow_404: true)
459
+ def delete_sessions(call, user)
460
+ call.call('DELETE', "/users/#{user}/sessions", allow_404: true)
392
461
  true
393
462
  rescue ProviderActionError => e
394
463
  raise unless not_on_plan?(e)
@@ -396,9 +465,9 @@ module NoResponse
396
465
  false
397
466
  end
398
467
 
399
- def delete_refresh_tokens(user_id, user)
468
+ def delete_refresh_tokens(call, user_id, user)
400
469
  begin
401
- @call.call('DELETE', "/users/#{user}/refresh-tokens", allow_404: true)
470
+ call.call('DELETE', "/users/#{user}/refresh-tokens", allow_404: true)
402
471
  return
403
472
  rescue ProviderActionError => e
404
473
  raise unless not_on_plan?(e)
@@ -407,15 +476,15 @@ module NoResponse
407
476
  ids = []
408
477
  %w[refresh_token rotating_refresh_token].each do |type|
409
478
  10.times do |page|
410
- body = @call.call('GET', '/device-credentials',
411
- query: [['user_id', user_id], ['type', type], ['page', page.to_s], ['per_page', PAGE.to_s]],
412
- allow_404: true)
479
+ body = call.call('GET', '/device-credentials',
480
+ query: [['user_id', user_id], ['type', type], ['page', page.to_s], ['per_page', PAGE.to_s]],
481
+ allow_404: true)
413
482
  listed = body.is_a?(Array) ? body.grep(Hash) : []
414
483
  ids.concat(listed.map { |c| c['id'] }.select { |id| id.is_a?(String) && !id.empty? })
415
484
  break if listed.length < PAGE
416
485
  end
417
486
  end
418
- ids.each { |id| @call.call('DELETE', "/device-credentials/#{ERB::Util.url_encode(id)}", allow_404: true) }
487
+ ids.each { |id| call.call('DELETE', "/device-credentials/#{ERB::Util.url_encode(id)}", allow_404: true) }
419
488
  end
420
489
  end
421
490
 
@@ -95,9 +95,9 @@ module NoResponse
95
95
 
96
96
  @language = "ruby"
97
97
  @openapi_doc_version = "1"
98
- @sdk_version = "3.0.0"
98
+ @sdk_version = "3.0.2"
99
99
  @gen_version = "internal"
100
- @user_agent = "noresponse-sdk/ruby 3.0.0 internal 1 noresponse"
100
+ @user_agent = "noresponse-sdk/ruby 3.0.2 internal 1 noresponse"
101
101
  end
102
102
 
103
103
  sig { returns([String, T::Hash[Symbol, String]]) }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: noresponse
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - NoResponse