Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,36 @@ Some notes:
* If both `client_secret` and `client_assertion_signing_key` are specified, `client_assertion_signing_key` takes precedence
* `client_assertion_signing_alg` is optional and defaults to `RS256` if omitted
* Only `RS256`, `RS384` and `PS256` algorithms are supported by Auth0 currently

## Read rate limit information from a response

Auth0 returns `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` headers on Management API responses. Pass an optional block to a supported endpoint to inspect this data without changing the method's return value. This is useful for monitoring how close you are to the rate limit.

```ruby
auth0_client = Auth0Client.new(
client_id: 'YOUR_CLIENT_ID',
token: 'YOUR_API_V2_TOKEN',
domain: 'YOUR_DOMAIN'
)

# The return value is unchanged; the block is optional.
sessions = auth0_client.user_sessions('auth0|USER_ID') do |response, rate_limit|
Rails.logger.info("Auth0 rate limit remaining: #{rate_limit.remaining}/#{rate_limit.limit}")
Rails.logger.info("Auth0 rate limit resets at: #{rate_limit.reset}") # a UTC Time
end
```

The block receives:

* `response` — the parsed response body (the same value the method returns)
* `rate_limit` — an `Auth0::RateLimit` with `limit` (Integer), `remaining` (Integer), and `reset` (UTC `Time`); fields are `nil` when the corresponding header is absent or non-numeric

The block is only invoked on a successful response. On an error response — including a `429` (`Auth0::RateLimitEncountered`) — the rate-limit headers remain available on the raised exception via `#headers`, and the reset time via `#reset`:

```ruby
begin
auth0_client.user_sessions('auth0|USER_ID')
rescue Auth0::RateLimitEncountered => e
Rails.logger.warn("Auth0 rate limited; retry after #{e.reset}") # a UTC Time
end
```
10 changes: 8 additions & 2 deletions lib/auth0/api/v2/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,17 @@ def delete_user_sessions(user_id)
# Retrieve details for a user's sessions.
#
# @param user_id [string] The user ID
# @yield [response, rate_limit] optional block yielded with the parsed
# response body and an {Auth0::RateLimit} built from the response's
# `x-ratelimit-*` headers. The block is only invoked on a successful
# response and the return value is unchanged. On an error response
# (including a 429), the rate-limit headers remain available on the
# raised exception via its `#headers` and, for 429s, `#reset`.
# @see https://auth0.com/docs/api/management/v2/users/get-sessions-for-user
def user_sessions(user_id)
def user_sessions(user_id, &block)
raise Auth0::MissingUserId, 'Must supply a valid user_id' if user_id.to_s.empty?

get "#{users_path}/#{user_id}/sessions"
get "#{users_path}/#{user_id}/sessions", &block
end

# Retrieve details for a user's refresh tokens.
Expand Down
1 change: 1 addition & 0 deletions lib/auth0/mixins.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require 'auth0/mixins/access_token_struct'
require 'auth0/mixins/api_token_struct'
require 'auth0/mixins/rate_limit_struct'
require 'auth0/mixins/headers'
require 'auth0/mixins/httpproxy'
require 'auth0/mixins/initializer'
Expand Down
27 changes: 21 additions & 6 deletions lib/auth0/mixins/httpproxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ module HTTPProxy

# proxying requests from instance methods to HTTP class methods
%i[get post post_file post_form put patch delete delete_with_body].each do |method|
define_method(method) do |uri, body = {}, extra_headers = {}|
define_method(method) do |uri, body = {}, extra_headers = {}, &block|
body = safe_merge_body(body, extra_headers)
token = get_token
authorization_header(token) unless token.nil?
request_with_retry(method, uri, body, extra_headers)
request_with_retry(method, uri, body, extra_headers, &block)
end
end

Expand Down Expand Up @@ -69,10 +69,18 @@ def safe_parse_json(body)
body
end

def request_with_retry(method, uri, body = {}, extra_headers = {})
Retryable.retryable(retry_options) do
request(method, uri, body, extra_headers)
def request_with_retry(method, uri, body = {}, extra_headers = {}, &block)
return Retryable.retryable(retry_options) { request(method, uri, body, extra_headers) } unless block

# Capture the rate limit inside the retry loop but invoke the caller's
# block only after retries complete, so an exception raised by the block
# can never be mistaken for a failed request and trigger a retry.
rate_limit = nil
result = Retryable.retryable(retry_options) do
request(method, uri, body, extra_headers) { |limit| rate_limit = limit }
end
block.call(result, rate_limit)
result
end

def request(method, uri, body = {}, extra_headers = {})
Expand Down Expand Up @@ -101,7 +109,14 @@ def request(method, uri, body = {}, extra_headers = {})
end

case result.code
when 200...226 then safe_parse_json(result.body)
when 200...226
body = safe_parse_json(result.body)
# Expose rate limit information from the response headers without
# changing the return value for existing callers. The block here is an
# internal capture; the caller's block is invoked in request_with_retry
# outside the retry scope.
yield(Auth0::RateLimit.from_headers(result.headers)) if block_given?
body
when 400 then raise Auth0::BadRequest.new(result.body, code: result.code, headers: result.headers)
when 401 then raise Auth0::Unauthorized.new(result.body, code: result.code, headers: result.headers)
when 403 then raise Auth0::AccessDenied.new(result.body, code: result.code, headers: result.headers)
Expand Down
54 changes: 54 additions & 0 deletions lib/auth0/mixins/rate_limit_struct.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

# Rate limit information parsed from the `x-ratelimit-*` headers Auth0 returns
# on Management API responses.
#
# @see https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy
#
# @!attribute limit
# @return [Integer, nil] the maximum number of requests allowed in the current window
# @!attribute remaining
# @return [Integer, nil] the number of requests remaining in the current window
# @!attribute reset
# @return [Time, nil] the UTC time at which the current window resets
Auth0::RateLimit = Struct.new(:limit, :remaining, :reset) do
# Build a RateLimit from a response headers hash.
#
# Header names are matched case-insensitively and accept symbol, snake_case
# string, or dashed string forms (RestClient normalizes them to symbols such
# as `:x_ratelimit_limit`). Missing or non-numeric header values yield `nil`.
#
# @param headers [Hash, nil] the response headers
# @return [Auth0::RateLimit]
def self.from_headers(headers)
normalized = normalize_keys(headers || {})

new(
to_integer(normalized['x_ratelimit_limit']),
to_integer(normalized['x_ratelimit_remaining']),
to_reset(normalized['x_ratelimit_reset'])
)
end

# Normalize header keys to lower-case, underscore-separated strings so lookups
# are case-insensitive and agnostic to symbol/string and dash/underscore forms.
def self.normalize_keys(headers)
headers.each_with_object({}) do |(key, value), acc|
acc[key.to_s.downcase.tr('-', '_')] = value
end
end
private_class_method :normalize_keys

# Parse an integer header value, returning nil for blank or non-numeric input
# (so a malformed header is never silently reported as 0).
def self.to_integer(value)
Integer(value.to_s.strip, exception: false)
end
private_class_method :to_integer

def self.to_reset(value)
epoch = to_integer(value)
epoch.nil? ? nil : Time.at(epoch).utc
end
private_class_method :to_reset
end
13 changes: 13 additions & 0 deletions spec/lib/auth0/api/v2/users_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,19 @@
@instance.user_sessions('USER_ID')
end.not_to raise_error
end

it 'is expected to forward an optional block through to the request' do
rate_limit = Auth0::RateLimit.from_headers(x_ratelimit_remaining: '5')
allow(@instance).to receive(:get).with('/api/v2/users/USER_ID/sessions') do |_path, &block|
block&.call({ 'sessions' => [] }, rate_limit)
{ 'sessions' => [] }
end

yielded = nil
@instance.user_sessions('USER_ID') { |body, rl| yielded = [body, rl] }

expect(yielded).to eq([{ 'sessions' => [] }, rate_limit])
end
end

context '.user_refresh_tokens' do
Expand Down
58 changes: 58 additions & 0 deletions spec/lib/auth0/mixins/httpproxy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -657,3 +657,61 @@ def expected_payload(method, overrides = {})
end
end
end

describe Auth0::Mixins::HTTPProxy, 'rate limit block' do
before :each do
@instance = DummyClassForProxy.new
@instance.extend(Auth0::Mixins::HTTPProxy)
@instance.base_uri = 'https://auth0.com'
@instance.retry_count = 0
end

it 'yields the parsed body and rate limit info from the response headers on success' do
allow(RestClient::Request).to receive(:execute).and_return(
StubResponse.new(
{ 'foo' => 'bar' }.to_json,
true,
200,
{
x_ratelimit_limit: '100',
x_ratelimit_remaining: '42',
x_ratelimit_reset: '1724000000'
}
)
)

yielded_body = nil
yielded_rate_limit = nil
result = @instance.get('/test') do |body, rate_limit|
yielded_body = body
yielded_rate_limit = rate_limit
end

expect(result).to eq('foo' => 'bar')
expect(yielded_body).to eq('foo' => 'bar')
expect(yielded_rate_limit).to be_a(Auth0::RateLimit)
expect(yielded_rate_limit.limit).to eq(100)
expect(yielded_rate_limit.remaining).to eq(42)
expect(yielded_rate_limit.reset).to eq(Time.at(1_724_000_000).utc)
end

it 'returns the response body unchanged and does not require a block' do
allow(RestClient::Request).to receive(:execute).and_return(
StubResponse.new({ 'foo' => 'bar' }.to_json, true, 200, {})
)

expect(@instance.get('/test')).to eq('foo' => 'bar')
end

it 'does not retry the request when the caller block raises' do
# The callback runs outside the retry scope, so an exception it raises must
# not be treated as a failed request (which would replay the call).
expect(RestClient::Request).to receive(:execute).once.and_return(
StubResponse.new({ 'foo' => 'bar' }.to_json, true, 200, {})
)

expect do
@instance.get('/test') { |_body, _rate_limit| raise 'callback error' }
end.to raise_error('callback error')
end
end
91 changes: 91 additions & 0 deletions spec/lib/auth0/mixins/rate_limit_struct_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

require 'spec_helper'

describe Auth0::RateLimit do
describe '.from_headers' do
let(:reset_epoch) { 1_724_000_000 }

it 'parses symbol header keys as RestClient returns them' do
rate_limit = described_class.from_headers(
x_ratelimit_limit: '100',
x_ratelimit_remaining: '99',
x_ratelimit_reset: reset_epoch.to_s
)

expect(rate_limit.limit).to eq(100)
expect(rate_limit.remaining).to eq(99)
expect(rate_limit.reset).to eq(Time.at(reset_epoch).utc)
end

it 'parses dashed string header keys' do
rate_limit = described_class.from_headers(
'x-ratelimit-limit' => '10',
'x-ratelimit-remaining' => '0',
'x-ratelimit-reset' => reset_epoch.to_s
)

expect(rate_limit.limit).to eq(10)
expect(rate_limit.remaining).to eq(0)
expect(rate_limit.reset).to eq(Time.at(reset_epoch).utc)
end

it 'parses snake_case string header keys' do
rate_limit = described_class.from_headers(
'x_ratelimit_limit' => '10',
'x_ratelimit_remaining' => '3',
'x_ratelimit_reset' => reset_epoch.to_s
)

expect(rate_limit.limit).to eq(10)
expect(rate_limit.remaining).to eq(3)
expect(rate_limit.reset).to eq(Time.at(reset_epoch).utc)
end

it 'matches header names case-insensitively' do
rate_limit = described_class.from_headers(
'X-RateLimit-Limit' => '10',
'X-RateLimit-Remaining' => '7',
'X-RateLimit-Reset' => reset_epoch.to_s
)

expect(rate_limit.limit).to eq(10)
expect(rate_limit.remaining).to eq(7)
expect(rate_limit.reset).to eq(Time.at(reset_epoch).utc)
end

it 'reports a remaining of 0 as an integer, not nil' do
rate_limit = described_class.from_headers(x_ratelimit_remaining: '0')

expect(rate_limit.remaining).to eq(0)
end

it 'treats blank or non-numeric values as nil rather than 0' do
rate_limit = described_class.from_headers(
x_ratelimit_limit: '',
x_ratelimit_remaining: 'not-a-number',
x_ratelimit_reset: ' '
)

expect(rate_limit.limit).to be_nil
expect(rate_limit.remaining).to be_nil
expect(rate_limit.reset).to be_nil
end

it 'returns nil fields when rate limit headers are absent' do
rate_limit = described_class.from_headers({})

expect(rate_limit.limit).to be_nil
expect(rate_limit.remaining).to be_nil
expect(rate_limit.reset).to be_nil
end

it 'handles nil headers' do
rate_limit = described_class.from_headers(nil)

expect(rate_limit.limit).to be_nil
expect(rate_limit.remaining).to be_nil
expect(rate_limit.reset).to be_nil
end
end
end