From a270209f799a66934730204257ad402085752db2 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 21 Oct 2026 21:50:11 +0300 Subject: [PATCH 1/3] feat(errors): expose Retry-After seconds on rate-limit errors errors.Error gains RetryAfterSeconds (*int) and RateLimitError gains RetryAfterSeconds(). The transport now builds errors with the new errors.FromResponse(body, status, header), which reads Retry-After (delta seconds, or seconds until an HTTP date, never below 0; nil when absent or unreadable). FromBody is unchanged. Co-Authored-By: Claude Opus 5.5 --- norbix/api/logs.go | 5 +-- norbix/errors/errors.go | 48 ++++++++++++++++++++++++++ norbix/internal/transport/transport.go | 4 +-- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/norbix/api/logs.go b/norbix/api/logs.go index 6373d43..7b36640 100644 --- a/norbix/api/logs.go +++ b/norbix/api/logs.go @@ -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, diff --git a/norbix/errors/errors.go b/norbix/errors/errors.go index d902ea8..6c1cba5 100644 --- a/norbix/errors/errors.go +++ b/norbix/errors/errors.go @@ -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 @@ -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 } @@ -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 } @@ -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"} { diff --git a/norbix/internal/transport/transport.go b/norbix/internal/transport/transport.go index 6074cb4..54cfa83 100644 --- a/norbix/internal/transport/transport.go +++ b/norbix/internal/transport/transport.go @@ -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 @@ -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 { From 77b033b91a6c77d4290756e77c5b4e1f0856023f Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 21 Oct 2026 21:50:11 +0300 Subject: [PATCH 2/3] test(errors): Retry-After seconds on a WriteLogs 429, absent header, 500 429 with Retry-After: 37 gives 37 with CM-ERRORS-LOGGING-003 and one POST /logs; an HTTP date gives the seconds until it; no header, an unreadable one and a 500 give nil. RetryAfterSecondsOf table test. Co-Authored-By: Claude Opus 5.5 --- norbix/api/logs_test.go | 105 ++++++++++++++++++++++++++++++++++++++++ norbix/errors_test.go | 30 ++++++++++++ 2 files changed, 135 insertions(+) diff --git a/norbix/api/logs_test.go b/norbix/api/logs_test.go index e972ab4..2a15145 100644 --- a/norbix/api/logs_test.go +++ b/norbix/api/logs_test.go @@ -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) + } +} diff --git a/norbix/errors_test.go b/norbix/errors_test.go index 2922eee..46c2c12 100644 --- a/norbix/errors_test.go +++ b/norbix/errors_test.go @@ -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" @@ -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 } From b3dbe6aeafbbaaac9c5f1d8ce3dfbc6234d3fec9 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Wed, 21 Oct 2026 21:50:11 +0300 Subject: [PATCH 3/3] docs(errors): RetryAfterSeconds in the errors section and the Logs page Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++++++ docs/hub/logs.md | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8ab26e7..4cb4e78 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/hub/logs.md b/docs/hub/logs.md index 3083d8b..be81fde 100644 --- a/docs/hub/logs.md +++ b/docs/hub/logs.md @@ -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`).