From f72c8daf3ba443dfe073cd2a144c8321e893b8b5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Mon, 30 Mar 2026 13:36:30 +0000 Subject: [PATCH] refactor(ax): align recovery helpers with AX docs Co-Authored-By: Claude Opus 4.5 --- docs/architecture.md | 6 ++++++ docs/index.md | 12 +++++++---- errors.go | 47 ++++++++++++++++++++++++++++++++++---------- errors_test.go | 14 +++++++++++++ specs/RFC.md | 46 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 109 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 08c87ad..d36f7f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,6 +63,9 @@ type Err struct { Msg string // human-readable description Err error // underlying cause (optional) Code string // machine-readable code (optional, e.g. "VALIDATION_FAILED") + Retryable bool // whether the caller can retry the operation + RetryAfter *time.Duration // optional retry delay hint + NextAction string // suggested next step when not retryable } ``` @@ -115,6 +118,9 @@ log(LevelInfo, "[INF]", ...) | if any value implements `error`: | extract Op -> append "op" key if not already present | extract FormatStackTrace -> append "stack" key if not already present + | extract recovery hints -> append "retryable", + | "retry_after_seconds", + | "next_action" if not already present +-- format key-value pairs: | string values -> %q (quoted, injection-safe) | other values -> %v diff --git a/docs/index.md b/docs/index.md index 945ed86..da399e9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,7 @@ description: Structured logging and error handling for Core applications # go-log -`forge.lthn.ai/core/go-log` provides structured logging and contextual error +`dappco.re/go/core/log` provides structured logging and contextual error handling for Go applications built on the Core framework. It is a small, zero-dependency library (only `testify` at test time) that replaces ad-hoc `fmt.Println` / `log.Printf` calls with level-filtered, key-value structured @@ -15,7 +15,7 @@ stack. ## Quick Start ```go -import "forge.lthn.ai/core/go-log" +import "dappco.re/go/core/log" // Use the package-level default logger straight away log.SetLevel(log.LevelDebug) @@ -54,6 +54,10 @@ log.Op(err) // "user.Save" log.Root(err) // the original underlyingErr log.StackTrace(err) // ["user.Save", "db.Connect"] log.FormatStackTrace(err) // "user.Save -> db.Connect" + +// Recovery hints are also available when the error carries them +log.IsRetryable(err) // false unless a wrapped Err marks it retryable +log.RecoveryAction(err) // "retry with backoff" when provided ``` ### Combined Log-and-Return @@ -70,7 +74,7 @@ if err != nil { | File | Purpose | |------|---------| | `log.go` | Logger type, log levels, key-value formatting, redaction, default logger, `Username()` helper | -| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) | +| `errors.go` | `Err` structured error type, creation helpers (`E`, `Wrap`, `WrapCode`, `NewCode`, and recovery-aware variants), introspection (`Op`, `ErrCode`, `Root`, `StackTrace`, recovery hints), combined log-and-return helpers (`LogError`, `LogWarn`, `Must`) | | `log_test.go` | Tests for the Logger: level filtering, key-value output, redaction, injection prevention, security logging | | `errors_test.go` | Tests for structured errors: creation, wrapping, code propagation, introspection, stack traces, log-and-return helpers | @@ -89,7 +93,7 @@ code. ## Module Path ``` -forge.lthn.ai/core/go-log +dappco.re/go/core/log ``` Requires **Go 1.26+** (uses `iter.Seq` from the standard library). diff --git a/errors.go b/errors.go index 67a1115..a2420a2 100644 --- a/errors.go +++ b/errors.go @@ -7,7 +7,6 @@ package log import ( "errors" - "fmt" "iter" "strings" "time" @@ -30,20 +29,34 @@ type Err struct { // Error implements the error interface. func (e *Err) Error() string { - var prefix string - if e.Op != "" { - prefix = e.Op + ": " + if e == nil { + return "" } - if e.Err != nil { + + body := e.Msg + if body == "" { if e.Code != "" { - return fmt.Sprintf("%s%s [%s]: %v", prefix, e.Msg, e.Code, e.Err) + body = "[" + e.Code + "]" } - return fmt.Sprintf("%s%s: %v", prefix, e.Msg, e.Err) + } else if e.Code != "" { + body += " [" + e.Code + "]" } - if e.Code != "" { - return fmt.Sprintf("%s%s [%s]", prefix, e.Msg, e.Code) + + if e.Err != nil { + if body != "" { + body += ": " + e.Err.Error() + } else { + body = e.Err.Error() + } } - return fmt.Sprintf("%s%s", prefix, e.Msg) + + if e.Op != "" { + if body != "" { + return e.Op + ": " + body + } + return e.Op + } + return body } // Unwrap returns the underlying error for use with errors.Is and errors.As. @@ -65,6 +78,8 @@ func E(op, msg string, err error) error { } // EWithRecovery creates a new Err with operation context and recovery metadata. +// +// return log.EWithRecovery("api.Call", "temporary failure", err, true, &retryAfter, "retry with backoff") func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error { recoveryErr := &Err{ Op: op, @@ -95,6 +110,8 @@ func Wrap(err error, op, msg string) error { } // WrapWithRecovery wraps an error with operation context and explicit recovery metadata. +// +// return log.WrapWithRecovery(err, "api.Call", "temporary failure", true, &retryAfter, "retry with backoff") func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { if err == nil { return nil @@ -132,6 +149,8 @@ func WrapCode(err error, code, op, msg string) error { } // WrapCodeWithRecovery wraps an error with operation context, code, and recovery metadata. +// +// return log.WrapCodeWithRecovery(err, "TEMPORARY_UNAVAILABLE", "api.Call", "temporary failure", true, &retryAfter, "retry with backoff") func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { if code == "" { code = ErrCode(err) @@ -163,6 +182,8 @@ func NewCode(code, msg string) error { } // NewCodeWithRecovery creates a coded error with recovery metadata. +// +// var ErrTemporary = log.NewCodeWithRecovery("TEMPORARY_UNAVAILABLE", "temporary failure", true, &retryAfter, "retry with backoff") func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { return &Err{ Msg: msg, @@ -187,6 +208,8 @@ func inheritRecovery(dst *Err, err error) { } // RetryAfter returns the first retry-after hint from an error chain, if present. +// +// retryAfter, ok := log.RetryAfter(err) func RetryAfter(err error) (*time.Duration, bool) { var wrapped *Err if As(err, &wrapped) { @@ -198,6 +221,8 @@ func RetryAfter(err error) (*time.Duration, bool) { } // IsRetryable reports whether the error chain contains a retryable Err. +// +// if log.IsRetryable(err) { /* retry the operation */ } func IsRetryable(err error) bool { var wrapped *Err if As(err, &wrapped) { @@ -207,6 +232,8 @@ func IsRetryable(err error) bool { } // RecoveryAction returns the first next action from an error chain. +// +// next := log.RecoveryAction(err) func RecoveryAction(err error) string { var wrapped *Err if As(err, &wrapped) { diff --git a/errors_test.go b/errors_test.go index c09bba0..5296524 100644 --- a/errors_test.go +++ b/errors_test.go @@ -45,6 +45,20 @@ func TestErr_Error_EmptyOp_Good(t *testing.T) { assert.Equal(t, "wrapped: underlying", err.Error()) } +func TestErr_Error_EmptyMsg_Good(t *testing.T) { + err := &Err{Op: "api.Call", Code: "TIMEOUT"} + assert.Equal(t, "api.Call: [TIMEOUT]", err.Error()) + + err = &Err{Op: "api.Call", Err: errors.New("underlying")} + assert.Equal(t, "api.Call: underlying", err.Error()) + + err = &Err{Op: "api.Call", Code: "TIMEOUT", Err: errors.New("underlying")} + assert.Equal(t, "api.Call: [TIMEOUT]: underlying", err.Error()) + + err = &Err{Op: "api.Call"} + assert.Equal(t, "api.Call", err.Error()) +} + func TestErr_Unwrap_Good(t *testing.T) { underlying := errors.New("underlying error") err := &Err{Op: "test", Msg: "wrapped", Err: underlying} diff --git a/specs/RFC.md b/specs/RFC.md index 4f02d71..78aaf1d 100644 --- a/specs/RFC.md +++ b/specs/RFC.md @@ -8,15 +8,20 @@ `type Err struct` Structured error wrapper that carries operation context, a human-readable message, an optional wrapped cause, and an optional machine-readable code. +It can also carry agent-facing recovery metadata that survives wrapping and is +surfaced automatically in structured logs. Fields: - `Op string`: operation name. When non-empty, `Error` prefixes the formatted message with `Op + ": "`. - `Msg string`: human-readable message stored on the error and returned by `Message`. - `Err error`: wrapped cause returned by `Unwrap`. - `Code string`: optional machine-readable code. When non-empty, `Error` includes it in square brackets. +- `Retryable bool`: whether the caller can safely retry the operation. +- `RetryAfter *time.Duration`: optional retry delay hint when `Retryable` is true. +- `NextAction string`: suggested next step when the error is not directly retryable. Methods: -- `func (e *Err) Error() string`: formats the error text from `Op`, `Msg`, `Code`, and `Err`. The result omits missing parts, so the output can be `"{Msg}"`, `"{Msg} [{Code}]"`, `"{Msg}: {Err}"`, or `"{Op}: {Msg} [{Code}]: {Err}"`. +- `func (e *Err) Error() string`: formats the error text from `Op`, `Msg`, `Code`, and `Err`. The result omits missing parts, so the output can be `"{Msg}"`, `"{Msg} [{Code}]"`, `"{Msg}: {Err}"`, `"{Op}: {Msg} [{Code}]: {Err}"`, or a cleanly collapsed form such as `"{Op}"` when no message, code, or cause is present. - `func (e *Err) Unwrap() error`: returns `e.Err`. ### Level @@ -32,7 +37,7 @@ Methods: Concurrency-safe structured logger. `New` clones the configured redaction keys, stores the configured level and writer, and initializes all style hooks to identity functions. -Each log call writes one line in the form `HH:MM:SS {prefix} {msg}` followed by space-separated key/value pairs. String values are rendered with Go `%q` quoting, redacted keys are replaced with `"[REDACTED]"`, a trailing key without a value renders as ``, and any `error` value in `keyvals` can cause `op` and `stack` fields to be appended automatically if those keys were not already supplied. +Each log call writes one line in the form `HH:MM:SS {prefix} {msg}` followed by space-separated key/value pairs. String values are rendered with Go `%q` quoting, redacted keys are replaced with `"[REDACTED]"`, a trailing key without a value renders as ``, and any `error` value in `keyvals` can cause `op`, `stack`, `retryable`, `retry_after_seconds`, and `next_action` fields to be appended automatically if those keys were not already supplied. Fields: - `StyleTimestamp func(string) string`: transforms the rendered `HH:MM:SS` timestamp before it is written. @@ -103,6 +108,12 @@ Returns the package-level default logger. The package initializes it with `New(O Returns `&Err{Op: op, Msg: msg, Err: err}` as an `error`. It always returns a non-nil error value, even when `err` is nil. +### EWithRecovery +`func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns `&Err{Op: op, Msg: msg, Err: err}` with explicit recovery metadata +attached to the new error value. + ### ErrCode `func ErrCode(err error) string` @@ -133,6 +144,16 @@ Thin wrapper around `errors.Is`. Thin wrapper around `errors.Join`. +### IsRetryable +`func IsRetryable(err error) bool` + +Returns whether the first matching `*Err` in the chain is marked retryable. + +### NewCodeWithRecovery +`func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns `&Err{Msg: msg, Code: code}` with recovery metadata attached. + ### LogError `func LogError(err error, op, msg string) error` @@ -163,6 +184,16 @@ Constructs a logger from `opts`. It prefers a rotating writer only when `opts.Ro Returns `&Err{Msg: msg, Code: code}` as an `error`. +### RecoveryAction +`func RecoveryAction(err error) string` + +Returns the first next-action hint from the error chain. + +### RetryAfter +`func RetryAfter(err error) (*time.Duration, bool)` + +Returns the first retry-after hint from the error chain, if present. + ### NewError `func NewError(text string) error` @@ -218,7 +249,18 @@ Calls `Default().Warn(msg, keyvals...)`. If `err` is nil, returns nil. Otherwise returns a new `*Err` containing `op`, `msg`, and `err`. If the wrapped error chain already contains an `*Err` with a non-empty `Code`, the new wrapper copies that code. +### WrapCodeWithRecovery +`func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns nil only when both `err` is nil and `code` is empty. Otherwise it +returns a wrapped `*Err` with explicit recovery metadata attached. + ### WrapCode `func WrapCode(err error, code, op, msg string) error` Returns nil only when both `err` is nil and `code` is empty. In every other case it returns `&Err{Op: op, Msg: msg, Err: err, Code: code}` as an `error`. + +### WrapWithRecovery +`func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error` + +Returns nil when `err` is nil. Otherwise it returns a wrapped `*Err` with explicit recovery metadata attached. -- 2.45.3