From a1518cee516fc61457d2a71e12c6745d28632057 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Mon, 27 Jul 2026 14:34:56 -0400 Subject: [PATCH 1/2] Expose rate limit data via optional block on API responses Adds an optional block to API methods that yields the parsed response body and the rate limit information (x-ratelimit-limit / -remaining / -reset) from the response headers, without changing the return value for existing callers. This lets consumers monitor how close they are to Auth0's rate limits without monkey-patching. - Add Auth0::RateLimit value object built from response headers - Thread an optional block through the HTTPProxy request path and yield (response_body, rate_limit) on successful responses - Support the block on Users#user_sessions per the requested example - Add unit tests and an EXAMPLES.md usage section Closes #606 --- EXAMPLES.md | 23 +++++++++ lib/auth0/api/v2/users.rb | 7 ++- lib/auth0/mixins.rb | 1 + lib/auth0/mixins/httpproxy.rb | 15 ++++-- lib/auth0/mixins/rate_limit_struct.rb | 44 +++++++++++++++++ spec/lib/auth0/api/v2/users_spec.rb | 13 +++++ spec/lib/auth0/mixins/httpproxy_spec.rb | 46 +++++++++++++++++ .../auth0/mixins/rate_limit_struct_spec.rb | 49 +++++++++++++++++++ 8 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 lib/auth0/mixins/rate_limit_struct.rb create mode 100644 spec/lib/auth0/mixins/rate_limit_struct_spec.rb diff --git a/EXAMPLES.md b/EXAMPLES.md index fe5f2fd9..981421d2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -220,3 +220,26 @@ 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 diff --git a/lib/auth0/api/v2/users.rb b/lib/auth0/api/v2/users.rb index 7e76f620..0fc9518e 100644 --- a/lib/auth0/api/v2/users.rb +++ b/lib/auth0/api/v2/users.rb @@ -458,11 +458,14 @@ 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 return value is unchanged. # @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..d1c9744c 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,9 +69,9 @@ def safe_parse_json(body) body end - def request_with_retry(method, uri, body = {}, extra_headers = {}) + def request_with_retry(method, uri, body = {}, extra_headers = {}, &block) Retryable.retryable(retry_options) do - request(method, uri, body, extra_headers) + request(method, uri, body, extra_headers, &block) end end @@ -101,7 +101,12 @@ 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) + # Optionally expose rate limit information from the response headers + # without changing the return value for existing callers. + yield(body, 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..a2891d13 --- /dev/null +++ b/lib/auth0/mixins/rate_limit_struct.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Auth0 + # 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 keys 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 headers yield `nil` values. + # + # @param headers [Hash, nil] the response headers + # @return [Auth0::RateLimit] + def self.from_headers(headers) + headers ||= {} + limit = header_value(headers, :x_ratelimit_limit) + remaining = header_value(headers, :x_ratelimit_remaining) + reset = header_value(headers, :x_ratelimit_reset) + + new( + limit&.to_i, + remaining&.to_i, + reset.nil? ? nil : Time.at(reset.to_i).utc + ) + end + + def self.header_value(headers, symbol_key) + headers[symbol_key] || + headers[symbol_key.to_s] || + headers[symbol_key.to_s.tr('_', '-')] + end + private_class_method :header_value + end +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..05af34c2 100644 --- a/spec/lib/auth0/mixins/httpproxy_spec.rb +++ b/spec/lib/auth0/mixins/httpproxy_spec.rb @@ -657,3 +657,49 @@ 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 +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..7fcf7f87 --- /dev/null +++ b/spec/lib/auth0/mixins/rate_limit_struct_spec.rb @@ -0,0 +1,49 @@ +# 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 '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 From 1943366820dbf4f840f73a1619b772bc5c71a2f8 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Fri, 31 Jul 2026 16:31:14 -0400 Subject: [PATCH 2/2] Address review feedback on rate limit callback - Invoke the caller's block outside the Retryable scope so a block exception can never be treated as a failed request and replay the call - Normalize header keys (case-insensitive, dash/underscore agnostic) so canonically-cased headers are matched - Parse integer headers with Integer(_, exception: false) so blank/non-numeric values become nil instead of a misleading 0 - Define Auth0::RateLimit at top level to match the sibling struct style - Document that error responses (incl. 429) expose rate-limit data on the raised exception via #headers / #reset - Add tests: mixed-case and snake_case string headers, remaining "0" -> 0, blank/non-numeric -> nil, and no retry when the callback raises --- EXAMPLES.md | 12 ++- lib/auth0/api/v2/users.rb | 5 +- lib/auth0/mixins/httpproxy.rb | 20 +++-- lib/auth0/mixins/rate_limit_struct.rb | 80 +++++++++++-------- spec/lib/auth0/mixins/httpproxy_spec.rb | 12 +++ .../auth0/mixins/rate_limit_struct_spec.rb | 42 ++++++++++ 6 files changed, 129 insertions(+), 42 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 981421d2..a3a1b666 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -242,4 +242,14 @@ 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 +* `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 0fc9518e..ece8f28c 100644 --- a/lib/auth0/api/v2/users.rb +++ b/lib/auth0/api/v2/users.rb @@ -460,7 +460,10 @@ def delete_user_sessions(user_id) # @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 return value is unchanged. + # `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, &block) raise Auth0::MissingUserId, 'Must supply a valid user_id' if user_id.to_s.empty? diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index d1c9744c..1bb0e753 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -70,9 +70,17 @@ def safe_parse_json(body) end def request_with_retry(method, uri, body = {}, extra_headers = {}, &block) - Retryable.retryable(retry_options) do - request(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 = {}) @@ -103,9 +111,11 @@ def request(method, uri, body = {}, extra_headers = {}) case result.code when 200...226 body = safe_parse_json(result.body) - # Optionally expose rate limit information from the response headers - # without changing the return value for existing callers. - yield(body, Auth0::RateLimit.from_headers(result.headers)) if block_given? + # 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) diff --git a/lib/auth0/mixins/rate_limit_struct.rb b/lib/auth0/mixins/rate_limit_struct.rb index a2891d13..0c93ad78 100644 --- a/lib/auth0/mixins/rate_limit_struct.rb +++ b/lib/auth0/mixins/rate_limit_struct.rb @@ -1,44 +1,54 @@ # frozen_string_literal: true -module Auth0 - # Rate limit information parsed from the `x-ratelimit-*` headers Auth0 returns - # on Management API responses. +# 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. # - # @see https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy + # 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`. # - # @!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 keys 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 headers yield `nil` values. - # - # @param headers [Hash, nil] the response headers - # @return [Auth0::RateLimit] - def self.from_headers(headers) - headers ||= {} - limit = header_value(headers, :x_ratelimit_limit) - remaining = header_value(headers, :x_ratelimit_remaining) - reset = header_value(headers, :x_ratelimit_reset) + # @param headers [Hash, nil] the response headers + # @return [Auth0::RateLimit] + def self.from_headers(headers) + normalized = normalize_keys(headers || {}) - new( - limit&.to_i, - remaining&.to_i, - reset.nil? ? nil : Time.at(reset.to_i).utc - ) - end + new( + to_integer(normalized['x_ratelimit_limit']), + to_integer(normalized['x_ratelimit_remaining']), + to_reset(normalized['x_ratelimit_reset']) + ) + end - def self.header_value(headers, symbol_key) - headers[symbol_key] || - headers[symbol_key.to_s] || - headers[symbol_key.to_s.tr('_', '-')] + # 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 - private_class_method :header_value 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/mixins/httpproxy_spec.rb b/spec/lib/auth0/mixins/httpproxy_spec.rb index 05af34c2..5f710c90 100644 --- a/spec/lib/auth0/mixins/httpproxy_spec.rb +++ b/spec/lib/auth0/mixins/httpproxy_spec.rb @@ -702,4 +702,16 @@ def expected_payload(method, overrides = {}) 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 index 7fcf7f87..63c15cca 100644 --- a/spec/lib/auth0/mixins/rate_limit_struct_spec.rb +++ b/spec/lib/auth0/mixins/rate_limit_struct_spec.rb @@ -30,6 +30,48 @@ 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({})