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(json); + + [Test] + public void Message_UsesApiMessage_WhenBodyHasMessage() + { + var ex = new BadRequestError(Body("""{"message": "Payload validation error."}""")); + Assert.That(ex.Message, Is.EqualTo("Payload validation error.")); + } + + [Test] + public void Message_UsesErrorDescription_WhenMessageMissing() + { + var ex = new BadRequestError(Body("""{"error_description": "invalid scope"}""")); + Assert.That(ex.Message, Is.EqualTo("invalid scope")); + } + + [Test] + public void Message_FallsBackToDefault_WhenBodyHasNoUsableFields() + { + var ex = new BadRequestError(Body("""{"unrelated": "x"}""")); + Assert.That(ex.Message, Is.EqualTo("BadRequestError")); + } + + [Test] + public void Message_FallsBackToDefault_WhenBodyIsNotAnObject() + { + var ex = new BadRequestError(Body("""["not","an","object"]""")); + Assert.That(ex.Message, Is.EqualTo("BadRequestError")); + } + + [Test] + public void ApiError_ExposesErrorAndErrorCodeAndMessage() + { + var ex = new BadRequestError( + Body( + """{"error": "Bad Request", "errorCode": "invalid_body", "message": "bad"}""" + ) + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.ApiError, Is.Not.Null); + Assert.That(ex.ApiError!.Error, Is.EqualTo("Bad Request")); + Assert.That(ex.ErrorCode, Is.EqualTo("invalid_body")); + Assert.That(ex.Description, Is.EqualTo("bad")); + } + } + + [Test] + public void ApiError_IsNull_WhenNoErrorInfoPresent() + { + var ex = new BadRequestError(Body("""{"unrelated": "x"}""")); + Assert.That(ex.ApiError, Is.Null); + } + + [Test] + public void RateLimit_ParsesHeaders_FromTooManyRequestsError() + { + var reset = DateTimeOffset.UtcNow.AddSeconds(120).ToUnixTimeSeconds(); + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("x-ratelimit-limit", "100"), + ("x-ratelimit-remaining", "0"), + ("x-ratelimit-reset", reset.ToString()), + ("retry-after", "30") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.Message, Is.EqualTo("Too many requests")); + Assert.That(ex.RateLimit, Is.Not.Null); + Assert.That(ex.RateLimit!.Limit, Is.EqualTo(100)); + Assert.That(ex.RateLimit.Remaining, Is.EqualTo(0)); + Assert.That(ex.RateLimit.Reset, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(reset))); + Assert.That(ex.RateLimit.RetryAfter, Is.EqualTo(TimeSpan.FromSeconds(30))); + } + } + + [Test] + public void RateLimit_ParsesHeaders_CaseInsensitively() + { + // The Auth0 API returns these headers capitalized (e.g. "X-RateLimit-Limit"), + // while RateLimit.Parse looks them up in lowercase. HTTP header lookups are + // case-insensitive, so this must still resolve. + var reset = DateTimeOffset.UtcNow.AddSeconds(60).ToUnixTimeSeconds(); + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("X-RateLimit-Limit", "50"), + ("X-RateLimit-Remaining", "10"), + ("X-RateLimit-Reset", reset.ToString()), + ("Retry-After", "15") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit, Is.Not.Null); + Assert.That(ex.RateLimit!.Limit, Is.EqualTo(50)); + Assert.That(ex.RateLimit.Remaining, Is.EqualTo(10)); + Assert.That(ex.RateLimit.Reset, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(reset))); + Assert.That(ex.RateLimit.RetryAfter, Is.EqualTo(TimeSpan.FromSeconds(15))); + } + } + + [Test] + public void RateLimit_ReturnsNullValues_WhenHeadersMissing() + { + var raw = BuildRawResponse((int)HttpStatusCode.TooManyRequests); + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit, Is.Not.Null); + Assert.That(ex.RateLimit!.Limit, Is.Null); + Assert.That(ex.RateLimit.Remaining, Is.Null); + Assert.That(ex.RateLimit.Reset, Is.Null); + Assert.That(ex.RateLimit.RetryAfter, Is.Null); + } + } + + [Test] + public void RateLimit_IsNull_WhenNoRawResponse() + { + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}""")); + Assert.That(ex.RateLimit, Is.Null); + } + + [Test] + public void RateLimit_IsExposedOnBaseException_ForNon429Errors() + { + // RateLimit lives on the base ManagementApiException, so any error type can surface + // rate limit headers when the API includes them (not just TooManyRequestsError). + var reset = DateTimeOffset.UtcNow.AddSeconds(60).ToUnixTimeSeconds(); + var raw = BuildRawResponse( + (int)HttpStatusCode.BadRequest, + ("x-ratelimit-limit", "100"), + ("x-ratelimit-remaining", "5"), + ("x-ratelimit-reset", reset.ToString()), + ("retry-after", "10") + ); + + ManagementApiException ex = new BadRequestError( + Body("""{"message": "Payload validation error."}"""), + raw + ); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit, Is.Not.Null); + Assert.That(ex.RateLimit!.Limit, Is.EqualTo(100)); + Assert.That(ex.RateLimit.Remaining, Is.EqualTo(5)); + Assert.That(ex.RateLimit.Reset, Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(reset))); + Assert.That(ex.RateLimit.RetryAfter, Is.EqualTo(TimeSpan.FromSeconds(10))); + } + } + + [Test] + public void RateLimit_ParsesClientAndOrganizationQuota() + { + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("Auth0-Client-Quota-Limit", "b=per_hour;q=100;r=90;t=3600,b=per_day;q=1000;r=950;t=86400"), + ("Auth0-Organization-Quota-Limit", "b=per_hour;q=50;r=25;t=1800") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit!.ClientQuotaLimit, Is.Not.Null); + Assert.That(ex.RateLimit.ClientQuotaLimit!.PerHour!.Quota, Is.EqualTo(100)); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerHour.Remaining, Is.EqualTo(90)); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerHour.ResetAfter, Is.EqualTo(3600)); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerDay!.Quota, Is.EqualTo(1000)); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerDay.Remaining, Is.EqualTo(950)); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerDay.ResetAfter, Is.EqualTo(86400)); + + Assert.That(ex.RateLimit.OrganizationQuotaLimit, Is.Not.Null); + Assert.That(ex.RateLimit.OrganizationQuotaLimit!.PerHour!.Quota, Is.EqualTo(50)); + Assert.That(ex.RateLimit.OrganizationQuotaLimit.PerHour.Remaining, Is.EqualTo(25)); + Assert.That(ex.RateLimit.OrganizationQuotaLimit.PerHour.ResetAfter, Is.EqualTo(1800)); + Assert.That(ex.RateLimit.OrganizationQuotaLimit.PerDay, Is.Null); + } + } + + [Test] + public void RateLimit_QuotaIsNull_WhenHeadersMissing() + { + var raw = BuildRawResponse((int)HttpStatusCode.TooManyRequests); + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit!.ClientQuotaLimit, Is.Null); + Assert.That(ex.RateLimit.OrganizationQuotaLimit, Is.Null); + } + } + + [Test] + public void RateLimit_QuotaBucketIsNull_WhenHeaderMalformed() + { + // Missing the required 'q' key in the bucket makes it unparseable. + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("Auth0-Client-Quota-Limit", "b=per_hour;r=90;t=3600") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + // The header is present, so ClientQuotaLimit is non-null, but the bucket did not parse. + Assert.That(ex.RateLimit!.ClientQuotaLimit, Is.Not.Null); + Assert.That(ex.RateLimit.ClientQuotaLimit!.PerDay, Is.Null); + } + } + + [Test] + public void RateLimit_Reset_IsNull_WhenHeaderOutOfRange() + { + // A value beyond DateTimeOffset's representable range must not throw + // when RateLimit.Reset is accessed; it should surface as null. + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("x-ratelimit-reset", "99999999999999999") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + Assert.That(ex.RateLimit!.Reset, Is.Null); + } + + [Test] + public void RateLimit_QuotaValues_HandleLargeValues() + { + // Quota values can exceed int.MaxValue, so they are parsed as long. + var quota = (long)int.MaxValue + 1; + var remaining = (long)int.MaxValue + 2; + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("Auth0-Client-Quota-Limit", $"b=per_hour;q={quota};r={remaining};t=3600") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit!.ClientQuotaLimit!.PerHour!.Quota, Is.EqualTo(quota)); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerHour.Remaining, Is.EqualTo(remaining)); + } + } + + [Test] + public void RateLimit_UnknownQuotaBucket_IsIgnored() + { + // A bucket name other than per_hour/per_day must not be misclassified. + var raw = BuildRawResponse( + (int)HttpStatusCode.TooManyRequests, + ("Auth0-Client-Quota-Limit", "b=per_minute;q=100;r=90;t=60") + ); + + var ex = new TooManyRequestsError(Body("""{"message": "Too many requests"}"""), raw); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ex.RateLimit!.ClientQuotaLimit, Is.Not.Null); + Assert.That(ex.RateLimit.ClientQuotaLimit!.PerHour, Is.Null); + Assert.That(ex.RateLimit.ClientQuotaLimit.PerDay, Is.Null); + } + } + + private static RawResponse BuildRawResponse( + int statusCode, + params (string Name, string Value)[] headers + ) + { + var response = new HttpResponseMessage((HttpStatusCode)statusCode) + { + RequestMessage = new HttpRequestMessage( + HttpMethod.Get, + "https://tenant.auth0.com/api/v2/users" + ), + }; + foreach (var (name, value) in headers) + { + response.Headers.TryAddWithoutValidation(name, value); + } + + return new RawResponse + { + StatusCode = (HttpStatusCode)statusCode, + Url = response.RequestMessage!.RequestUri!, + Headers = ResponseHeaders.FromHttpResponseMessage(response), + }; + } +}