diff --git a/.fernignore b/.fernignore
index 601b2d76..9033257a 100644
--- a/.fernignore
+++ b/.fernignore
@@ -54,6 +54,12 @@ tests/Auth0.ManagementApi.Test/Core/CustomDomainInterceptorTest.cs
tests/Auth0.ManagementApi.Test/Unit/MockServer/CustomDomainHeaderTest.cs
src/Auth0.ManagementApi/Core/BooleanStringConverter.cs
tests/Auth0.ManagementApi.Test/Unit/BooleanStringConverterTest.cs
+src/Auth0.ManagementApi/Core/Public/ManagementApiException.cs
+src/Auth0.ManagementApi/Core/Public/ApiError.cs
+src/Auth0.ManagementApi/Core/Public/RateLimit.cs
+src/Auth0.ManagementApi/Core/Public/QuotaLimit.cs
+src/Auth0.ManagementApi/Exceptions/TooManyRequestsError.cs
+tests/Auth0.ManagementApi.Test/Unit/ExceptionEnrichmentTest.cs
.fern/replay.lock
.fern/replay.yml
diff --git a/src/Auth0.ManagementApi/Core/Public/ApiError.cs b/src/Auth0.ManagementApi/Core/Public/ApiError.cs
new file mode 100644
index 00000000..296fe0f7
--- /dev/null
+++ b/src/Auth0.ManagementApi/Core/Public/ApiError.cs
@@ -0,0 +1,79 @@
+using global::System.Text.Json;
+
+namespace Auth0.ManagementApi;
+
+///
+/// Structured error information extracted from a failed Management API response body.
+/// Only the well-known, non-sensitive error fields returned by the API are surfaced.
+///
+public sealed class ApiError
+{
+ ///
+ /// The short error identifier returned by the API (the error field), e.g. "Bad Request".
+ ///
+ public string? Error { get; init; }
+
+ ///
+ /// The machine-readable error code returned by the API (the errorCode field), if present.
+ ///
+ public string? ErrorCode { get; init; }
+
+ ///
+ /// The human-readable error description returned by the API. Populated from the first available
+ /// of message, error_description, description, or error.
+ ///
+ public string? Message { get; init; }
+
+ ///
+ /// Attempts to build an from a response body. The body is expected to be the
+ /// boxed produced by the SDK when deserializing an error response.
+ /// Returns null when no usable error information can be extracted.
+ ///
+ internal static ApiError? TryParse(object? body)
+ {
+ if (body is not JsonElement element || element.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ try
+ {
+ var error = GetString(element, "error");
+ var errorCode = GetString(element, "errorCode") ?? GetString(element, "error_code");
+ var message =
+ GetString(element, "message")
+ ?? GetString(element, "error_description")
+ ?? GetString(element, "description")
+ ?? error;
+
+ if (error is null && errorCode is null && message is null)
+ {
+ return null;
+ }
+
+ return new ApiError
+ {
+ Error = error,
+ ErrorCode = errorCode,
+ Message = message,
+ };
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ private static string? GetString(JsonElement element, string propertyName)
+ {
+ if (
+ element.TryGetProperty(propertyName, out var value)
+ && value.ValueKind == JsonValueKind.String
+ )
+ {
+ return value.GetString();
+ }
+
+ return null;
+ }
+}
diff --git a/src/Auth0.ManagementApi/Core/Public/ManagementApiException.cs b/src/Auth0.ManagementApi/Core/Public/ManagementApiException.cs
index eefb9b66..f1f4778b 100644
--- a/src/Auth0.ManagementApi/Core/Public/ManagementApiException.cs
+++ b/src/Auth0.ManagementApi/Core/Public/ManagementApiException.cs
@@ -11,6 +11,11 @@ public class ManagementApiException(
Auth0.ManagementApi.RawResponse? rawResponse = null
) : ManagementException(message, innerException)
{
+ private bool _apiErrorParsed;
+ private ApiError? _apiError;
+ private bool _rateLimitParsed;
+ private RateLimit? _rateLimit;
+
///
/// The error code of the response that triggered the exception.
///
@@ -26,6 +31,62 @@ public class ManagementApiException(
///
public Auth0.ManagementApi.RawResponse? RawResponse => rawResponse;
+ ///
+ /// Structured error information extracted from the response , if the API returned a
+ /// recognizable error payload. Returns null when no error information could be parsed.
+ ///
+ public ApiError? ApiError
+ {
+ get
+ {
+ if (!_apiErrorParsed)
+ {
+ _apiError = Auth0.ManagementApi.ApiError.TryParse(body);
+ _apiErrorParsed = true;
+ }
+
+ return _apiError;
+ }
+ }
+
+ ///
+ /// The human-readable error description returned by the API, if available.
+ ///
+ public string? Description => ApiError?.Message;
+
+ ///
+ /// The machine-readable error code returned by the API, if available.
+ ///
+ public string? ErrorCode => ApiError?.ErrorCode;
+
+ ///
+ /// Rate limit information parsed from the response headers (x-ratelimit-limit,
+ /// x-ratelimit-remaining, x-ratelimit-reset, retry-after), along with any
+ /// client and organization quota headers. Returns null when no raw response is available.
+ ///
+ public RateLimit? RateLimit
+ {
+ get
+ {
+ if (!_rateLimitParsed)
+ {
+ _rateLimit =
+ rawResponse is null
+ ? null
+ : Auth0.ManagementApi.RateLimit.Parse(rawResponse.Headers);
+ _rateLimitParsed = true;
+ }
+
+ return _rateLimit;
+ }
+ }
+
+ ///
+ /// The error message. When the API returned a recognizable error description it is surfaced here;
+ /// otherwise falls back to the default message supplied by the SDK.
+ ///
+ public override string Message => ApiError?.Message ?? base.Message;
+
public override string ToString()
{
var sb = new System.Text.StringBuilder();
diff --git a/src/Auth0.ManagementApi/Core/Public/QuotaLimit.cs b/src/Auth0.ManagementApi/Core/Public/QuotaLimit.cs
new file mode 100644
index 00000000..4b6f0d55
--- /dev/null
+++ b/src/Auth0.ManagementApi/Core/Public/QuotaLimit.cs
@@ -0,0 +1,175 @@
+using global::System.Collections.Generic;
+
+namespace Auth0.ManagementApi;
+
+///
+/// Represents the client quota headers (Auth0-Client-Quota-Limit) returned as part of a response.
+///
+public sealed class ClientQuotaLimit
+{
+ ///
+ /// The per-hour quota bucket, if present.
+ ///
+ public QuotaLimit? PerHour { get; init; }
+
+ ///
+ /// The per-day quota bucket, if present.
+ ///
+ public QuotaLimit? PerDay { get; init; }
+
+ ///
+ /// Parses the Auth0-Client-Quota-Limit header value into a .
+ /// Returns null when the header is absent or empty.
+ ///
+ public static ClientQuotaLimit? Parse(string? headerValue)
+ {
+ if (!QuotaLimit.TryParseBuckets(headerValue, out var perHour, out var perDay))
+ {
+ return null;
+ }
+
+ return new ClientQuotaLimit { PerHour = perHour, PerDay = perDay };
+ }
+}
+
+///
+/// Represents the organization quota headers (Auth0-Organization-Quota-Limit) returned as part of a response.
+///
+public sealed class OrganizationQuotaLimit
+{
+ ///
+ /// The per-hour quota bucket, if present.
+ ///
+ public QuotaLimit? PerHour { get; init; }
+
+ ///
+ /// The per-day quota bucket, if present.
+ ///
+ public QuotaLimit? PerDay { get; init; }
+
+ ///
+ /// Parses the Auth0-Organization-Quota-Limit header value into an .
+ /// Returns null when the header is absent or empty.
+ ///
+ public static OrganizationQuotaLimit? Parse(string? headerValue)
+ {
+ if (!QuotaLimit.TryParseBuckets(headerValue, out var perHour, out var perDay))
+ {
+ return null;
+ }
+
+ return new OrganizationQuotaLimit { PerHour = perHour, PerDay = perDay };
+ }
+}
+
+///
+/// Represents a single quota bucket returned in the Auth0 quota limit headers.
+///
+public sealed class QuotaLimit
+{
+ ///
+ /// The current configured quota.
+ ///
+ public long Quota { get; init; }
+
+ ///
+ /// The remaining quota.
+ ///
+ public long Remaining { get; init; }
+
+ ///
+ /// The number of seconds until the quota resets.
+ ///
+ public int ResetAfter { get; init; }
+
+ ///
+ /// Splits a quota header value (a comma-separated list of buckets) into its per-hour and per-day
+ /// components. Returns false when the header is absent or empty.
+ ///
+ internal static bool TryParseBuckets(
+ string? headerValue,
+ out QuotaLimit? perHour,
+ out QuotaLimit? perDay
+ )
+ {
+ perHour = null;
+ perDay = null;
+
+ if (string.IsNullOrEmpty(headerValue))
+ {
+ return false;
+ }
+
+ foreach (var eachBucket in headerValue!.Split(','))
+ {
+ var quotaLimit = ParseBucket(eachBucket, out var bucket);
+ if (bucket == "per_hour")
+ {
+ perHour = quotaLimit;
+ }
+ else if (bucket == "per_day")
+ {
+ perDay = quotaLimit;
+ }
+ // Unknown bucket names are ignored so a future bucket type is not
+ // silently misclassified as per-day.
+ }
+
+ return true;
+ }
+
+ private static QuotaLimit? ParseBucket(string headerValue, out string? bucket)
+ {
+ bucket = null;
+
+ if (string.IsNullOrEmpty(headerValue))
+ {
+ return null;
+ }
+
+ var kvp = new Dictionary();
+ foreach (var segment in headerValue.Split(';'))
+ {
+ var parts = segment.Split(new[] { '=' }, 2);
+ if (parts.Length != 2 || parts[0].Length == 0 || parts[1].Length == 0)
+ {
+ return null;
+ }
+
+ // Duplicate keys indicate a malformed header.
+ if (kvp.ContainsKey(parts[0]))
+ {
+ return null;
+ }
+
+ kvp[parts[0]] = parts[1];
+ }
+
+ if (
+ !kvp.TryGetValue("b", out var bucketValue)
+ || !kvp.TryGetValue("q", out var quota)
+ || !kvp.TryGetValue("r", out var remaining)
+ || !kvp.TryGetValue("t", out var resetAfter)
+ )
+ {
+ return null;
+ }
+
+ if (
+ !long.TryParse(quota, out var quotaLong)
+ || !long.TryParse(remaining, out var remainingLong)
+ || !int.TryParse(resetAfter, out var resetAfterInt)
+ )
+ {
+ return null;
+ }
+
+ bucket = bucketValue;
+ return new QuotaLimit
+ {
+ Quota = quotaLong,
+ Remaining = remainingLong,
+ ResetAfter = resetAfterInt,
+ };
+ }
+}
diff --git a/src/Auth0.ManagementApi/Core/Public/RateLimit.cs b/src/Auth0.ManagementApi/Core/Public/RateLimit.cs
new file mode 100644
index 00000000..81a28ce2
--- /dev/null
+++ b/src/Auth0.ManagementApi/Core/Public/RateLimit.cs
@@ -0,0 +1,109 @@
+using Auth0.ManagementApi.Core;
+
+namespace Auth0.ManagementApi;
+
+///
+/// Represents rate limit information returned by the Management API on a
+/// (HTTP 429) response.
+///
+public sealed class RateLimit
+{
+ ///
+ /// The maximum number of requests the consumer is allowed to make,
+ /// from the x-ratelimit-limit header. null if the header was absent.
+ ///
+ public long? Limit { get; init; }
+
+ ///
+ /// The number of requests remaining in the current rate limit window,
+ /// from the x-ratelimit-remaining header. null if the header was absent.
+ ///
+ public long? Remaining { get; init; }
+
+ ///
+ /// The date and time at which the current rate limit window resets,
+ /// from the x-ratelimit-reset header (Unix epoch seconds). null if the header was absent.
+ ///
+ public DateTimeOffset? Reset { get; init; }
+
+ ///
+ /// The time to wait before retrying, from the retry-after header. null if the header was absent.
+ ///
+ public TimeSpan? RetryAfter { get; init; }
+
+ ///
+ /// Client-level quota information, from the Auth0-Client-Quota-Limit header.
+ /// null if the header was absent or malformed.
+ ///
+ public ClientQuotaLimit? ClientQuotaLimit { get; init; }
+
+ ///
+ /// Organization-level quota information, from the Auth0-Organization-Quota-Limit header.
+ /// null if the header was absent or malformed.
+ ///
+ public OrganizationQuotaLimit? OrganizationQuotaLimit { get; init; }
+
+ ///
+ /// Parses rate limit information from the supplied response headers.
+ ///
+ public static RateLimit Parse(ResponseHeaders headers)
+ {
+ return new RateLimit
+ {
+ Limit = GetLong(headers, "x-ratelimit-limit"),
+ Remaining = GetLong(headers, "x-ratelimit-remaining"),
+ Reset = GetReset(headers, "x-ratelimit-reset"),
+ RetryAfter = GetRetryAfter(headers, "retry-after"),
+ ClientQuotaLimit = Auth0.ManagementApi.ClientQuotaLimit.Parse(
+ GetString(headers, "Auth0-Client-Quota-Limit")
+ ),
+ OrganizationQuotaLimit = Auth0.ManagementApi.OrganizationQuotaLimit.Parse(
+ GetString(headers, "Auth0-Organization-Quota-Limit")
+ ),
+ };
+ }
+
+ private static string? GetString(ResponseHeaders headers, string name)
+ {
+ return headers.TryGetValue(name, out var value) ? value : null;
+ }
+
+ private static long? GetLong(ResponseHeaders headers, string name)
+ {
+ if (
+ headers.TryGetValue(name, out var value)
+ && long.TryParse(value, out var result)
+ )
+ {
+ return result;
+ }
+
+ return null;
+ }
+
+ private static DateTimeOffset? GetReset(ResponseHeaders headers, string name)
+ {
+ var seconds = GetLong(headers, name);
+ if (seconds is null)
+ {
+ return null;
+ }
+
+ // Guard against out-of-range values: FromUnixTimeSeconds throws for
+ // seconds outside the representable DateTimeOffset range.
+ try
+ {
+ return DateTimeOffset.FromUnixTimeSeconds(seconds.Value);
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return null;
+ }
+ }
+
+ private static TimeSpan? GetRetryAfter(ResponseHeaders headers, string name)
+ {
+ var seconds = GetLong(headers, name);
+ return seconds is null ? null : TimeSpan.FromSeconds(seconds.Value);
+ }
+}
diff --git a/tests/Auth0.ManagementApi.IntegrationTests/TenantSettingsTests.cs b/tests/Auth0.ManagementApi.IntegrationTests/TenantSettingsTests.cs
index 38f15165..60bee6e2 100644
--- a/tests/Auth0.ManagementApi.IntegrationTests/TenantSettingsTests.cs
+++ b/tests/Auth0.ManagementApi.IntegrationTests/TenantSettingsTests.cs
@@ -160,7 +160,9 @@ public async Task Test_tenant_settings_sequence()
{
ClientCredentials = new TokenQuotaClientCredentials
{
- Enforce = false
+ Enforce = false,
+ PerDay = 200,
+ PerHour = 100
}
},
Organizations = new TokenQuotaConfiguration
diff --git a/tests/Auth0.ManagementApi.Test/Unit/ExceptionEnrichmentTest.cs b/tests/Auth0.ManagementApi.Test/Unit/ExceptionEnrichmentTest.cs
new file mode 100644
index 00000000..6f430567
--- /dev/null
+++ b/tests/Auth0.ManagementApi.Test/Unit/ExceptionEnrichmentTest.cs
@@ -0,0 +1,311 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using Auth0.ManagementApi;
+using Auth0.ManagementApi.Core;
+using NUnit.Framework;
+
+namespace Auth0.ManagementApi.Test.Unit;
+
+[TestFixture]
+public class ExceptionEnrichmentTest
+{
+ private static object Body(string json) => JsonUtils.Deserialize