diff --git a/EXAMPLES.md b/EXAMPLES.md index b2bef2093..589020574 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -166,6 +166,26 @@ management.roles.create(name: 'admin', description: 'Administrator') management.organizations.list(take: 20) ``` +## Rate Limit Monitoring + +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( + 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..e26d77eb9 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 for [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` 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( + 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 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 Management API errors use the `Auth0::Errors` namespace: diff --git a/lib/auth0/auth_client.rb b/lib/auth0/auth_client.rb index 9f3cbde6f..2b7becd81 100644 --- a/lib/auth0/auth_client.rb +++ b/lib/auth0/auth_client.rb @@ -85,14 +85,39 @@ 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) + 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 + + private + + # 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? + + raw_client = management.instance_variable_get(:@raw_client) + 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 likely 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 new file mode 100644 index 000000000..dfb975b47 --- /dev/null +++ b/lib/auth0/internal/http/rate_limit.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Auth0 + module Internal + module Http + # Rate limit information parsed from the `x-ratelimit-*` headers Auth0 + # returns on 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 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 [#[]] responds to `[]` with header access + # @return [Auth0::Internal::Http::RateLimit] + 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 + + # 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) + + new( + 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). + # + # @param value [String, nil] the raw header value + # @return [Integer, nil] + 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..a22cd1091 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 @@ -67,6 +76,9 @@ def send(request) response = conn.request(http_request) + # 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) delay = retry_delay(response, attempt) diff --git a/lib/auth0/mixins/httpproxy.rb b/lib/auth0/mixins/httpproxy.rb index f4446c47b..ae6dcdfa0 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,13 @@ 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. `call` + # 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 + 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) diff --git a/lib/auth0/mixins/initializer.rb b/lib/auth0/mixins/initializer.rb index a37ac88a2..2d071b185 100644 --- a/lib/auth0/mixins/initializer.rb +++ b/lib/auth0/mixins/initializer.rb @@ -21,6 +21,11 @@ def initialize(config) @management_timeout = options[:management_timeout] @management_max_retries = options[:management_max_retries] @management_additional_headers = options[:management_additional_headers] + 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 new file mode 100644 index 000000000..569445ee1 --- /dev/null +++ b/test/unit/internal/http/test_rate_limit.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "test_helper" + +describe Auth0::Internal::Http::RateLimit do + module TestRateLimit + RateLimit = Auth0::Internal::Http::RateLimit + + # Mimics Net::HTTPResponse#[], which is case-insensitive. + class CaseInsensitiveResponse + def initialize(headers) + @headers = headers.transform_keys { |k| k.to_s.downcase } + end + + 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 + _(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 "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 + _(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 (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 + + 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 new file mode 100644 index 000000000..87a17abc5 --- /dev/null +++ b/test/unit/internal/http/test_raw_client.rb @@ -0,0 +1,125 @@ +# 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 + + # Returns a queued response per call, mimicking a retried request. + class FakeConnection + def initialize(responses) + @responses = responses + end + + def open_timeout=(_); end + def read_timeout=(_); end + def write_timeout=(_); end + def continue_timeout=(_); end + + def request(_http_request) + @responses.shift + end + end + + 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.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_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 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 } + ) + response = TestRawClient.ok(remaining: 12) + + result = send_through(client, response) + + _(result).must_be_same_as response + _(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.ok(remaining: 5) + + _(send_through(client, response)).must_be_same_as response + end + + 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, + rate_limit_handler: ->(_rate_limit) { raise "boom" } + ) + response = TestRawClient.ok(remaining: 5) + + 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 new file mode 100644 index 000000000..482319c6b --- /dev/null +++ b/test/unit/test_auth_client_rate_limit.rb @@ -0,0 +1,62 @@ +# 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 + + def raw_client_for(client) + client.management.instance_variable_get(:@raw_client) + end + + 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_for(client).rate_limit_handler).must_be_same_as handler + end + + it "leaves the handler unset when none is configured" do + _(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 "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) + + first = client.stub(:get_token, "token-1") { client.management } + second = client.stub(:get_token, "token-2") { client.management } + + _(second).wont_be_same_as first + _(second.instance_variable_get(:@raw_client).rate_limit_handler).must_be_same_as handler + end +end diff --git a/test/unit/test_httpproxy_rate_limit.rb b/test/unit/test_httpproxy_rate_limit.rb new file mode 100644 index 000000000..b4eba33f4 --- /dev/null +++ b/test/unit/test_httpproxy_rate_limit.rb @@ -0,0 +1,106 @@ +# 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, 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! + 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 + + 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