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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<code>`, `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:<code>`, `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). |
Expand Down Expand Up @@ -1271,6 +1271,20 @@ when the body has no `responseStatus` are the top-level `message` and
`errorCode` read. `Request failed (HTTP <status>)` 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. |
Expand Down
12 changes: 11 additions & 1 deletion src/Norbix.Contracts/NorbixException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,31 @@ public sealed class NorbixException : Exception
/// <summary>Same value as <see cref="RawBody"/> — the answer as it arrived.</summary>
public object? Body => RawBody;

/// <summary>
/// Seconds to wait before sending again, from the answer's <c>Retry-After</c>
/// header: the number itself, or the seconds until the HTTP date it names
/// (never below 0). <c>null</c> when the header is absent or unreadable.
/// A 429 <c>CM-ERRORS-LOGGING-003</c> from <c>WriteLogsAsync</c> carries it.
/// </summary>
public int? RetryAfterSeconds { get; }

public NorbixException(
string message,
int statusCode = 0,
string? code = null,
IReadOnlyList<ResponseError>? fieldErrors = null,
string? url = null,
object? rawBody = null,
Exception? innerException = null)
Exception? innerException = null,
int? retryAfterSeconds = null)
: base(message, innerException)
{
StatusCode = statusCode;
Code = code;
FieldErrors = fieldErrors ?? Array.Empty<ResponseError>();
Url = url;
RawBody = rawBody;
RetryAfterSeconds = retryAfterSeconds;
}
}

Expand Down
27 changes: 24 additions & 3 deletions src/Norbix.Sdk/Transport/HttpTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -337,10 +337,30 @@ private static bool TryGetResponseStatus(JsonElement root, out JsonElement statu
return false;
}

/// <summary>
/// The answer's <c>Retry-After</c> as whole seconds: the delta itself, or the
/// seconds until the HTTP date it names (never below 0). <c>null</c> when the
/// header is absent or unreadable.
/// </summary>
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<NorbixException> BuildExceptionAsync(
Stream body,
int statusCode,
string url,
int? retryAfterSeconds,
CancellationToken cancellationToken)
{
ResponseStatus? status = null;
Expand Down Expand Up @@ -383,7 +403,8 @@ private static async Task<NorbixException> BuildExceptionAsync(
code: code,
fieldErrors: status?.Errors,
url: url,
rawBody: raw);
rawBody: raw,
retryAfterSeconds: retryAfterSeconds);

static string? Pick(string? value) => string.IsNullOrEmpty(value) ? null : value;
}
Expand Down
13 changes: 11 additions & 2 deletions tests/Norbix.Sdk.Tests/Helpers/MockHttpHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>? headers = null
)
{
_responders.Insert(
Expand All @@ -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<string, string>())
{
response.Headers.TryAddWithoutValidation(name, value);
}
return response;
},
}
);
return this;
Expand Down
10 changes: 7 additions & 3 deletions tests/Norbix.Sdk.Tests/Helpers/NorbixTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,18 @@ public static NorbixTestFixture Create(Action<NorbixClientOptions>? configure =
return new NorbixTestFixture(opts);
}

/// <summary>Register a JSON response for a route. Returns <c>this</c> for chaining.</summary>
/// <summary>
/// Register a JSON response for a route, with optional response headers
/// (<c>Retry-After</c>, say). Returns <c>this</c> for chaining.
/// </summary>
public NorbixTestFixture Respond(
string pathSuffix,
object body,
System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK
System.Net.HttpStatusCode status = System.Net.HttpStatusCode.OK,
IReadOnlyDictionary<string, string>? headers = null
)
{
_handler.RespondJson(pathSuffix, body, status);
_handler.RespondJson(pathSuffix, body, status, headers);
return this;
}

Expand Down
119 changes: 119 additions & 0 deletions tests/Norbix.Sdk.Tests/LogsEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NorbixException>(
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<string, string> { ["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<string, string> { ["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<string, string> { ["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<string, string> { ["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));
}
}
Loading