Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 79 additions & 0 deletions src/Auth0.ManagementApi/Core/Public/ApiError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using global::System.Text.Json;

namespace Auth0.ManagementApi;

/// <summary>
/// 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.
/// </summary>
public sealed class ApiError
{
/// <summary>
/// The short error identifier returned by the API (the <c>error</c> field), e.g. "Bad Request".
/// </summary>
public string? Error { get; init; }

/// <summary>
/// The machine-readable error code returned by the API (the <c>errorCode</c> field), if present.
/// </summary>
public string? ErrorCode { get; init; }

/// <summary>
/// The human-readable error description returned by the API. Populated from the first available
/// of <c>message</c>, <c>error_description</c>, <c>description</c>, or <c>error</c>.
/// </summary>
public string? Message { get; init; }

/// <summary>
/// Attempts to build an <see cref="ApiError"/> from a response body. The body is expected to be the
/// boxed <see cref="JsonElement"/> produced by the SDK when deserializing an error response.
/// Returns <c>null</c> when no usable error information can be extracted.
/// </summary>
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;
}
}
61 changes: 61 additions & 0 deletions src/Auth0.ManagementApi/Core/Public/ManagementApiException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// The error code of the response that triggered the exception.
/// </summary>
Expand All @@ -26,6 +31,62 @@ public class ManagementApiException(
/// </summary>
public Auth0.ManagementApi.RawResponse? RawResponse => rawResponse;

/// <summary>
/// Structured error information extracted from the response <see cref="Body"/>, if the API returned a
/// recognizable error payload. Returns <c>null</c> when no error information could be parsed.
/// </summary>
public ApiError? ApiError
{
get
{
if (!_apiErrorParsed)
{
_apiError = Auth0.ManagementApi.ApiError.TryParse(body);
_apiErrorParsed = true;
}

return _apiError;
}
}

/// <summary>
/// The human-readable error description returned by the API, if available.
/// </summary>
public string? Description => ApiError?.Message;

/// <summary>
/// The machine-readable error code returned by the API, if available.
/// </summary>
public string? ErrorCode => ApiError?.ErrorCode;

/// <summary>
/// Rate limit information parsed from the response headers (<c>x-ratelimit-limit</c>,
/// <c>x-ratelimit-remaining</c>, <c>x-ratelimit-reset</c>, <c>retry-after</c>), along with any
/// client and organization quota headers. Returns <c>null</c> when no raw response is available.
/// </summary>
public RateLimit? RateLimit
{
get
{
if (!_rateLimitParsed)
{
_rateLimit =
rawResponse is null
? null
: Auth0.ManagementApi.RateLimit.Parse(rawResponse.Headers);
_rateLimitParsed = true;
}

return _rateLimit;
}
}

/// <summary>
/// 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.
/// </summary>
public override string Message => ApiError?.Message ?? base.Message;

public override string ToString()
{
var sb = new System.Text.StringBuilder();
Expand Down
175 changes: 175 additions & 0 deletions src/Auth0.ManagementApi/Core/Public/QuotaLimit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
using global::System.Collections.Generic;

namespace Auth0.ManagementApi;

/// <summary>
/// Represents the client quota headers (<c>Auth0-Client-Quota-Limit</c>) returned as part of a response.
/// </summary>
public sealed class ClientQuotaLimit
{
/// <summary>
/// The per-hour quota bucket, if present.
/// </summary>
public QuotaLimit? PerHour { get; init; }

/// <summary>
/// The per-day quota bucket, if present.
/// </summary>
public QuotaLimit? PerDay { get; init; }

/// <summary>
/// Parses the <c>Auth0-Client-Quota-Limit</c> header value into a <see cref="ClientQuotaLimit"/>.
/// Returns <c>null</c> when the header is absent or empty.
/// </summary>
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 };
}
}

/// <summary>
/// Represents the organization quota headers (<c>Auth0-Organization-Quota-Limit</c>) returned as part of a response.
/// </summary>
public sealed class OrganizationQuotaLimit
{
/// <summary>
/// The per-hour quota bucket, if present.
/// </summary>
public QuotaLimit? PerHour { get; init; }

/// <summary>
/// The per-day quota bucket, if present.
/// </summary>
public QuotaLimit? PerDay { get; init; }

/// <summary>
/// Parses the <c>Auth0-Organization-Quota-Limit</c> header value into an <see cref="OrganizationQuotaLimit"/>.
/// Returns <c>null</c> when the header is absent or empty.
/// </summary>
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 };
}
}

/// <summary>
/// Represents a single quota bucket returned in the Auth0 quota limit headers.
/// </summary>
public sealed class QuotaLimit
{
/// <summary>
/// The current configured quota.
/// </summary>
public long Quota { get; init; }

/// <summary>
/// The remaining quota.
/// </summary>
public long Remaining { get; init; }

/// <summary>
/// The number of seconds until the quota resets.
/// </summary>
public int ResetAfter { get; init; }

/// <summary>
/// Splits a quota header value (a comma-separated list of buckets) into its per-hour and per-day
/// components. Returns <c>false</c> when the header is absent or empty.
/// </summary>
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<string, string>();
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,
};
}
}
Loading
Loading