diff --git a/README.md b/README.md index 40acf2f..61fd586 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ The public endpoint surface is generated from gateway DTOs at compile time. The | `database` | 22 | Collection CRUD (`FindAsync`, `FindOneAsync`, `FindOwnAsync`, insert / update / replace / delete one or many), count, distinct, aggregate, saved aggregate execution (`ExecuteAggregateAsync`), `ChangeResponsibilityAsync`, schema reads, taxonomy and term reads (`FindTermsAsync`, `FindTermsChildrenAsync`, `FindTaxonomyTreeAsync`, `FindTermTreeAsync`, `FindMergedTermTreeAsync`). | | `echo` | 1 | Smoke-test echo endpoint. | | `files` | 13 | List, info, signed URL, upload URL + commit, download, delete one / many, public links, and a file by its stored id (`GetFileByIdAsync`). | -| `logs` | 1 | `WriteLogsAsync` sends your app's own log entries to the project's logs: 1–100 `WriteLogEntry` per call (`Title` up to 200 characters; optional `Level` `information` / `warning` / `error`, `Message`, `EventCode` stored as `app:`, `CorrelationId`, `Timestamp`, `Meta` up to 20 keys). It answers `Accepted` at once — the entries are queued. Over the project's per-minute quota it throws `NorbixException` with `HttpStatus` 429 and `CM-ERRORS-LOGGING-003` (wait a minute and retry); `CM-ERRORS-LOGGING-002` means the Logs module is off. | +| `logs` | 1 | `WriteLogsAsync` sends your app's own log entries to the project's logs: 1–100 `WriteLogEntry` per call (`Title` up to 200 characters; optional `Level` `information` / `warning` / `error`, `Message`, `EventCode` stored as `app:`, `CorrelationId`, `Timestamp`, `Meta` up to 20 keys). It answers `Accepted` at once — the entries are queued. Over the project's per-minute quota it throws `NorbixException` with `HttpStatus` 429 and `CM-ERRORS-LOGGING-003`; `RetryAfterSeconds` says how many seconds to wait before sending again (the SDK never resends a POST itself); `CM-ERRORS-LOGGING-002` means the Logs module is off. | | `membership` | 18 | User CRUD, registration, preferences, roles, and permissions. | | `payments` | 4 | Payment orders and checkout: `CreatePaymentOrderAsync`, `GetPaymentOrderAsync`, `GetPaymentOrdersAsync`, `StartPaymentCheckoutAsync`. See [Payment orders and checkout](#payment-orders-and-checkout). | | `public` | 2 | The project's public config and legal documents, read by the Admin Portal before sign-in (`GetPublicProjectConfigAsync`, `GetPublicProjectLegalAsync`). See [docs/hub/project.md](./docs/hub/project.md#public-config-and-legal-no-sign-in). | @@ -1271,6 +1271,20 @@ when the body has no `responseStatus` are the top-level `message` and `errorCode` read. `Request failed (HTTP )` is the last fallback, used when the body says nothing (for example a 500 page that is not JSON). +`RetryAfterSeconds` (`int?`) is the answer's `Retry-After` header in whole +seconds — the number itself, or the seconds until the HTTP date it names (never +below 0). It is `null` when the header is absent or unreadable. A 429 carries +it, for example `CM-ERRORS-LOGGING-003` from `WriteLogsAsync` when the project's +per-minute log quota is used up: + +```csharp +catch (NorbixException ex) when (ex.HttpStatus == 429) +{ + await Task.Delay(TimeSpan.FromSeconds(ex.RetryAfterSeconds ?? 60)); + // send the entries again +} +``` + | Code | Meaning | | --- | --- | | `NORBIX_NOT_AUTHENTICATED` | No `ApiKey`, `BearerToken`, or env var, and `LoginAsync` was not called. | diff --git a/src/Norbix.Contracts/NorbixException.cs b/src/Norbix.Contracts/NorbixException.cs index e864000..6a30d09 100644 --- a/src/Norbix.Contracts/NorbixException.cs +++ b/src/Norbix.Contracts/NorbixException.cs @@ -38,6 +38,14 @@ public sealed class NorbixException : Exception /// Same value as — the answer as it arrived. public object? Body => RawBody; + /// + /// Seconds to wait before sending again, from the answer's Retry-After + /// header: the number itself, or the seconds until the HTTP date it names + /// (never below 0). null when the header is absent or unreadable. + /// A 429 CM-ERRORS-LOGGING-003 from WriteLogsAsync carries it. + /// + public int? RetryAfterSeconds { get; } + public NorbixException( string message, int statusCode = 0, @@ -45,7 +53,8 @@ public NorbixException( IReadOnlyList? fieldErrors = null, string? url = null, object? rawBody = null, - Exception? innerException = null) + Exception? innerException = null, + int? retryAfterSeconds = null) : base(message, innerException) { StatusCode = statusCode; @@ -53,6 +62,7 @@ public NorbixException( FieldErrors = fieldErrors ?? Array.Empty(); Url = url; RawBody = rawBody; + RetryAfterSeconds = retryAfterSeconds; } } diff --git a/src/Norbix.Sdk/Transport/HttpTransport.cs b/src/Norbix.Sdk/Transport/HttpTransport.cs index bf388ac..4736fac 100644 --- a/src/Norbix.Sdk/Transport/HttpTransport.cs +++ b/src/Norbix.Sdk/Transport/HttpTransport.cs @@ -233,7 +233,7 @@ public HttpTransport(NorbixClientOptions options, HttpClient? httpClient = null, using var ms = new MemoryStream(); await stream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); ms.Position = 0; - throw await BuildExceptionAsync(ms, (int)response.StatusCode, url, cancellationToken) + throw await BuildExceptionAsync(ms, (int)response.StatusCode, url, RetryAfterSecondsOf(response), cancellationToken) .ConfigureAwait(false); } @@ -276,7 +276,7 @@ public HttpTransport(NorbixClientOptions options, HttpClient? httpClient = null, if (SaysItFailed(buffer)) { buffer.Position = 0; - throw await BuildExceptionAsync(buffer, (int)response.StatusCode, url, cancellationToken) + throw await BuildExceptionAsync(buffer, (int)response.StatusCode, url, RetryAfterSecondsOf(response), cancellationToken) .ConfigureAwait(false); } @@ -337,10 +337,30 @@ private static bool TryGetResponseStatus(JsonElement root, out JsonElement statu return false; } + /// + /// The answer's Retry-After as whole seconds: the delta itself, or the + /// seconds until the HTTP date it names (never below 0). null when the + /// header is absent or unreadable. + /// + internal static int? RetryAfterSecondsOf(HttpResponseMessage response) + { + var header = response.Headers.RetryAfter; + if (header?.Delta is { } delta) + { + return (int)Math.Ceiling(Math.Max(0, delta.TotalSeconds)); + } + if (header?.Date is { } date) + { + return (int)Math.Max(0, Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds)); + } + return null; + } + private static async Task BuildExceptionAsync( Stream body, int statusCode, string url, + int? retryAfterSeconds, CancellationToken cancellationToken) { ResponseStatus? status = null; @@ -383,7 +403,8 @@ private static async Task BuildExceptionAsync( code: code, fieldErrors: status?.Errors, url: url, - rawBody: raw); + rawBody: raw, + retryAfterSeconds: retryAfterSeconds); static string? Pick(string? value) => string.IsNullOrEmpty(value) ? null : value; } diff --git a/tests/Norbix.Sdk.Tests/Helpers/MockHttpHandler.cs b/tests/Norbix.Sdk.Tests/Helpers/MockHttpHandler.cs index 0eed78f..634d1e3 100644 --- a/tests/Norbix.Sdk.Tests/Helpers/MockHttpHandler.cs +++ b/tests/Norbix.Sdk.Tests/Helpers/MockHttpHandler.cs @@ -29,7 +29,8 @@ internal sealed class MockHttpHandler : HttpMessageHandler public MockHttpHandler RespondJson( string pathSuffix, object body, - HttpStatusCode status = HttpStatusCode.OK + HttpStatusCode status = HttpStatusCode.OK, + IReadOnlyDictionary? headers = null ) { _responders.Insert( @@ -39,7 +40,15 @@ public MockHttpHandler RespondJson( Match = req => req.RequestUri?.AbsolutePath.EndsWith(pathSuffix, StringComparison.Ordinal) == true, - Build = () => Json(body, status), + Build = () => + { + var response = Json(body, status); + foreach (var (name, value) in headers ?? new Dictionary()) + { + response.Headers.TryAddWithoutValidation(name, value); + } + return response; + }, } ); return this; diff --git a/tests/Norbix.Sdk.Tests/Helpers/NorbixTestFixture.cs b/tests/Norbix.Sdk.Tests/Helpers/NorbixTestFixture.cs index 421cc30..f7b42dd 100644 --- a/tests/Norbix.Sdk.Tests/Helpers/NorbixTestFixture.cs +++ b/tests/Norbix.Sdk.Tests/Helpers/NorbixTestFixture.cs @@ -50,14 +50,18 @@ public static NorbixTestFixture Create(Action? configure = return new NorbixTestFixture(opts); } - /// Register a JSON response for a route. Returns this for chaining. + /// + /// Register a JSON response for a route, with optional response headers + /// (Retry-After, say). Returns this for chaining. + /// public NorbixTestFixture Respond( string pathSuffix, object body, - System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK + System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK, + IReadOnlyDictionary? headers = null ) { - _handler.RespondJson(pathSuffix, body, status); + _handler.RespondJson(pathSuffix, body, status, headers); return this; } diff --git a/tests/Norbix.Sdk.Tests/LogsEndpointTests.cs b/tests/Norbix.Sdk.Tests/LogsEndpointTests.cs index 6748356..d768738 100644 --- a/tests/Norbix.Sdk.Tests/LogsEndpointTests.cs +++ b/tests/Norbix.Sdk.Tests/LogsEndpointTests.cs @@ -86,4 +86,123 @@ public void WriteLogs_over_the_minute_quota_throws_429_with_the_logging_code() Assert.That(ex.HttpStatus, Is.EqualTo(429)); Assert.That(ex.ErrorCode, Is.EqualTo("CM-ERRORS-LOGGING-003")); } + + private static object QuotaRefusal => new + { + responseStatus = new + { + isSuccess = false, + errorCode = "CM-ERRORS-LOGGING-003", + message = "The project's log quota for this minute is used up", + errors = new[] + { + new + { + message = "The project's log quota for this minute is used up", + errorCode = "CM-ERRORS-LOGGING-003", + }, + }, + }, + }; + + private static NorbixException WriteOneAndCatch(NorbixTestFixture fixture) => + Assert.ThrowsAsync( + async () => await fixture.Client.Logs.WriteLogsAsync(new WriteLogs + { + Entries = [new WriteLogEntry { Title = "Order paid" }], + }))!; + + [Test] + public void WriteLogs_429_carries_the_Retry_After_seconds() + { + using var fixture = NorbixTestFixture.Create(); + fixture.Respond( + "/logs", + QuotaRefusal, + HttpStatusCode.TooManyRequests, + new Dictionary { ["Retry-After"] = "37" }); + + var ex = WriteOneAndCatch(fixture); + + Assert.That(ex.HttpStatus, Is.EqualTo(429)); + Assert.That(ex.ErrorCode, Is.EqualTo("CM-ERRORS-LOGGING-003")); + Assert.That(ex.RetryAfterSeconds, Is.EqualTo(37)); + // A POST is never resent by the SDK: one call, one request. + Assert.That(fixture.RecordedRequests, Has.Count.EqualTo(1)); + Assert.That(fixture.LastRequest!.Method, Is.EqualTo("POST")); + } + + [Test] + public void WriteLogs_429_with_an_HTTP_date_gives_the_seconds_until_it() + { + using var fixture = NorbixTestFixture.Create(); + var at = DateTimeOffset.UtcNow.AddSeconds(120).ToString("R"); + fixture.Respond( + "/logs", + QuotaRefusal, + HttpStatusCode.TooManyRequests, + new Dictionary { ["Retry-After"] = at }); + + var ex = WriteOneAndCatch(fixture); + + Assert.That(ex.RetryAfterSeconds, Is.InRange(115, 121)); + } + + [Test] + public void WriteLogs_429_with_a_past_HTTP_date_gives_zero() + { + using var fixture = NorbixTestFixture.Create(); + fixture.Respond( + "/logs", + QuotaRefusal, + HttpStatusCode.TooManyRequests, + new Dictionary { ["Retry-After"] = "Wed, 21 Oct 2015 07:28:00 GMT" }); + + var ex = WriteOneAndCatch(fixture); + + Assert.That(ex.RetryAfterSeconds, Is.EqualTo(0)); + } + + [Test] + public void WriteLogs_429_without_Retry_After_leaves_it_null() + { + using var fixture = NorbixTestFixture.Create(); + fixture.Respond("/logs", QuotaRefusal, HttpStatusCode.TooManyRequests); + + var ex = WriteOneAndCatch(fixture); + + Assert.That(ex.HttpStatus, Is.EqualTo(429)); + Assert.That(ex.RetryAfterSeconds, Is.Null); + } + + [Test] + public void WriteLogs_429_with_an_unreadable_Retry_After_leaves_it_null() + { + using var fixture = NorbixTestFixture.Create(); + fixture.Respond( + "/logs", + QuotaRefusal, + HttpStatusCode.TooManyRequests, + new Dictionary { ["Retry-After"] = "soon" }); + + var ex = WriteOneAndCatch(fixture); + + Assert.That(ex.RetryAfterSeconds, Is.Null); + } + + [Test] + public void WriteLogs_500_leaves_Retry_After_null() + { + using var fixture = NorbixTestFixture.Create(); + fixture.Respond( + "/logs", + new { responseStatus = new { isSuccess = false, errorCode = "CM-ERRORS-500", message = "Boom" } }, + HttpStatusCode.InternalServerError); + + var ex = WriteOneAndCatch(fixture); + + Assert.That(ex.HttpStatus, Is.EqualTo(500)); + Assert.That(ex.RetryAfterSeconds, Is.Null); + Assert.That(fixture.RecordedRequests, Has.Count.EqualTo(1)); + } }