From df19130e5a84d304f06aaba3ba49562f16cf74e5 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 2 Sep 2026 09:39:24 -0400 Subject: [PATCH 1/7] Expose Management API rate limit info via a client callback (v6) Exposes Auth0's rate limit info (x-ratelimit-limit / -remaining / -reset) from every Management API response via an opt-in callback, so callers can monitor how close they are to the limit (per #606). - Add Auth0::Internal::Http::RateLimit (limit/remaining/reset; blank and non-numeric header values become nil rather than 0) - RawClient gains a rate_limit_handler, invoked in #send after retries on every response; handler errors are swallowed so they can't break a request. #send's return value is unchanged, so generated callers are unaffected. - Wire it through the custom client: Auth0::Client.new(management_rate_limit_handler:) attaches the handler to the management raw client - Unit tests for RateLimit, RawClient#send handler behavior, and client wiring All changes live in fernignored files, so they survive regeneration. Refs #606. --- lib/auth0/auth_client.rb | 15 +++ lib/auth0/internal/http/rate_limit.rb | 50 ++++++++++ lib/auth0/internal/http/raw_client.rb | 27 +++++- lib/auth0/mixins/initializer.rb | 1 + test/unit/internal/http/test_rate_limit.rb | 38 ++++++++ test/unit/internal/http/test_raw_client.rb | 102 +++++++++++++++++++++ test/unit/test_auth_client_rate_limit.rb | 24 +++++ 7 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 lib/auth0/internal/http/rate_limit.rb create mode 100644 test/unit/internal/http/test_rate_limit.rb create mode 100644 test/unit/internal/http/test_raw_client.rb create mode 100644 test/unit/test_auth_client_rate_limit.rb diff --git a/lib/auth0/auth_client.rb b/lib/auth0/auth_client.rb index 9f3cbde6f..dde11ea80 100644 --- a/lib/auth0/auth_client.rb +++ b/lib/auth0/auth_client.rb @@ -91,8 +91,23 @@ def management opts[:max_retries] = @management_max_retries if @management_max_retries opts[:headers] = @management_additional_headers if @management_additional_headers @_management = Auth0::Management.new(**opts) + attach_rate_limit_handler(@_management) end @_management end + + private + + # Attaches the configured rate limit handler to the management client's + # underlying raw client. Management is generated and builds its own raw + # client, so we set the handler on it after construction. + # @param management [Auth0::Management] + # @return [void] + def attach_rate_limit_handler(management) + return if @management_rate_limit_handler.nil? + + raw_client = management.instance_variable_get(:@raw_client) + raw_client.rate_limit_handler = @management_rate_limit_handler if raw_client + end end end diff --git a/lib/auth0/internal/http/rate_limit.rb b/lib/auth0/internal/http/rate_limit.rb new file mode 100644 index 000000000..6030bab48 --- /dev/null +++ b/lib/auth0/internal/http/rate_limit.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Auth0 + module Internal + module Http + # 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 + class RateLimit + # @return [Integer, nil] the maximum number of requests allowed in the current window + attr_reader :limit + # @return [Integer, nil] the number of requests remaining in the current window + attr_reader :remaining + # @return [Time, nil] the UTC time at which the current window resets + attr_reader :reset + + # @param limit [Integer, nil] + # @param remaining [Integer, nil] + # @param reset [Time, nil] + def initialize(limit:, remaining:, reset:) + @limit = limit + @remaining = remaining + @reset = reset + end + + # Build a RateLimit from an HTTP response. Header lookups are + # case-insensitive (delegated to the response), and missing or + # non-numeric values become nil rather than a misleading 0. + # + # @param response [Net::HTTPResponse] anything responding to `[]` with header access + # @return [Auth0::Internal::Http::RateLimit] + def self.from_response(response) + reset = to_integer(response["x-ratelimit-reset"]) + + new( + limit: to_integer(response["x-ratelimit-limit"]), + remaining: to_integer(response["x-ratelimit-remaining"]), + reset: reset.nil? ? nil : Time.at(reset).utc + ) + end + + def self.to_integer(value) + Integer(value.to_s.strip, exception: false) + end + private_class_method :to_integer + end + end + end +end diff --git a/lib/auth0/internal/http/raw_client.rb b/lib/auth0/internal/http/raw_client.rb index 0de6d27cb..bc2e222cf 100644 --- a/lib/auth0/internal/http/raw_client.rb +++ b/lib/auth0/internal/http/raw_client.rb @@ -3,6 +3,8 @@ # This file was auto-generated by Fern from our API Definition. # Modified by Auth0 to use Auth0 telemetry format with dynamic versioning. +require_relative "rate_limit" + module Auth0 module Internal module Http @@ -20,14 +22,21 @@ class RawClient # @return [String] The base URL for requests attr_reader :base_url + # @return [#call, nil] Optional callback invoked with an + # {Auth0::Internal::Http::RateLimit} after every response. + attr_accessor :rate_limit_handler + # @param base_url [String] The base url for the request. # @param max_retries [Integer] The number of times to retry a failed request, defaults to 2. # @param timeout [Float] The timeout for the request, defaults to 60.0 seconds. # @param headers [Hash] The headers for the request. - def initialize(base_url:, max_retries: 2, timeout: 60.0, headers: {}) + # @param rate_limit_handler [#call, nil] Optional callback invoked with the + # parsed rate limit (from the `x-ratelimit-*` headers) after every response. + def initialize(base_url:, max_retries: 2, timeout: 60.0, headers: {}, rate_limit_handler: nil) @base_url = base_url @max_retries = max_retries @timeout = timeout + @rate_limit_handler = rate_limit_handler # Auth0 telemetry in standard format telemetry = { @@ -45,7 +54,7 @@ def initialize(base_url:, max_retries: 2, timeout: 60.0, headers: {}) end # @param request [Auth0::Internal::Http::BaseRequest] The HTTP request. - # @return [HTTP::Response] The HTTP response. + # @return [Net::HTTPResponse] The HTTP response. def send(request) url = build_url(request) attempt = 0 @@ -74,9 +83,23 @@ def send(request) attempt += 1 end + notify_rate_limit(response) response end + # Invokes the rate limit handler with the rate limit parsed from the + # response headers. Runs after retries, on every response. A handler + # error must never break the request, so it is swallowed. + # @param response [Net::HTTPResponse] The HTTP response. + # @return [void] + def notify_rate_limit(response) + return if @rate_limit_handler.nil? + + @rate_limit_handler.call(RateLimit.from_response(response)) + rescue StandardError + nil + end + # Determines if a request should be retried based on the response status code. # @param response [Net::HTTPResponse] The HTTP response. # @param attempt [Integer] The current retry attempt (0-indexed). diff --git a/lib/auth0/mixins/initializer.rb b/lib/auth0/mixins/initializer.rb index a37ac88a2..9885738e8 100644 --- a/lib/auth0/mixins/initializer.rb +++ b/lib/auth0/mixins/initializer.rb @@ -21,6 +21,7 @@ def initialize(config) @management_timeout = options[:management_timeout] @management_max_retries = options[:management_max_retries] @management_additional_headers = options[:management_additional_headers] + @management_rate_limit_handler = options[:management_rate_limit_handler] extend Auth0::Api::AuthenticationEndpoints @client_id = options[:client_id] diff --git a/test/unit/internal/http/test_rate_limit.rb b/test/unit/internal/http/test_rate_limit.rb new file mode 100644 index 000000000..eacfdaa2e --- /dev/null +++ b/test/unit/internal/http/test_rate_limit.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" + +describe Auth0::Internal::Http::RateLimit do + RateLimit = Auth0::Internal::Http::RateLimit + + describe ".from_response" do + it "parses the x-ratelimit-* headers" do + response = { + "x-ratelimit-limit" => "100", + "x-ratelimit-remaining" => "42", + "x-ratelimit-reset" => "1724000000" + } + + rate_limit = RateLimit.from_response(response) + + _(rate_limit.limit).must_equal 100 + _(rate_limit.remaining).must_equal 42 + _(rate_limit.reset).must_equal Time.at(1_724_000_000).utc + end + + it "reports a remaining of 0 as an integer, not nil" do + _(RateLimit.from_response("x-ratelimit-remaining" => "0").remaining).must_equal 0 + end + + it "returns nil for missing or non-numeric values instead of a misleading 0" do + rate_limit = RateLimit.from_response( + "x-ratelimit-limit" => "", + "x-ratelimit-remaining" => "not-a-number" + ) + + _(rate_limit.limit).must_be_nil + _(rate_limit.remaining).must_be_nil + _(rate_limit.reset).must_be_nil + end + end +end diff --git a/test/unit/internal/http/test_raw_client.rb b/test/unit/internal/http/test_raw_client.rb new file mode 100644 index 000000000..e107887c6 --- /dev/null +++ b/test/unit/internal/http/test_raw_client.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "test_helper" + +describe Auth0::Internal::Http::RawClient do + module TestRawClient + # Minimal stand-in for a Net::HTTPResponse. + class FakeHttpResponse + def initialize(code:, body:, headers:) + @code = code + @body = body + @headers = headers + end + + attr_reader :code, :body + + def [](name) + @headers[name] + end + end + + # Minimal stand-in for the Net::HTTP connection. + class FakeConnection + def initialize(response) + @response = response + end + + def open_timeout=(_); end + def read_timeout=(_); end + def write_timeout=(_); end + def continue_timeout=(_); end + + def request(_http_request) + @response + end + end + + def self.build_request + Auth0::Internal::JSON::Request.new( + base_url: nil, + method: "GET", + path: "users", + query: {}, + request_options: {} + ) + end + + def self.build_response + FakeHttpResponse.new( + code: "200", + body: "{}", + headers: { + "x-ratelimit-limit" => "100", + "x-ratelimit-remaining" => "12", + "x-ratelimit-reset" => "1724000000" + } + ) + end + end + + def send_with(client, response) + client.stub(:connect, TestRawClient::FakeConnection.new(response)) do + client.send(TestRawClient.build_request) + end + end + + it "invokes the rate limit handler with the parsed rate limit and returns the response unchanged" do + captured = nil + client = Auth0::Internal::Http::RawClient.new( + base_url: "https://tenant.auth0.com", + max_retries: 0, + rate_limit_handler: ->(rate_limit) { captured = rate_limit } + ) + response = TestRawClient.build_response + + result = send_with(client, response) + + _(result).must_be_same_as response + _(captured).must_be_instance_of Auth0::Internal::Http::RateLimit + _(captured.limit).must_equal 100 + _(captured.remaining).must_equal 12 + _(captured.reset).must_equal Time.at(1_724_000_000).utc + end + + it "returns the response unchanged when no handler is configured" do + client = Auth0::Internal::Http::RawClient.new(base_url: "https://tenant.auth0.com", max_retries: 0) + response = TestRawClient.build_response + + _(send_with(client, response)).must_be_same_as response + end + + it "does not let a handler error break the request" do + client = Auth0::Internal::Http::RawClient.new( + base_url: "https://tenant.auth0.com", + max_retries: 0, + rate_limit_handler: ->(_rate_limit) { raise "boom" } + ) + response = TestRawClient.build_response + + _(send_with(client, response)).must_be_same_as response + end +end diff --git a/test/unit/test_auth_client_rate_limit.rb b/test/unit/test_auth_client_rate_limit.rb new file mode 100644 index 000000000..e2827a0b3 --- /dev/null +++ b/test/unit/test_auth_client_rate_limit.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "test_helper" + +describe Auth0::Client do + def build_client(**extra) + Auth0::Client.new(domain: "tenant.auth0.com", token: "test-token", **extra) + end + + it "attaches the configured management_rate_limit_handler to the management raw client" do + handler = ->(_rate_limit) {} + client = build_client(management_rate_limit_handler: handler) + + raw_client = client.management.instance_variable_get(:@raw_client) + + _(raw_client.rate_limit_handler).must_be_same_as handler + end + + it "leaves the handler unset when none is configured" do + raw_client = build_client.management.instance_variable_get(:@raw_client) + + _(raw_client.rate_limit_handler).must_be_nil + end +end From d42e861b2d14650b94e6f04ec61dbf026d6294df Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Mon, 5 Oct 2026 09:46:08 -0400 Subject: [PATCH 2/7] Address review feedback on rate limit handler - Notify on every response (inside the retry loop) so intermediate 429s reach the handler, not just the final response - Fail loud if the handler can't be attached to the management raw client (raise) instead of silently no-opping if internals drift - Warn (but still swallow) when a handler raises, so broken monitoring is visible - Extend support to the Authentication API path (HTTPProxy), via a single rate_limit_handler option covering both APIs - RateLimit gains from_http_response (Net::HTTP) and from_headers (RestClient) builders; reset/whitespace parsing pinned - Tests: retried-request notification, token-rebuild re-attach, case-insensitive header lookup, reset garbage/whitespace, Authentication-path handler - Docs: README options row + Rate Limit Monitoring section, EXAMPLES snippet --- EXAMPLES.md | 20 ++++ README.md | 18 ++++ lib/auth0/auth_client.rb | 14 ++- lib/auth0/internal/http/rate_limit.rb | 51 ++++++++-- lib/auth0/internal/http/raw_client.rb | 15 +-- lib/auth0/mixins/httpproxy.rb | 18 +++- lib/auth0/mixins/initializer.rb | 2 +- test/unit/internal/http/test_rate_limit.rb | 66 ++++++++++--- test/unit/internal/http/test_raw_client.rb | 97 +++++++++++-------- test/unit/mixins/test_httpproxy_rate_limit.rb | 56 +++++++++++ test/unit/test_auth_client_rate_limit.rb | 25 +++-- 11 files changed, 303 insertions(+), 79 deletions(-) create mode 100644 test/unit/mixins/test_httpproxy_rate_limit.rb diff --git a/EXAMPLES.md b/EXAMPLES.md index b2bef2093..9413449dc 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -166,6 +166,26 @@ management.roles.create(name: 'admin', description: 'Administrator') management.organizations.list(take: 20) ``` +### Monitoring rate limits + +Pass a `rate_limit_handler` to observe Auth0's `x-ratelimit-*` headers after every Management and Authentication API response, without changing the return value of your calls: + +```ruby +client = Auth0::Client.new( + domain: ENV['AUTH0_RUBY_DOMAIN'], + client_id: ENV['AUTH0_RUBY_CLIENT_ID'], + client_secret: ENV['AUTH0_RUBY_CLIENT_SECRET'], + rate_limit_handler: lambda do |rate_limit| + # rate_limit.limit / rate_limit.remaining (Integers), rate_limit.reset (UTC Time) + Rails.logger.info("Auth0 rate limit: #{rate_limit.remaining}/#{rate_limit.limit}, resets at #{rate_limit.reset}") + end +) + +client.users.get(id: 'auth0|123456') # handler fires with this response's rate limit +``` + +The handler is invoked on every response, including the `429`s that trigger an automatic retry. An error raised inside the handler is caught (and warned) so it never breaks the request. + ## Organizations [Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications. diff --git a/README.md b/README.md index acb584c1d..71ced28d8 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ client = Auth0::Client.new( | `management_timeout` | Float | Timeout in seconds for Management API calls. | `60` | | `management_max_retries` | Integer | Maximum retries for Management API calls. | `2` | | `management_additional_headers` | Hash | Additional HTTP headers for Management API calls. | `nil` | +| `rate_limit_handler` | `#call` | Callback invoked with an `Auth0::Internal::Http::RateLimit` after every Management and Authentication API response, for monitoring rate-limit headroom. See [Rate Limit Monitoring](#rate-limit-monitoring). | `nil` | #### Accessing the Management Client Directly @@ -283,6 +284,23 @@ client.users.list( ) ``` +### Rate Limit Monitoring + +Auth0 returns `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` headers on API responses. Pass a `rate_limit_handler` to be notified of this data after every Management **and** Authentication API response, so you can monitor how close you are to the limit (for example, emit a metric and alert before you run out): + +```ruby +client = Auth0::Client.new( + domain: ENV['AUTH0_RUBY_DOMAIN'], + client_id: ENV['AUTH0_RUBY_CLIENT_ID'], + client_secret: ENV['AUTH0_RUBY_CLIENT_SECRET'], + rate_limit_handler: lambda do |rate_limit| + StatsD.gauge('auth0.rate_limit.remaining', rate_limit.remaining) if rate_limit.remaining + end +) +``` + +The handler receives an `Auth0::Internal::Http::RateLimit` with `#limit`, `#remaining` (Integers), and `#reset` (a UTC `Time`); each is `nil` when the corresponding header is absent or non-numeric. It is invoked on every response — including the `429`s that trigger an automatic retry — so you can observe the point at which the limit was reached. The return value of your API call is unchanged, and an exception raised inside the handler is caught (and warned) so it can never break a request. + ### Errors Management API errors use the `Auth0::Errors` namespace: diff --git a/lib/auth0/auth_client.rb b/lib/auth0/auth_client.rb index dde11ea80..26de51128 100644 --- a/lib/auth0/auth_client.rb +++ b/lib/auth0/auth_client.rb @@ -101,13 +101,23 @@ def management # Attaches the configured rate limit handler to the management client's # underlying raw client. Management is generated and builds its own raw # client, so we set the handler on it after construction. + # + # Fails loudly if the raw client can't be found: Management is regenerated + # and could rename/restructure `@raw_client`, and silently skipping would + # turn the feature off with no signal. # @param management [Auth0::Management] # @return [void] def attach_rate_limit_handler(management) - return if @management_rate_limit_handler.nil? + return if @rate_limit_handler.nil? raw_client = management.instance_variable_get(:@raw_client) - raw_client.rate_limit_handler = @management_rate_limit_handler if raw_client + unless raw_client.respond_to?(:rate_limit_handler=) + raise Auth0::Unsupported, + "Unable to attach rate_limit_handler: the management client does not expose a compatible raw client. " \ + "This usually means the ruby-auth0 internals changed; please report it." + end + + raw_client.rate_limit_handler = @rate_limit_handler end end end diff --git a/lib/auth0/internal/http/rate_limit.rb b/lib/auth0/internal/http/rate_limit.rb index 6030bab48..83528b27a 100644 --- a/lib/auth0/internal/http/rate_limit.rb +++ b/lib/auth0/internal/http/rate_limit.rb @@ -4,7 +4,7 @@ module Auth0 module Internal module Http # Rate limit information parsed from the `x-ratelimit-*` headers Auth0 - # returns on Management API responses. + # returns on API responses. # # @see https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy class RateLimit @@ -24,22 +24,53 @@ def initialize(limit:, remaining:, reset:) @reset = reset end - # Build a RateLimit from an HTTP response. Header lookups are - # case-insensitive (delegated to the response), and missing or - # non-numeric values become nil rather than a misleading 0. + # Build from an object that exposes headers via `#[]` using the HTTP + # header name (e.g. a `Net::HTTPResponse`, whose `#[]` is + # case-insensitive). Used by the Management API (RawClient) path. # - # @param response [Net::HTTPResponse] anything responding to `[]` with header access + # @param response [#[]] responds to `[]` with header access # @return [Auth0::Internal::Http::RateLimit] - def self.from_response(response) - reset = to_integer(response["x-ratelimit-reset"]) + def self.from_http_response(response) + build( + response["x-ratelimit-limit"], + response["x-ratelimit-remaining"], + response["x-ratelimit-reset"] + ) + end + + # Build from a plain headers hash (e.g. RestClient's, whose keys are + # symbols like `:x_ratelimit_remaining`). Keys are matched + # case-insensitively and dash/underscore-agnostically. Used by the + # Authentication API (HTTPProxy) path. + # + # @param headers [Hash, nil] + # @return [Auth0::Internal::Http::RateLimit] + def self.from_headers(headers) + normalized = (headers || {}).each_with_object({}) do |(key, value), acc| + acc[key.to_s.downcase.tr("-", "_")] = value + end + + build( + normalized["x_ratelimit_limit"], + normalized["x_ratelimit_remaining"], + normalized["x_ratelimit_reset"] + ) + end + + # @return [Auth0::Internal::Http::RateLimit] + def self.build(limit, remaining, reset) + reset_epoch = to_integer(reset) new( - limit: to_integer(response["x-ratelimit-limit"]), - remaining: to_integer(response["x-ratelimit-remaining"]), - reset: reset.nil? ? nil : Time.at(reset).utc + limit: to_integer(limit), + remaining: to_integer(remaining), + reset: reset_epoch.nil? ? nil : Time.at(reset_epoch).utc ) end + private_class_method :build + # 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 diff --git a/lib/auth0/internal/http/raw_client.rb b/lib/auth0/internal/http/raw_client.rb index bc2e222cf..a1c7c8945 100644 --- a/lib/auth0/internal/http/raw_client.rb +++ b/lib/auth0/internal/http/raw_client.rb @@ -76,6 +76,10 @@ def send(request) response = conn.request(http_request) + # Notify on every response, including the 429s that trigger a retry, + # so a handler watching `remaining` sees the point where it ran out. + notify_rate_limit(response) + break unless should_retry?(response, attempt) delay = retry_delay(response, attempt) @@ -83,21 +87,20 @@ def send(request) attempt += 1 end - notify_rate_limit(response) response end # Invokes the rate limit handler with the rate limit parsed from the - # response headers. Runs after retries, on every response. A handler - # error must never break the request, so it is swallowed. + # response headers. A handler error must never break the request, so it + # is swallowed, but a warning is emitted so a broken handler is visible. # @param response [Net::HTTPResponse] The HTTP response. # @return [void] def notify_rate_limit(response) return if @rate_limit_handler.nil? - @rate_limit_handler.call(RateLimit.from_response(response)) - rescue StandardError - nil + @rate_limit_handler.call(RateLimit.from_http_response(response)) + rescue StandardError => e + warn "[auth0] rate_limit_handler raised #{e.class}: #{e.message}" end # Determines if a request should be retried based on the response status code. diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index f4446c47b..51ae0a23e 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -1,13 +1,14 @@ require "addressable/uri" require "retryable" require_relative "../exception.rb" +require_relative "../internal/http/rate_limit" module Auth0 module Mixins # here's the proxy for Rest calls based on rest-client, we're building all request on that gem # for now, if you want to feel free to use your own http client module HTTPProxy - attr_accessor :headers, :base_uri, :timeout, :retry_count + attr_accessor :headers, :base_uri, :timeout, :retry_count, :rate_limit_handler DEFAULT_RETRIES = 3 MAX_ALLOWED_RETRIES = 10 MAX_REQUEST_RETRY_JITTER = 250 @@ -95,6 +96,10 @@ def request(method, uri, body = {}, extra_headers = {}) call(method, encode_uri(uri), timeout, headers, body.to_json) end + # Notify on every response, including the 429s that trigger a retry, so + # a handler watching `remaining` sees the point where it ran out. + notify_rate_limit(result) + case result.code when 200...226 then safe_parse_json(result.body) when 400 then raise Auth0::BadRequest.new(result.body, code: result.code, headers: result.headers) @@ -107,6 +112,17 @@ def request(method, uri, body = {}, extra_headers = {}) end end + # Invokes the rate limit handler with the rate limit parsed from the + # response headers. A handler error must never break the request, so it is + # swallowed, but a warning is emitted so a broken handler is visible. + def notify_rate_limit(result) + return if @rate_limit_handler.nil? + + @rate_limit_handler.call(Auth0::Internal::Http::RateLimit.from_headers(result.headers)) + rescue StandardError => e + warn "[auth0] rate_limit_handler raised #{e.class}: #{e.message}" + end + def call(method, url, timeout, headers, body = nil) RestClient::Request.execute( method: method, diff --git a/lib/auth0/mixins/initializer.rb b/lib/auth0/mixins/initializer.rb index 9885738e8..386f2801d 100644 --- a/lib/auth0/mixins/initializer.rb +++ b/lib/auth0/mixins/initializer.rb @@ -21,7 +21,7 @@ def initialize(config) @management_timeout = options[:management_timeout] @management_max_retries = options[:management_max_retries] @management_additional_headers = options[:management_additional_headers] - @management_rate_limit_handler = options[:management_rate_limit_handler] + @rate_limit_handler = options[:rate_limit_handler] extend Auth0::Api::AuthenticationEndpoints @client_id = options[:client_id] diff --git a/test/unit/internal/http/test_rate_limit.rb b/test/unit/internal/http/test_rate_limit.rb index eacfdaa2e..a31fd6fef 100644 --- a/test/unit/internal/http/test_rate_limit.rb +++ b/test/unit/internal/http/test_rate_limit.rb @@ -3,31 +3,53 @@ require "test_helper" describe Auth0::Internal::Http::RateLimit do - RateLimit = Auth0::Internal::Http::RateLimit + module TestRateLimit + RateLimit = Auth0::Internal::Http::RateLimit - describe ".from_response" do - it "parses the x-ratelimit-* headers" do - response = { - "x-ratelimit-limit" => "100", - "x-ratelimit-remaining" => "42", - "x-ratelimit-reset" => "1724000000" - } + # Mimics Net::HTTPResponse#[], which is case-insensitive. + class CaseInsensitiveResponse + def initialize(headers) + @headers = headers.transform_keys { |k| k.to_s.downcase } + end - rate_limit = RateLimit.from_response(response) + def [](name) + @headers[name.to_s.downcase] + end + end + end + + describe ".from_headers" do + it "parses symbol keys as RestClient returns them" do + rate_limit = TestRateLimit::RateLimit.from_headers( + x_ratelimit_limit: "100", + x_ratelimit_remaining: "42", + x_ratelimit_reset: "1724000000" + ) _(rate_limit.limit).must_equal 100 _(rate_limit.remaining).must_equal 42 _(rate_limit.reset).must_equal Time.at(1_724_000_000).utc end + it "parses dashed and mixed-case string keys" do + rate_limit = TestRateLimit::RateLimit.from_headers("X-RateLimit-Remaining" => "7") + + _(rate_limit.remaining).must_equal 7 + end + it "reports a remaining of 0 as an integer, not nil" do - _(RateLimit.from_response("x-ratelimit-remaining" => "0").remaining).must_equal 0 + _(TestRateLimit::RateLimit.from_headers(x_ratelimit_remaining: "0").remaining).must_equal 0 + end + + it "trims surrounding whitespace" do + _(TestRateLimit::RateLimit.from_headers(x_ratelimit_limit: " 100 ").limit).must_equal 100 end - it "returns nil for missing or non-numeric values instead of a misleading 0" do - rate_limit = RateLimit.from_response( - "x-ratelimit-limit" => "", - "x-ratelimit-remaining" => "not-a-number" + it "treats blank or non-numeric values (including reset) as nil instead of a misleading 0" do + rate_limit = TestRateLimit::RateLimit.from_headers( + x_ratelimit_limit: "", + x_ratelimit_remaining: "not-a-number", + x_ratelimit_reset: "garbage" ) _(rate_limit.limit).must_be_nil @@ -35,4 +57,20 @@ _(rate_limit.reset).must_be_nil end end + + describe ".from_http_response" do + it "reads headers case-insensitively via the response's #[]" do + response = TestRateLimit::CaseInsensitiveResponse.new( + "X-RateLimit-Limit" => "100", + "X-RateLimit-Remaining" => "9", + "X-RateLimit-Reset" => "1724000000" + ) + + rate_limit = TestRateLimit::RateLimit.from_http_response(response) + + _(rate_limit.limit).must_equal 100 + _(rate_limit.remaining).must_equal 9 + _(rate_limit.reset).must_equal Time.at(1_724_000_000).utc + end + end end diff --git a/test/unit/internal/http/test_raw_client.rb b/test/unit/internal/http/test_raw_client.rb index e107887c6..e05c2c075 100644 --- a/test/unit/internal/http/test_raw_client.rb +++ b/test/unit/internal/http/test_raw_client.rb @@ -6,7 +6,7 @@ module TestRawClient # Minimal stand-in for a Net::HTTPResponse. class FakeHttpResponse - def initialize(code:, body:, headers:) + def initialize(code:, body: "{}", headers: {}) @code = code @body = body @headers = headers @@ -19,10 +19,10 @@ def [](name) end end - # Minimal stand-in for the Net::HTTP connection. + # Returns a queued response per call, mimicking a retried request. class FakeConnection - def initialize(response) - @response = response + def initialize(responses) + @responses = responses end def open_timeout=(_); end @@ -31,62 +31,83 @@ def write_timeout=(_); end def continue_timeout=(_); end def request(_http_request) - @response + @responses.shift end end - def self.build_request - Auth0::Internal::JSON::Request.new( - base_url: nil, - method: "GET", - path: "users", - query: {}, - request_options: {} - ) + def self.ok(remaining:) + FakeHttpResponse.new(code: "200", headers: rate_limit_headers(remaining)) + end + + def self.too_many(remaining:) + FakeHttpResponse.new(code: "429", headers: rate_limit_headers(remaining)) end - def self.build_response - FakeHttpResponse.new( - code: "200", - body: "{}", - headers: { - "x-ratelimit-limit" => "100", - "x-ratelimit-remaining" => "12", - "x-ratelimit-reset" => "1724000000" - } + def self.rate_limit_headers(remaining) + { + "x-ratelimit-limit" => "100", + "x-ratelimit-remaining" => remaining.to_s, + "x-ratelimit-reset" => "1724000000" + } + end + + def self.build_request + Auth0::Internal::JSON::Request.new( + base_url: nil, method: "GET", path: "users", query: {}, request_options: {} ) end end - def send_with(client, response) - client.stub(:connect, TestRawClient::FakeConnection.new(response)) do - client.send(TestRawClient.build_request) + def send_through(client, *responses) + client.stub(:sleep, nil) do + client.stub(:connect, TestRawClient::FakeConnection.new(responses)) do + client.send(TestRawClient.build_request) + end end end - it "invokes the rate limit handler with the parsed rate limit and returns the response unchanged" do - captured = nil + it "invokes the handler with the parsed rate limit and returns the response unchanged" do + captured = [] client = Auth0::Internal::Http::RawClient.new( base_url: "https://tenant.auth0.com", max_retries: 0, - rate_limit_handler: ->(rate_limit) { captured = rate_limit } + rate_limit_handler: ->(rate_limit) { captured << rate_limit } ) - response = TestRawClient.build_response + response = TestRawClient.ok(remaining: 12) - result = send_with(client, response) + result = send_through(client, response) _(result).must_be_same_as response - _(captured).must_be_instance_of Auth0::Internal::Http::RateLimit - _(captured.limit).must_equal 100 - _(captured.remaining).must_equal 12 - _(captured.reset).must_equal Time.at(1_724_000_000).utc + _(captured.length).must_equal 1 + _(captured.first.limit).must_equal 100 + _(captured.first.remaining).must_equal 12 + _(captured.first.reset).must_equal Time.at(1_724_000_000).utc + end + + it "notifies on every response across a retried request, including the 429s" do + seen = [] + client = Auth0::Internal::Http::RawClient.new( + base_url: "https://tenant.auth0.com", + max_retries: 2, + rate_limit_handler: ->(rate_limit) { seen << rate_limit.remaining } + ) + + result = send_through( + client, + TestRawClient.too_many(remaining: 1), + TestRawClient.too_many(remaining: 0), + TestRawClient.ok(remaining: 99) + ) + + _(result.code).must_equal "200" + _(seen).must_equal [1, 0, 99] end it "returns the response unchanged when no handler is configured" do client = Auth0::Internal::Http::RawClient.new(base_url: "https://tenant.auth0.com", max_retries: 0) - response = TestRawClient.build_response + response = TestRawClient.ok(remaining: 5) - _(send_with(client, response)).must_be_same_as response + _(send_through(client, response)).must_be_same_as response end it "does not let a handler error break the request" do @@ -95,8 +116,8 @@ def send_with(client, response) max_retries: 0, rate_limit_handler: ->(_rate_limit) { raise "boom" } ) - response = TestRawClient.build_response + response = TestRawClient.ok(remaining: 5) - _(send_with(client, response)).must_be_same_as response + _(send_through(client, response)).must_be_same_as response end end diff --git a/test/unit/mixins/test_httpproxy_rate_limit.rb b/test/unit/mixins/test_httpproxy_rate_limit.rb new file mode 100644 index 000000000..7a572babc --- /dev/null +++ b/test/unit/mixins/test_httpproxy_rate_limit.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "test_helper" + +describe Auth0::Mixins::HTTPProxy do + module TestHttpProxyRateLimit + # Minimal host that mixes in the Authentication API HTTP path. + class DummyProxy + include Auth0::Mixins::HTTPProxy + end + + # Mimics a RestClient::Response (symbol header keys, #code / #body). + FakeResponse = Struct.new(:code, :body, :headers) + end + + def build_proxy(handler) + proxy = TestHttpProxyRateLimit::DummyProxy.new + proxy.base_uri = "https://tenant.auth0.com" + proxy.rate_limit_handler = handler + proxy + end + + it "invokes the handler with the rate limit parsed from an authentication response" do + captured = nil + proxy = build_proxy(->(rate_limit) { captured = rate_limit }) + response = TestHttpProxyRateLimit::FakeResponse.new( + 200, + "{}", + { + x_ratelimit_limit: "100", + x_ratelimit_remaining: "7", + x_ratelimit_reset: "1724000000" + } + ) + + proxy.stub(:call, response) do + proxy.request(:get, "/userinfo") + end + + _(captured).must_be_instance_of Auth0::Internal::Http::RateLimit + _(captured.limit).must_equal 100 + _(captured.remaining).must_equal 7 + _(captured.reset).must_equal Time.at(1_724_000_000).utc + end + + it "does nothing when no handler is configured" do + proxy = build_proxy(nil) + response = TestHttpProxyRateLimit::FakeResponse.new(200, "{}", {}) + + result = proxy.stub(:call, response) do + proxy.request(:get, "/userinfo") + end + + _(result).must_equal({}) + end +end diff --git a/test/unit/test_auth_client_rate_limit.rb b/test/unit/test_auth_client_rate_limit.rb index e2827a0b3..4ec0dd377 100644 --- a/test/unit/test_auth_client_rate_limit.rb +++ b/test/unit/test_auth_client_rate_limit.rb @@ -7,18 +7,29 @@ def build_client(**extra) Auth0::Client.new(domain: "tenant.auth0.com", token: "test-token", **extra) end - it "attaches the configured management_rate_limit_handler to the management raw client" do - handler = ->(_rate_limit) {} - client = build_client(management_rate_limit_handler: handler) + def raw_client_for(client) + client.management.instance_variable_get(:@raw_client) + end - raw_client = client.management.instance_variable_get(:@raw_client) + it "attaches the configured rate_limit_handler to the management raw client" do + handler = ->(_rate_limit) {} + client = build_client(rate_limit_handler: handler) - _(raw_client.rate_limit_handler).must_be_same_as handler + _(raw_client_for(client).rate_limit_handler).must_be_same_as handler end it "leaves the handler unset when none is configured" do - raw_client = build_client.management.instance_variable_get(:@raw_client) + _(raw_client_for(build_client).rate_limit_handler).must_be_nil + end + + it "re-attaches the handler after a token change rebuilds the management client" do + handler = ->(_rate_limit) {} + client = build_client(rate_limit_handler: handler) + + first = client.stub(:get_token, "token-1") { client.management } + second = client.stub(:get_token, "token-2") { client.management } - _(raw_client.rate_limit_handler).must_be_nil + _(second).wont_be_same_as first + _(second.instance_variable_get(:@raw_client).rate_limit_handler).must_be_same_as handler end end From 01731745927acdef47839c57df6fc8bacb7fc04f Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 7 Oct 2026 17:48:25 -0600 Subject: [PATCH 3/7] Raise Auth0::Exception instead of Auth0::Unsupported for handler attach Auth0::Unsupported is part of the HTTP error family; a failure to attach the rate limit handler is a configuration/internals issue, not an HTTP failure, so a plain Auth0::Exception is a better fit. --- lib/auth0/auth_client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/auth0/auth_client.rb b/lib/auth0/auth_client.rb index 26de51128..d14b8dbd4 100644 --- a/lib/auth0/auth_client.rb +++ b/lib/auth0/auth_client.rb @@ -112,7 +112,7 @@ def attach_rate_limit_handler(management) raw_client = management.instance_variable_get(:@raw_client) unless raw_client.respond_to?(:rate_limit_handler=) - raise Auth0::Unsupported, + raise Auth0::Exception, "Unable to attach rate_limit_handler: the management client does not expose a compatible raw client. " \ "This usually means the ruby-auth0 internals changed; please report it." end From 76432faf0f2749a52fd88b8503bb830d4ca2585e Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 14 Oct 2026 10:19:56 -0400 Subject: [PATCH 4/7] Address second review pass on rate limit handler - Validate rate_limit_handler responds to #call at build time (fail once up front instead of warning on every response) - Extract RateLimit.deliver so header parsing happens outside the handler rescue (a parsing bug is no longer misattributed to the handler) and the notify logic/comment is no longer duplicated across the two HTTP paths - Stop advertising the internal RateLimit class in docs; document the contract (responds to #limit/#remaining/#reset) instead - Note the handler is Auth0::Client-only and that validate_id_token's JWKS fetch is not reported; give Rate Limit Monitoring its own EXAMPLES section - Docs/style: trim comments, split run-on sentences, add @raise/@param/@return, shorten the README options row, unbold mid-sentence text - Tests: Authentication path now uses webmock with a real response; add from_headers(nil), RateLimit.deliver, and non-callable-handler cases; move the proxy test out of test/unit/mixins into test/unit --- EXAMPLES.md | 4 +- README.md | 6 +- lib/auth0/auth_client.rb | 13 ++-- lib/auth0/internal/http/rate_limit.rb | 22 +++++++ lib/auth0/internal/http/raw_client.rb | 18 +----- lib/auth0/mixins/httpproxy.rb | 19 ++---- lib/auth0/mixins/initializer.rb | 6 +- test/unit/internal/http/test_rate_limit.rb | 27 ++++++++ test/unit/mixins/test_httpproxy_rate_limit.rb | 56 ----------------- test/unit/test_auth_client_rate_limit.rb | 4 ++ test/unit/test_httpproxy_rate_limit.rb | 63 +++++++++++++++++++ 11 files changed, 138 insertions(+), 100 deletions(-) delete mode 100644 test/unit/mixins/test_httpproxy_rate_limit.rb create mode 100644 test/unit/test_httpproxy_rate_limit.rb diff --git a/EXAMPLES.md b/EXAMPLES.md index 9413449dc..589020574 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -166,9 +166,9 @@ management.roles.create(name: 'admin', description: 'Administrator') management.organizations.list(take: 20) ``` -### Monitoring rate limits +## Rate Limit Monitoring -Pass a `rate_limit_handler` to observe Auth0's `x-ratelimit-*` headers after every Management and Authentication API response, without changing the return value of your calls: +Pass a `rate_limit_handler` when building an `Auth0::Client` to observe Auth0's `x-ratelimit-*` headers, without changing the return value of your calls. It covers Management API calls and the Authentication API calls made through the client's HTTP layer (it is not available on `Auth0::Management` directly): ```ruby client = Auth0::Client.new( diff --git a/README.md b/README.md index 71ced28d8..244f0d919 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ client = Auth0::Client.new( | `management_timeout` | Float | Timeout in seconds for Management API calls. | `60` | | `management_max_retries` | Integer | Maximum retries for Management API calls. | `2` | | `management_additional_headers` | Hash | Additional HTTP headers for Management API calls. | `nil` | -| `rate_limit_handler` | `#call` | Callback invoked with an `Auth0::Internal::Http::RateLimit` after every Management and Authentication API response, for monitoring rate-limit headroom. See [Rate Limit Monitoring](#rate-limit-monitoring). | `nil` | +| `rate_limit_handler` | `#call` | Callback for monitoring rate-limit headroom. See [Rate Limit Monitoring](#rate-limit-monitoring). | `nil` | #### Accessing the Management Client Directly @@ -286,7 +286,7 @@ client.users.list( ### Rate Limit Monitoring -Auth0 returns `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` headers on API responses. Pass a `rate_limit_handler` to be notified of this data after every Management **and** Authentication API response, so you can monitor how close you are to the limit (for example, emit a metric and alert before you run out): +Auth0 returns `x-ratelimit-limit`, `x-ratelimit-remaining`, and `x-ratelimit-reset` headers on API responses. Pass a `rate_limit_handler` when building an `Auth0::Client` to be notified of this data, so you can monitor how close you are to the limit (for example, emit a metric and alert before you run out). It covers Management API calls and the Authentication API calls made through the client's HTTP layer (the JWKS fetch used by `validate_id_token` goes through a separate object and is not reported). It is not available on `Auth0::Management` directly. ```ruby client = Auth0::Client.new( @@ -299,7 +299,7 @@ client = Auth0::Client.new( ) ``` -The handler receives an `Auth0::Internal::Http::RateLimit` with `#limit`, `#remaining` (Integers), and `#reset` (a UTC `Time`); each is `nil` when the corresponding header is absent or non-numeric. It is invoked on every response — including the `429`s that trigger an automatic retry — so you can observe the point at which the limit was reached. The return value of your API call is unchanged, and an exception raised inside the handler is caught (and warned) so it can never break a request. +The handler receives an object with `#limit`, `#remaining` (Integers), and `#reset` (a UTC `Time`); each is `nil` when the corresponding header is absent or non-numeric. It is invoked on every response, including the `429`s that trigger an automatic retry, so you can observe the point at which the limit was reached. The return value of your API call is unchanged, and an exception raised inside the handler is caught (and warned) so it can never break a request. ### Errors diff --git a/lib/auth0/auth_client.rb b/lib/auth0/auth_client.rb index d14b8dbd4..ccecc6ff7 100644 --- a/lib/auth0/auth_client.rb +++ b/lib/auth0/auth_client.rb @@ -98,15 +98,12 @@ def management private - # Attaches the configured rate limit handler to the management client's - # underlying raw client. Management is generated and builds its own raw - # client, so we set the handler on it after construction. - # - # Fails loudly if the raw client can't be found: Management is regenerated - # and could rename/restructure `@raw_client`, and silently skipping would - # turn the feature off with no signal. + # Attaches the configured rate limit handler to the management client's raw + # client. Fails loudly rather than silently skipping, so a future change to + # the generated client's internals surfaces instead of quietly disabling it. # @param management [Auth0::Management] # @return [void] + # @raise [Auth0::Exception] if the management client exposes no compatible raw client def attach_rate_limit_handler(management) return if @rate_limit_handler.nil? @@ -114,7 +111,7 @@ def attach_rate_limit_handler(management) unless raw_client.respond_to?(:rate_limit_handler=) raise Auth0::Exception, "Unable to attach rate_limit_handler: the management client does not expose a compatible raw client. " \ - "This usually means the ruby-auth0 internals changed; please report it." + "This likely means the ruby-auth0 internals changed. Please report it." end raw_client.rate_limit_handler = @rate_limit_handler diff --git a/lib/auth0/internal/http/rate_limit.rb b/lib/auth0/internal/http/rate_limit.rb index 83528b27a..dfb975b47 100644 --- a/lib/auth0/internal/http/rate_limit.rb +++ b/lib/auth0/internal/http/rate_limit.rb @@ -57,6 +57,25 @@ def self.from_headers(headers) ) end + # Invokes the handler with the given rate limit. A handler error must + # never break the caller's request, so it is rescued and warned rather + # than raised. Parsing happens before this call, so a parsing bug is not + # misattributed to the handler. + # + # @param handler [#call, nil] the configured rate limit handler + # @param rate_limit [Auth0::Internal::Http::RateLimit] + # @return [void] + def self.deliver(handler, rate_limit) + return if handler.nil? + + handler.call(rate_limit) + rescue StandardError => e + warn "[auth0] rate_limit_handler raised #{e.class}: #{e.message}" + end + + # @param limit [String, nil] raw `x-ratelimit-limit` value + # @param remaining [String, nil] raw `x-ratelimit-remaining` value + # @param reset [String, nil] raw `x-ratelimit-reset` value # @return [Auth0::Internal::Http::RateLimit] def self.build(limit, remaining, reset) reset_epoch = to_integer(reset) @@ -71,6 +90,9 @@ def self.build(limit, remaining, reset) # Parse an integer header value, returning nil for blank or non-numeric # input (so a malformed header is never silently reported as 0). + # + # @param value [String, nil] the raw header value + # @return [Integer, nil] def self.to_integer(value) Integer(value.to_s.strip, exception: false) end diff --git a/lib/auth0/internal/http/raw_client.rb b/lib/auth0/internal/http/raw_client.rb index a1c7c8945..a22cd1091 100644 --- a/lib/auth0/internal/http/raw_client.rb +++ b/lib/auth0/internal/http/raw_client.rb @@ -76,9 +76,8 @@ def send(request) response = conn.request(http_request) - # Notify on every response, including the 429s that trigger a retry, - # so a handler watching `remaining` sees the point where it ran out. - notify_rate_limit(response) + # Report rate limit on every response, including retried 429s. + RateLimit.deliver(@rate_limit_handler, RateLimit.from_http_response(response)) if @rate_limit_handler break unless should_retry?(response, attempt) @@ -90,19 +89,6 @@ def send(request) response end - # Invokes the rate limit handler with the rate limit parsed from the - # response headers. A handler error must never break the request, so it - # is swallowed, but a warning is emitted so a broken handler is visible. - # @param response [Net::HTTPResponse] The HTTP response. - # @return [void] - def notify_rate_limit(response) - return if @rate_limit_handler.nil? - - @rate_limit_handler.call(RateLimit.from_http_response(response)) - rescue StandardError => e - warn "[auth0] rate_limit_handler raised #{e.class}: #{e.message}" - end - # Determines if a request should be retried based on the response status code. # @param response [Net::HTTPResponse] The HTTP response. # @param attempt [Integer] The current retry attempt (0-indexed). diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index 51ae0a23e..a36cb584d 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -96,9 +96,11 @@ def request(method, uri, body = {}, extra_headers = {}) call(method, encode_uri(uri), timeout, headers, body.to_json) end - # Notify on every response, including the 429s that trigger a retry, so - # a handler watching `remaining` sees the point where it ran out. - notify_rate_limit(result) + # Surface rate limit for each response, including 429 retries. + if @rate_limit_handler + rate_limit = Auth0::Internal::Http::RateLimit.from_headers(result.headers) + Auth0::Internal::Http::RateLimit.deliver(@rate_limit_handler, rate_limit) + end case result.code when 200...226 then safe_parse_json(result.body) @@ -112,17 +114,6 @@ def request(method, uri, body = {}, extra_headers = {}) end end - # Invokes the rate limit handler with the rate limit parsed from the - # response headers. A handler error must never break the request, so it is - # swallowed, but a warning is emitted so a broken handler is visible. - def notify_rate_limit(result) - return if @rate_limit_handler.nil? - - @rate_limit_handler.call(Auth0::Internal::Http::RateLimit.from_headers(result.headers)) - rescue StandardError => e - warn "[auth0] rate_limit_handler raised #{e.class}: #{e.message}" - end - def call(method, url, timeout, headers, body = nil) RestClient::Request.execute( method: method, diff --git a/lib/auth0/mixins/initializer.rb b/lib/auth0/mixins/initializer.rb index 386f2801d..2d071b185 100644 --- a/lib/auth0/mixins/initializer.rb +++ b/lib/auth0/mixins/initializer.rb @@ -21,7 +21,11 @@ def initialize(config) @management_timeout = options[:management_timeout] @management_max_retries = options[:management_max_retries] @management_additional_headers = options[:management_additional_headers] - @rate_limit_handler = options[:rate_limit_handler] + handler = options[:rate_limit_handler] + raise ArgumentError, "rate_limit_handler must respond to #call" if handler && !handler.respond_to?(:call) + + @rate_limit_handler = handler + extend Auth0::Api::AuthenticationEndpoints @client_id = options[:client_id] diff --git a/test/unit/internal/http/test_rate_limit.rb b/test/unit/internal/http/test_rate_limit.rb index a31fd6fef..d19d250ec 100644 --- a/test/unit/internal/http/test_rate_limit.rb +++ b/test/unit/internal/http/test_rate_limit.rb @@ -56,6 +56,33 @@ def [](name) _(rate_limit.remaining).must_be_nil _(rate_limit.reset).must_be_nil end + + it "handles nil headers" do + rate_limit = TestRateLimit::RateLimit.from_headers(nil) + + _(rate_limit.limit).must_be_nil + _(rate_limit.remaining).must_be_nil + _(rate_limit.reset).must_be_nil + end + end + + describe ".deliver" do + let(:rate_limit) { TestRateLimit::RateLimit.from_headers(x_ratelimit_remaining: "5") } + + it "does nothing when the handler is nil" do + TestRateLimit::RateLimit.deliver(nil, rate_limit) # no error + end + + it "calls the handler with the rate limit" do + received = nil + TestRateLimit::RateLimit.deliver(->(rl) { received = rl }, rate_limit) + + _(received).must_be_same_as rate_limit + end + + it "swallows a handler error so it can't break the caller" do + TestRateLimit::RateLimit.deliver(->(_rl) { raise "boom" }, rate_limit) # no error raised + end end describe ".from_http_response" do diff --git a/test/unit/mixins/test_httpproxy_rate_limit.rb b/test/unit/mixins/test_httpproxy_rate_limit.rb deleted file mode 100644 index 7a572babc..000000000 --- a/test/unit/mixins/test_httpproxy_rate_limit.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require "test_helper" - -describe Auth0::Mixins::HTTPProxy do - module TestHttpProxyRateLimit - # Minimal host that mixes in the Authentication API HTTP path. - class DummyProxy - include Auth0::Mixins::HTTPProxy - end - - # Mimics a RestClient::Response (symbol header keys, #code / #body). - FakeResponse = Struct.new(:code, :body, :headers) - end - - def build_proxy(handler) - proxy = TestHttpProxyRateLimit::DummyProxy.new - proxy.base_uri = "https://tenant.auth0.com" - proxy.rate_limit_handler = handler - proxy - end - - it "invokes the handler with the rate limit parsed from an authentication response" do - captured = nil - proxy = build_proxy(->(rate_limit) { captured = rate_limit }) - response = TestHttpProxyRateLimit::FakeResponse.new( - 200, - "{}", - { - x_ratelimit_limit: "100", - x_ratelimit_remaining: "7", - x_ratelimit_reset: "1724000000" - } - ) - - proxy.stub(:call, response) do - proxy.request(:get, "/userinfo") - end - - _(captured).must_be_instance_of Auth0::Internal::Http::RateLimit - _(captured.limit).must_equal 100 - _(captured.remaining).must_equal 7 - _(captured.reset).must_equal Time.at(1_724_000_000).utc - end - - it "does nothing when no handler is configured" do - proxy = build_proxy(nil) - response = TestHttpProxyRateLimit::FakeResponse.new(200, "{}", {}) - - result = proxy.stub(:call, response) do - proxy.request(:get, "/userinfo") - end - - _(result).must_equal({}) - end -end diff --git a/test/unit/test_auth_client_rate_limit.rb b/test/unit/test_auth_client_rate_limit.rb index 4ec0dd377..15f3923d1 100644 --- a/test/unit/test_auth_client_rate_limit.rb +++ b/test/unit/test_auth_client_rate_limit.rb @@ -22,6 +22,10 @@ def raw_client_for(client) _(raw_client_for(build_client).rate_limit_handler).must_be_nil end + it "raises when the handler does not respond to #call" do + _(-> { build_client(rate_limit_handler: "not-callable") }).must_raise ArgumentError + end + it "re-attaches the handler after a token change rebuilds the management client" do handler = ->(_rate_limit) {} client = build_client(rate_limit_handler: handler) diff --git a/test/unit/test_httpproxy_rate_limit.rb b/test/unit/test_httpproxy_rate_limit.rb new file mode 100644 index 000000000..a8ff079f0 --- /dev/null +++ b/test/unit/test_httpproxy_rate_limit.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "test_helper" +require "webmock" + +describe Auth0::Mixins::HTTPProxy do + include WebMock::API + + # Minimal host that mixes in the Authentication API HTTP path. + class DummyProxy + include Auth0::Mixins::HTTPProxy + + def initialize(base_uri:, rate_limit_handler: nil) + @base_uri = base_uri + @headers = {} + @rate_limit_handler = rate_limit_handler + end + end + + before do + WebMock.enable! + WebMock.disable_net_connect! + end + + after do + WebMock.reset! + WebMock.allow_net_connect! + WebMock.disable! + end + + it "invokes the handler with the rate limit parsed from a real response's headers" do + captured = nil + proxy = DummyProxy.new(base_uri: "https://tenant.auth0.com", rate_limit_handler: ->(rate_limit) { captured = rate_limit }) + + stub_request(:get, "https://tenant.auth0.com/userinfo").to_return( + status: 200, + body: "{}", + headers: { + "x-ratelimit-limit" => "100", + "x-ratelimit-remaining" => "7", + "x-ratelimit-reset" => "1724000000", + "Content-Type" => "application/json" + } + ) + + proxy.request(:get, "/userinfo") + + _(captured).wont_be_nil + _(captured.limit).must_equal 100 + _(captured.remaining).must_equal 7 + _(captured.reset).must_equal Time.at(1_724_000_000).utc + end + + it "does nothing when no handler is configured" do + proxy = DummyProxy.new(base_uri: "https://tenant.auth0.com") + + stub_request(:get, "https://tenant.auth0.com/userinfo").to_return( + status: 200, body: "{}", headers: { "Content-Type" => "application/json" } + ) + + _(proxy.request(:get, "/userinfo")).must_equal({}) + end +end From 4a757919d4b043b928e69b84acf4ff9a4f922cc0 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 14 Oct 2026 11:09:02 -0400 Subject: [PATCH 5/7] Shorten rate_limit_handler README options row --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 244f0d919..59c28c07c 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ client = Auth0::Client.new( | `management_timeout` | Float | Timeout in seconds for Management API calls. | `60` | | `management_max_retries` | Integer | Maximum retries for Management API calls. | `2` | | `management_additional_headers` | Hash | Additional HTTP headers for Management API calls. | `nil` | -| `rate_limit_handler` | `#call` | Callback for monitoring rate-limit headroom. See [Rate Limit Monitoring](#rate-limit-monitoring). | `nil` | +| `rate_limit_handler` | `#call` | Callback for [rate-limit monitoring](#rate-limit-monitoring). | `nil` | #### Accessing the Management Client Directly From 0ff7e97875a813dc93363134768e497c023c0401 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 14 Oct 2026 11:19:12 -0400 Subject: [PATCH 6/7] Address earlier review sub-batch on rate limit handler - Attach the handler before caching the management client, so a failed attach doesn't leave a handler-less management cached (next call re-attempts) - Guard the Authentication-path notify against a nil response (call returns nil on e.g. a dropped connection) - Split a remaining semicolon-joined sentence in the README - Tests: cover the incompatible-raw-client raise and the no-cache-on-failure behavior; add Authentication-path retry (429s) and handler-raises cases; assert the warning (and stop leaking "boom" into test output) --- README.md | 2 +- lib/auth0/auth_client.rb | 9 +++-- lib/auth0/mixins/httpproxy.rb | 5 ++- test/unit/internal/http/test_rate_limit.rb | 6 ++- test/unit/internal/http/test_raw_client.rb | 6 ++- test/unit/test_auth_client_rate_limit.rb | 23 +++++++++++ test/unit/test_httpproxy_rate_limit.rb | 45 +++++++++++++++++++++- 7 files changed, 85 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 59c28c07c..e26d77eb9 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ client = Auth0::Client.new( ) ``` -The handler receives an object with `#limit`, `#remaining` (Integers), and `#reset` (a UTC `Time`); each is `nil` when the corresponding header is absent or non-numeric. It is invoked on every response, including the `429`s that trigger an automatic retry, so you can observe the point at which the limit was reached. The return value of your API call is unchanged, and an exception raised inside the handler is caught (and warned) so it can never break a request. +The handler receives an object with `#limit`, `#remaining` (Integers), and `#reset` (a UTC `Time`). Each is `nil` when the corresponding header is absent or non-numeric. It is invoked on every response, including the `429`s that trigger an automatic retry, so you can observe the point at which the limit was reached. The return value of your API call is unchanged, and an exception raised inside the handler is caught (and warned) so it can never break a request. ### Errors diff --git a/lib/auth0/auth_client.rb b/lib/auth0/auth_client.rb index ccecc6ff7..2b7becd81 100644 --- a/lib/auth0/auth_client.rb +++ b/lib/auth0/auth_client.rb @@ -85,13 +85,16 @@ def respond_to_missing?(method_name, include_private = false) def management current_token = get_token if @_management.nil? || @_management_token != current_token - @_management_token = current_token opts = { token: current_token, base_url: "#{@base_uri}/api/v2" } opts[:timeout] = @management_timeout if @management_timeout opts[:max_retries] = @management_max_retries if @management_max_retries opts[:headers] = @management_additional_headers if @management_additional_headers - @_management = Auth0::Management.new(**opts) - attach_rate_limit_handler(@_management) + management = Auth0::Management.new(**opts) + # Attach before caching so a failed attach doesn't leave a handler-less + # management cached (the next call would then skip the rebuild). + attach_rate_limit_handler(management) + @_management = management + @_management_token = current_token end @_management end diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index a36cb584d..da138d48a 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -96,8 +96,9 @@ def request(method, uri, body = {}, extra_headers = {}) call(method, encode_uri(uri), timeout, headers, body.to_json) end - # Surface rate limit for each response, including 429 retries. - if @rate_limit_handler + # Surface rate limit for each response, including 429 retries. `call` + # can return nil (e.g. a dropped connection), so guard on a real response. + if @rate_limit_handler && result.respond_to?(:headers) rate_limit = Auth0::Internal::Http::RateLimit.from_headers(result.headers) Auth0::Internal::Http::RateLimit.deliver(@rate_limit_handler, rate_limit) end diff --git a/test/unit/internal/http/test_rate_limit.rb b/test/unit/internal/http/test_rate_limit.rb index d19d250ec..569445ee1 100644 --- a/test/unit/internal/http/test_rate_limit.rb +++ b/test/unit/internal/http/test_rate_limit.rb @@ -80,8 +80,10 @@ def [](name) _(received).must_be_same_as rate_limit end - it "swallows a handler error so it can't break the caller" do - TestRateLimit::RateLimit.deliver(->(_rl) { raise "boom" }, rate_limit) # no error raised + it "swallows a handler error (warning) so it can't break the caller" do + assert_output(nil, /rate_limit_handler raised/) do + TestRateLimit::RateLimit.deliver(->(_rl) { raise "boom" }, rate_limit) + end end end diff --git a/test/unit/internal/http/test_raw_client.rb b/test/unit/internal/http/test_raw_client.rb index e05c2c075..87a17abc5 100644 --- a/test/unit/internal/http/test_raw_client.rb +++ b/test/unit/internal/http/test_raw_client.rb @@ -110,7 +110,7 @@ def send_through(client, *responses) _(send_through(client, response)).must_be_same_as response end - it "does not let a handler error break the request" do + it "warns but does not let a handler error break the request" do client = Auth0::Internal::Http::RawClient.new( base_url: "https://tenant.auth0.com", max_retries: 0, @@ -118,6 +118,8 @@ def send_through(client, *responses) ) response = TestRawClient.ok(remaining: 5) - _(send_through(client, response)).must_be_same_as response + assert_output(nil, /rate_limit_handler raised/) do + _(send_through(client, response)).must_be_same_as response + end end end diff --git a/test/unit/test_auth_client_rate_limit.rb b/test/unit/test_auth_client_rate_limit.rb index 15f3923d1..482319c6b 100644 --- a/test/unit/test_auth_client_rate_limit.rb +++ b/test/unit/test_auth_client_rate_limit.rb @@ -26,6 +26,29 @@ def raw_client_for(client) _(-> { build_client(rate_limit_handler: "not-callable") }).must_raise ArgumentError end + it "raises when the management client exposes no compatible raw client" do + client = build_client(rate_limit_handler: ->(_rate_limit) {}) + incompatible = Object.new + incompatible.instance_variable_set(:@raw_client, Object.new) # no rate_limit_handler= + + Auth0::Management.stub(:new, incompatible) do + _(-> { client.management }).must_raise Auth0::Exception + end + end + + it "does not cache a management client when attaching the handler fails" do + client = build_client(rate_limit_handler: ->(_rate_limit) {}) + incompatible = Object.new + incompatible.instance_variable_set(:@raw_client, Object.new) + + Auth0::Management.stub(:new, incompatible) do + _(-> { client.management }).must_raise Auth0::Exception + # A second call must retry (and raise again) rather than return a cached, + # handler-less management. + _(-> { client.management }).must_raise Auth0::Exception + end + end + it "re-attaches the handler after a token change rebuilds the management client" do handler = ->(_rate_limit) {} client = build_client(rate_limit_handler: handler) diff --git a/test/unit/test_httpproxy_rate_limit.rb b/test/unit/test_httpproxy_rate_limit.rb index a8ff079f0..b4eba33f4 100644 --- a/test/unit/test_httpproxy_rate_limit.rb +++ b/test/unit/test_httpproxy_rate_limit.rb @@ -10,13 +10,23 @@ class DummyProxy include Auth0::Mixins::HTTPProxy - def initialize(base_uri:, rate_limit_handler: nil) + def initialize(base_uri:, rate_limit_handler: nil, retry_count: nil) @base_uri = base_uri @headers = {} @rate_limit_handler = rate_limit_handler + @retry_count = retry_count end end + def rate_limit_headers(remaining) + { + "x-ratelimit-limit" => "100", + "x-ratelimit-remaining" => remaining.to_s, + "x-ratelimit-reset" => "1724000000", + "Content-Type" => "application/json" + } + end + before do WebMock.enable! WebMock.disable_net_connect! @@ -60,4 +70,37 @@ def initialize(base_uri:, rate_limit_handler: nil) _(proxy.request(:get, "/userinfo")).must_equal({}) end + + it "notifies on every response across a retried (429) request" do + seen = [] + proxy = DummyProxy.new( + base_uri: "https://tenant.auth0.com", + rate_limit_handler: ->(rate_limit) { seen << rate_limit.remaining }, + retry_count: 2 + ) + + stub_request(:get, "https://tenant.auth0.com/userinfo").to_return( + { status: 429, body: "{}", headers: rate_limit_headers(1) }, + { status: 429, body: "{}", headers: rate_limit_headers(0) }, + { status: 200, body: "{}", headers: rate_limit_headers(99) } + ) + + proxy.request_with_retry(:get, "/userinfo") + + _(seen).must_equal [1, 0, 99] + end + + it "swallows and warns when the handler raises, without breaking the request" do + proxy = DummyProxy.new( + base_uri: "https://tenant.auth0.com", + rate_limit_handler: ->(_rate_limit) { raise "boom" } + ) + stub_request(:get, "https://tenant.auth0.com/userinfo").to_return( + status: 200, body: "{}", headers: rate_limit_headers(7) + ) + + assert_output(nil, /rate_limit_handler raised/) do + _(proxy.request(:get, "/userinfo")).must_equal({}) + end + end end From 1a338f915a34843056299af17e4ac6957d706198 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 14 Oct 2026 12:38:17 -0400 Subject: [PATCH 7/7] Use an explicit nil check for the Authentication-path notify guard Matches the reviewer's suggestion and names the actual failure mode (call returns nil for some errors) rather than a broader respond_to? guard. --- lib/auth0/mixins/httpproxy.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index da138d48a..ae6dcdfa0 100644 --- a/lib/auth0/mixins/httpproxy.rb +++ b/lib/auth0/mixins/httpproxy.rb @@ -97,8 +97,8 @@ def request(method, uri, body = {}, extra_headers = {}) end # Surface rate limit for each response, including 429 retries. `call` - # can return nil (e.g. a dropped connection), so guard on a real response. - if @rate_limit_handler && result.respond_to?(:headers) + # returns nil for some errors (e.g. a dropped connection), so skip it then. + if @rate_limit_handler && !result.nil? rate_limit = Auth0::Internal::Http::RateLimit.from_headers(result.headers) Auth0::Internal::Http::RateLimit.deliver(@rate_limit_handler, rate_limit) end