Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions lib/auth0/auth_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,33 @@ 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.
#
# 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 @rate_limit_handler.nil?

raw_client = management.instance_variable_get(:@raw_client)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reaches into the generated Management client for its @raw_client by instance variable, and the if raw_client means that if a future regeneration renames or restructures that variable, the handler just never gets attached and the feature goes quiet with no error anywhere. This file is kept across regeneration but the generated class it reaches into is not, so the two can drift apart and nothing would flag it.

We should be handling this carefully.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have made this raise a Auth0::Unsupported error for now so that a future internals change surfaces immediately instead of silently skipping.

I'm using the ivar because the generated Auth0::Management doesn't expose its raw client, and since that class is regenerated, we can't add an accessor from our side without it being clobbered. If you'd be open to exposing a public reader for the raw client on the generated Management class, I would switch to it and drop the instance_variable_get entirely.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A public reader on Auth0::Management would be the cleaner fix, but that class is generated, so a hand edit would be overwritten on the next regen. It would need to come from the Fern side, so not blocking this PR on it.

The respond_to? check with the raise is enough for now, since a rename would fail loudly instead of silently dropping the handler.

One small question on the error. Auth0::Unsupported is part of the HTTP error family, and this isn't an HTTP failure. Would a plain Auth0::Exception fit better here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh ya that makes sense, thanks for pointing that out. made the change.

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."
end

raw_client.rate_limit_handler = @rate_limit_handler
end
end
end
81 changes: 81 additions & 0 deletions lib/auth0/internal/http/rate_limit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# 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

# @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).
def self.to_integer(value)
Integer(value.to_s.strip, exception: false)
end
private_class_method :to_integer
end
end
end
end
30 changes: 28 additions & 2 deletions lib/auth0/internal/http/raw_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -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
Expand All @@ -67,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)
Expand All @@ -77,6 +90,19 @@ 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).
Expand Down
18 changes: 17 additions & 1 deletion lib/auth0/mixins/httpproxy.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions lib/auth0/mixins/initializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
@rate_limit_handler = options[:rate_limit_handler]
extend Auth0::Api::AuthenticationEndpoints

@client_id = options[:client_id]
Expand Down
76 changes: 76 additions & 0 deletions test/unit/internal/http/test_rate_limit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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
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
Loading
Loading