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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ Types: `AuthenticationError` (401/403), `NotFoundError` (404),
`RateLimitError` (429), `ValidationError` (400/422), base `Error` otherwise.
Idempotent verbs (GET/DELETE) retry on 429/5xx with exponential backoff.

`Error.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); nil when the header is absent or unreadable. `RateLimitError` also
has it as `RetryAfterSeconds()`. A 429 `CM-ERRORS-LOGGING-003` from
`API.Logs.WriteLogs` carries it: wait that long, then send again.

`Message` and `Code` are the gateway's own. The gateway puts them inside
`responseStatus.errors[]`, so the SDK reads that list first, takes the first
entry for `Message` / `Code`, and keeps every entry in `Errors`. Only when the
Expand Down
16 changes: 14 additions & 2 deletions docs/hub/logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,20 @@ Refusals:
| 429 | `CM-ERRORS-LOGGING-003` | the project's per-minute quota (entries and bytes) is used up; the whole call is refused | `*errors.RateLimitError` |

The SDK never resends a `POST`. On a 429 the gateway sends a `Retry-After`
header with the seconds to wait; the SDK does not surface response headers on
its errors yet, so wait and try again (a few seconds up to a minute).
header with the seconds to wait; the SDK hands it over as
`RateLimitError.RetryAfterSeconds()` (a `*int`, nil when the header is absent
or unreadable — also on the base error as `Error.RetryAfterSeconds`):

```go
var rl *norbixerr.RateLimitError
if errors.As(err, &rl) {
wait := 60 * time.Second
if s := rl.RetryAfterSeconds(); s != nil {
wait = time.Duration(*s) * time.Second
}
time.Sleep(wait) // then send the entries again
}
```

A narrow key for an app: a service user whose only grant is the built-in
policy "Logs — write" (`logging:create`).
Expand Down
5 changes: 3 additions & 2 deletions norbix/api/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ type LogsModule struct{ t *transport.Transport }
// Refusals: HTTP 400 for a broken entry, HTTP 400 CM-ERRORS-LOGGING-002 when
// the Logs module is off, and HTTP 429 CM-ERRORS-LOGGING-003 (a
// *errors.RateLimitError) when the project's per-minute quota is used up — the
// whole call is refused; the gateway's Retry-After header says how many
// seconds to wait. The SDK does not retry a POST.
// whole call is refused; RateLimitError.RetryAfterSeconds() (from the
// gateway's Retry-After header) says how many seconds to wait. The SDK does
// not retry a POST.
func (m *LogsModule) WriteLogs(ctx context.Context, req map[string]any, out any) error {
return m.t.Send(ctx, transport.Request{
Target: transport.TargetAPI,
Expand Down
105 changes: 105 additions & 0 deletions norbix/api/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,108 @@ func TestAPILogsWriteLogsQuotaRefusal(t *testing.T) {
t.Errorf("calls: got %d want 1 (a POST is not retried)", n)
}
}

// answerLogs starts a fake gateway that counts the calls and answers every one
// with the given status, Retry-After (none when empty) and body.
func answerLogs(t *testing.T, status int, retryAfter, body string) (*LogsModule, *int32) {
t.Helper()
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
if retryAfter != "" {
w.Header().Set("Retry-After", retryAfter)
}
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
return newAPILogsTestModule(srv.URL), &calls
}

const logsQuotaBody = `{"responseStatus":{"errorCode":"CM-ERRORS-LOGGING-003","message":"Log quota used up.","errors":[{"errorCode":"CM-ERRORS-LOGGING-003","message":"Log quota used up."}]}}`

func writeOneLog(m *LogsModule) error {
return m.WriteLogs(context.Background(),
map[string]any{"entries": []any{map[string]any{"title": "x"}}}, nil)
}

// A 429 with Retry-After: 37 hands the 37 seconds to the caller, on the
// RateLimitError and on its base error, and the POST is still sent once.
func TestAPILogsWriteLogsQuotaRefusalCarriesRetryAfter(t *testing.T) {
m, calls := answerLogs(t, http.StatusTooManyRequests, "37", logsQuotaBody)

err := writeOneLog(m)

var rl *norbixerr.RateLimitError
if !stderrors.As(err, &rl) {
t.Fatalf("error: got %T %v, want *errors.RateLimitError", err, err)
}
if rl.Base.Code != "CM-ERRORS-LOGGING-003" {
t.Errorf("code: got %q want CM-ERRORS-LOGGING-003", rl.Base.Code)
}
if got := rl.RetryAfterSeconds(); got == nil || *got != 37 {
t.Errorf("RetryAfterSeconds(): got %v want 37", got)
}
var base *norbixerr.Error
if !stderrors.As(err, &base) || base.RetryAfterSeconds == nil || *base.RetryAfterSeconds != 37 {
t.Errorf("base RetryAfterSeconds: got %v want 37", base.RetryAfterSeconds)
}
if n := atomic.LoadInt32(calls); n != 1 {
t.Errorf("calls: got %d want 1 (a POST is not retried)", n)
}
}

// A 429 that names an HTTP date gives the seconds until that date.
func TestAPILogsWriteLogsQuotaRefusalWithAnHTTPDate(t *testing.T) {
at := time.Now().Add(120 * time.Second).UTC().Format(http.TimeFormat)
m, _ := answerLogs(t, http.StatusTooManyRequests, at, logsQuotaBody)

var rl *norbixerr.RateLimitError
if err := writeOneLog(m); !stderrors.As(err, &rl) {
t.Fatalf("error: got %T %v, want *errors.RateLimitError", err, err)
}
if got := rl.RetryAfterSeconds(); got == nil || *got < 115 || *got > 121 {
t.Errorf("RetryAfterSeconds(): got %v want about 120", got)
}
}

// A 429 without Retry-After, or with one that cannot be read, leaves it nil.
func TestAPILogsWriteLogsQuotaRefusalWithoutRetryAfter(t *testing.T) {
for _, header := range []string{"", "soon", "-5"} {
m, calls := answerLogs(t, http.StatusTooManyRequests, header, logsQuotaBody)

var rl *norbixerr.RateLimitError
if err := writeOneLog(m); !stderrors.As(err, &rl) {
t.Fatalf("Retry-After %q: got %T %v, want *errors.RateLimitError", header, err, err)
}
if got := rl.RetryAfterSeconds(); got != nil {
t.Errorf("Retry-After %q: RetryAfterSeconds() got %d want nil", header, *got)
}
if n := atomic.LoadInt32(calls); n != 1 {
t.Errorf("Retry-After %q: calls got %d want 1", header, n)
}
}
}

// A 500 has no Retry-After: the base error leaves it nil.
func TestAPILogsWriteLogs500HasNoRetryAfter(t *testing.T) {
m, calls := answerLogs(t, http.StatusInternalServerError, "",
`{"responseStatus":{"errorCode":"CM-ERRORS-500","message":"Boom"}}`)

err := writeOneLog(m)

var base *norbixerr.Error
if !stderrors.As(err, &base) {
t.Fatalf("error: got %T %v, want *errors.Error", err, err)
}
if base.Status != http.StatusInternalServerError {
t.Errorf("status: got %d want 500", base.Status)
}
if base.RetryAfterSeconds != nil {
t.Errorf("RetryAfterSeconds: got %d want nil", *base.RetryAfterSeconds)
}
if n := atomic.LoadInt32(calls); n != 1 {
t.Errorf("calls: got %d want 1 (a POST is not retried)", n)
}
}
48 changes: 48 additions & 0 deletions norbix/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ package errors
import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)

// Code values used by the SDK itself (network / client-side failures). HTTP
Expand Down Expand Up @@ -60,6 +65,11 @@ type Error struct {
Details map[string]any
// Body is the answer exactly as it arrived, for whoever needs the rest of it.
Body []byte
// RetryAfterSeconds 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). Nil when the header is absent or unreadable. A 429
// CM-ERRORS-LOGGING-003 from WriteLogs carries it.
RetryAfterSeconds *int
// wrapped is an optional underlying error (e.g. the net/http error).
wrapped error
}
Expand Down Expand Up @@ -104,6 +114,11 @@ type RateLimitError struct{ Base *Error }
func (e *RateLimitError) Error() string { return e.Base.Error() }
func (e *RateLimitError) Unwrap() error { return e.Base }

// RetryAfterSeconds is how many seconds to wait before sending again, from
// the answer's Retry-After header; nil when the gateway sent none. Same value
// as Base.RetryAfterSeconds.
func (e *RateLimitError) RetryAfterSeconds() *int { return e.Base.RetryAfterSeconds }

// ValidationError is returned for HTTP 400 / 422.
type ValidationError struct{ Base *Error }

Expand Down Expand Up @@ -160,6 +175,39 @@ func FromBody(raw []byte, status int) error {
return err
}

// FromResponse is FromBody plus the answer's headers: it also reads
// Retry-After into RetryAfterSeconds.
func FromResponse(raw []byte, status int, header http.Header) error {
err := FromBody(raw, status)
baseOf(err).RetryAfterSeconds = RetryAfterSecondsOf(header.Get("Retry-After"), time.Now())
return err
}

// RetryAfterSecondsOf reads a Retry-After header value as whole seconds: the
// number itself, or the seconds from now until the HTTP date it names (never
// below 0). Nil when the value is empty or unreadable.
func RetryAfterSecondsOf(value string, now time.Time) *int {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if f, err := strconv.ParseFloat(value, 64); err == nil {
if f < 0 || f > math.MaxInt32 || math.IsNaN(f) {
return nil
}
n := int(math.Ceil(f))
return &n
}
if at, err := http.ParseTime(value); err == nil {
n := int(math.Ceil(at.Sub(now).Seconds()))
if n < 0 {
n = 0
}
return &n
}
return nil
}

// responseStatusOf returns the responseStatus block, whatever its casing.
func responseStatusOf(body map[string]any) map[string]any {
for _, key := range []string{"responseStatus", "ResponseStatus"} {
Expand Down
30 changes: 30 additions & 0 deletions norbix/errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/norbix-code/sdk-go/v2/norbix/api/dtos"
norbixerr "github.com/norbix-code/sdk-go/v2/norbix/errors"
Expand Down Expand Up @@ -186,3 +187,32 @@ func TestReadsTheTopOfTheBodyWhenThereIsNoResponseStatus(t *testing.T) {
t.Errorf("code: got %q", base.Code)
}
}

// Retry-After is delta seconds or an HTTP date; anything else is no value.
func TestRetryAfterSecondsOf(t *testing.T) {
now := time.Date(2026, 10, 21, 8, 0, 0, 0, time.UTC)
cases := []struct {
value string
want *int
}{
{"37", intPtr(37)},
{" 0 ", intPtr(0)},
{"1.2", intPtr(2)},
{"Wed, 21 Oct 2026 08:01:30 GMT", intPtr(90)},
{"Wed, 21 Oct 2026 07:00:00 GMT", intPtr(0)},
{"", nil},
{"soon", nil},
{"-1", nil},
}
for _, c := range cases {
got := norbixerr.RetryAfterSecondsOf(c.value, now)
switch {
case c.want == nil && got != nil:
t.Errorf("%q: got %d want nil", c.value, *got)
case c.want != nil && (got == nil || *got != *c.want):
t.Errorf("%q: got %v want %d", c.value, got, *c.want)
}
}
}

func intPtr(n int) *int { return &n }
4 changes: 2 additions & 2 deletions norbix/internal/transport/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ func (t *Transport) Send(ctx context.Context, req Request, out any) error {
}

if resp.status >= 400 {
return norbixerr.FromBody(resp.body, resp.status)
return norbixerr.FromResponse(resp.body, resp.status, resp.headers)
}

// An endpoint that answers with a file, not a document: hand the bytes
Expand All @@ -229,7 +229,7 @@ func (t *Transport) Send(ctx context.Context, req Request, out any) error {
// refusal with HTTP 200 and responseStatus.isSuccess=false, and that is a
// failure the caller must see (10b-files, issue #67).
if norbixerr.SaysItFailed(resp.body) {
return norbixerr.FromBody(resp.body, resp.status)
return norbixerr.FromResponse(resp.body, resp.status, resp.headers)
}

if out == nil || resp.status == http.StatusNoContent || len(resp.body) == 0 {
Expand Down
Loading