From df19130e5a84d304f06aaba3ba49562f16cf74e5 Mon Sep 17 00:00:00 2001 From: Tom Turner Date: Wed, 2 Sep 2026 09:39:24 -0400 Subject: [PATCH 1/3] 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/3] 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/3] 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