diff --git a/EXAMPLES.md b/EXAMPLES.md index fe5f2fd9..a3a1b666 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -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 +``` diff --git a/lib/auth0/api/v2/users.rb b/lib/auth0/api/v2/users.rb index 7e76f620..ece8f28c 100644 --- a/lib/auth0/api/v2/users.rb +++ b/lib/auth0/api/v2/users.rb @@ -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. diff --git a/lib/auth0/mixins.rb b/lib/auth0/mixins.rb index e6b40f30..260ab185 100644 --- a/lib/auth0/mixins.rb +++ b/lib/auth0/mixins.rb @@ -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' diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index acd05265..1bb0e753 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -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 @@ -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 = {}) @@ -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) diff --git a/lib/auth0/mixins/rate_limit_struct.rb b/lib/auth0/mixins/rate_limit_struct.rb new file mode 100644 index 00000000..0c93ad78 --- /dev/null +++ b/lib/auth0/mixins/rate_limit_struct.rb @@ -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 diff --git a/spec/lib/auth0/api/v2/users_spec.rb b/spec/lib/auth0/api/v2/users_spec.rb index 95deb0bd..aefe3dbd 100644 --- a/spec/lib/auth0/api/v2/users_spec.rb +++ b/spec/lib/auth0/api/v2/users_spec.rb @@ -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 diff --git a/spec/lib/auth0/mixins/httpproxy_spec.rb b/spec/lib/auth0/mixins/httpproxy_spec.rb index 5a45ff06..5f710c90 100644 --- a/spec/lib/auth0/mixins/httpproxy_spec.rb +++ b/spec/lib/auth0/mixins/httpproxy_spec.rb @@ -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 diff --git a/spec/lib/auth0/mixins/rate_limit_struct_spec.rb b/spec/lib/auth0/mixins/rate_limit_struct_spec.rb new file mode 100644 index 00000000..63c15cca --- /dev/null +++ b/spec/lib/auth0/mixins/rate_limit_struct_spec.rb @@ -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