diff --git a/errors.go b/errors.go index 266a8dd..67a1115 100644 --- a/errors.go +++ b/errors.go @@ -10,6 +10,7 @@ import ( "fmt" "iter" "strings" + "time" ) // Err represents a structured error with operational context. @@ -19,6 +20,12 @@ type Err struct { Msg string // Human-readable message Err error // Underlying error (optional) Code string // Error code (optional, e.g., "VALIDATION_FAILED") + // Retryable indicates whether the caller can safely retry this error. + Retryable bool + // RetryAfter suggests a delay before retrying when Retryable is true. + RetryAfter *time.Duration + // NextAction suggests an alternative path when this error is not directly retryable. + NextAction string } // Error implements the error interface. @@ -57,6 +64,20 @@ func E(op, msg string, err error) error { return &Err{Op: op, Msg: msg, Err: err} } +// EWithRecovery creates a new Err with operation context and recovery metadata. +func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error { + recoveryErr := &Err{ + Op: op, + Msg: msg, + Err: err, + } + inheritRecovery(recoveryErr, err) + recoveryErr.Retryable = retryable + recoveryErr.RetryAfter = retryAfter + recoveryErr.NextAction = nextAction + return recoveryErr +} + // Wrap wraps an error with operation context. // Returns nil if err is nil, to support conditional wrapping. // Preserves error Code if the wrapped error is an *Err. @@ -68,7 +89,27 @@ func Wrap(err error, op, msg string) error { if err == nil { return nil } - return &Err{Op: op, Msg: msg, Err: err, Code: ErrCode(err)} + wrapped := &Err{Op: op, Msg: msg, Err: err, Code: ErrCode(err)} + inheritRecovery(wrapped, err) + return wrapped +} + +// WrapWithRecovery wraps an error with operation context and explicit recovery metadata. +func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { + if err == nil { + return nil + } + recoveryErr := &Err{ + Op: op, + Msg: msg, + Err: err, + Code: ErrCode(err), + } + inheritRecovery(recoveryErr, err) + recoveryErr.Retryable = retryable + recoveryErr.RetryAfter = retryAfter + recoveryErr.NextAction = nextAction + return recoveryErr } // WrapCode wraps an error with operation context and error code. @@ -85,7 +126,30 @@ func WrapCode(err error, code, op, msg string) error { if err == nil && code == "" { return nil } - return &Err{Op: op, Msg: msg, Err: err, Code: code} + wrapped := &Err{Op: op, Msg: msg, Err: err, Code: code} + inheritRecovery(wrapped, err) + return wrapped +} + +// WrapCodeWithRecovery wraps an error with operation context, code, and recovery metadata. +func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { + if code == "" { + code = ErrCode(err) + } + if err == nil && code == "" { + return nil + } + recoveryErr := &Err{ + Op: op, + Msg: msg, + Err: err, + Code: code, + } + inheritRecovery(recoveryErr, err) + recoveryErr.Retryable = retryable + recoveryErr.RetryAfter = retryAfter + recoveryErr.NextAction = nextAction + return recoveryErr } // NewCode creates an error with just code and message (no underlying error). @@ -98,6 +162,59 @@ func NewCode(code, msg string) error { return &Err{Msg: msg, Code: code} } +// NewCodeWithRecovery creates a coded error with recovery metadata. +func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { + return &Err{ + Msg: msg, + Code: code, + Retryable: retryable, + RetryAfter: retryAfter, + NextAction: nextAction, + } +} + +// inheritRecovery copies recovery metadata from the first *Err in err's chain. +func inheritRecovery(dst *Err, err error) { + if err == nil || dst == nil { + return + } + var source *Err + if As(err, &source) { + dst.Retryable = source.Retryable + dst.RetryAfter = source.RetryAfter + dst.NextAction = source.NextAction + } +} + +// RetryAfter returns the first retry-after hint from an error chain, if present. +func RetryAfter(err error) (*time.Duration, bool) { + var wrapped *Err + if As(err, &wrapped) { + if wrapped.RetryAfter != nil { + return wrapped.RetryAfter, true + } + } + return nil, false +} + +// IsRetryable reports whether the error chain contains a retryable Err. +func IsRetryable(err error) bool { + var wrapped *Err + if As(err, &wrapped) { + return wrapped.Retryable + } + return false +} + +// RecoveryAction returns the first next action from an error chain. +func RecoveryAction(err error) string { + var wrapped *Err + if As(err, &wrapped) { + return wrapped.NextAction + } + return "" +} + // --- Standard Library Wrappers --- // Is reports whether any error in err's tree matches target. diff --git a/errors_test.go b/errors_test.go index a43d21f..c09bba0 100644 --- a/errors_test.go +++ b/errors_test.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -73,6 +74,20 @@ func TestE_Good_NilError(t *testing.T) { assert.Equal(t, "op.Name: message", err.Error()) } +func TestEWithRecovery_Good(t *testing.T) { + retryAfter := time.Second * 5 + err := EWithRecovery("op.Name", "message", nil, true, &retryAfter, "retry once") + + var logErr *Err + assert.NotNil(t, err) + assert.True(t, As(err, &logErr)) + assert.True(t, logErr.Retryable) + if assert.NotNil(t, logErr.RetryAfter) { + assert.Equal(t, retryAfter, *logErr.RetryAfter) + } + assert.Equal(t, "retry once", logErr.NextAction) +} + func TestWrap_Good(t *testing.T) { underlying := errors.New("base") err := Wrap(underlying, "handler.Process", "processing failed") @@ -95,6 +110,22 @@ func TestWrap_PreservesCode_Good(t *testing.T) { assert.Contains(t, outer.Error(), "[VALIDATION_ERROR]") } +func TestWrap_PreservesRecovery_Good(t *testing.T) { + retryAfter := 15 * time.Second + inner := &Err{Msg: "inner", Retryable: true, RetryAfter: &retryAfter, NextAction: "inspect input"} + + outer := Wrap(inner, "outer.Op", "outer context") + + assert.NotNil(t, outer) + var logErr *Err + assert.True(t, As(outer, &logErr)) + assert.True(t, logErr.Retryable) + if assert.NotNil(t, logErr.RetryAfter) { + assert.Equal(t, retryAfter, *logErr.RetryAfter) + } + assert.Equal(t, "inspect input", logErr.NextAction) +} + func TestWrap_PreservesCode_FromNestedChain_Good(t *testing.T) { root := WrapCode(errors.New("base"), "CHAIN_ERROR", "inner", "inner failed") wrapped := Wrap(fmt.Errorf("mid layer: %w", root), "outer", "outer context") @@ -120,6 +151,20 @@ func TestWrapCode_Good(t *testing.T) { assert.Contains(t, err.Error(), "[INVALID_INPUT]") } +func TestWrapCodeWithRecovery_Good(t *testing.T) { + retryAfter := time.Minute + err := WrapCodeWithRecovery(errors.New("validation failed"), "INVALID_INPUT", "api.Validate", "bad request", true, &retryAfter, "retry with backoff") + + var logErr *Err + assert.NotNil(t, err) + assert.True(t, As(err, &logErr)) + assert.True(t, logErr.Retryable) + assert.NotNil(t, logErr.RetryAfter) + assert.Equal(t, retryAfter, *logErr.RetryAfter) + assert.Equal(t, "retry with backoff", logErr.NextAction) + assert.Equal(t, "INVALID_INPUT", logErr.Code) +} + func TestWrapCode_Good_NilError(t *testing.T) { // WrapCode with nil error but with code still creates an error err := WrapCode(nil, "CODE", "op", "msg") @@ -141,6 +186,19 @@ func TestNewCode_Good(t *testing.T) { assert.Nil(t, logErr.Err) } +func TestNewCodeWithRecovery_Good(t *testing.T) { + retryAfter := 2 * time.Minute + err := NewCodeWithRecovery("NOT_FOUND", "resource not found", false, &retryAfter, "contact support") + + var logErr *Err + assert.NotNil(t, err) + assert.True(t, As(err, &logErr)) + assert.False(t, logErr.Retryable) + assert.NotNil(t, logErr.RetryAfter) + assert.Equal(t, retryAfter, *logErr.RetryAfter) + assert.Equal(t, "contact support", logErr.NextAction) +} + // --- Standard Library Wrapper Tests --- func TestIs_Good(t *testing.T) { @@ -205,6 +263,25 @@ func TestErrCode_Good_Nil(t *testing.T) { assert.Equal(t, "", ErrCode(nil)) } +func TestRetryAfter_Good(t *testing.T) { + retryAfter := 42 * time.Second + err := &Err{Msg: "typed", RetryAfter: &retryAfter} + + got, ok := RetryAfter(err) + assert.True(t, ok) + assert.Equal(t, retryAfter, *got) +} + +func TestIsRetryable_Good(t *testing.T) { + err := &Err{Msg: "typed", Retryable: true} + assert.True(t, IsRetryable(err)) +} + +func TestRecoveryAction_Good(t *testing.T) { + err := &Err{Msg: "typed", NextAction: "inspect"} + assert.Equal(t, "inspect", RecoveryAction(err)) +} + func TestMessage_Good(t *testing.T) { err := E("op", "the message", errors.New("base")) assert.Equal(t, "the message", Message(err)) diff --git a/log.go b/log.go index eb4c667..1e67927 100644 --- a/log.go +++ b/log.go @@ -255,6 +255,25 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { if !ok { continue } + var logErr *Err + if As(err, &logErr) { + if _, hasRetryable := existing["retryable"]; !hasRetryable { + existing["retryable"] = struct{}{} + keyvals = append(keyvals, "retryable", logErr.Retryable) + } + if logErr.RetryAfter != nil { + if _, hasRetryAfter := existing["retry_after_seconds"]; !hasRetryAfter { + existing["retry_after_seconds"] = struct{}{} + keyvals = append(keyvals, "retry_after_seconds", logErr.RetryAfter.Seconds()) + } + } + if logErr.NextAction != "" { + if _, hasNextAction := existing["next_action"]; !hasNextAction { + existing["next_action"] = struct{}{} + keyvals = append(keyvals, "next_action", logErr.NextAction) + } + } + } if op := Op(err); op != "" { if _, hasOp := existing["op"]; !hasOp { diff --git a/log_test.go b/log_test.go index 5a573b2..b5512cd 100644 --- a/log_test.go +++ b/log_test.go @@ -2,10 +2,12 @@ package log import ( "bytes" + "errors" goio "io" "os" "strings" "testing" + "time" ) // nopWriteCloser wraps a writer with a no-op Close for testing rotation. @@ -99,6 +101,26 @@ func TestLogger_ErrorContext_Good(t *testing.T) { } } +func TestLogger_ErrorContextIncludesRecovery_Good(t *testing.T) { + var buf bytes.Buffer + l := New(Options{Output: &buf, Level: LevelInfo}) + retryAfter := 45 * time.Second + + err := EWithRecovery("retryable.Op", "temporary failure", errors.New("temporary failure"), true, &retryAfter, "retry with backoff") + l.Error("request failed", "err", err) + + output := buf.String() + if !strings.Contains(output, "retryable=true") { + t.Errorf("expected output to contain retryable=true, got %q", output) + } + if !strings.Contains(output, "retry_after_seconds=45") { + t.Errorf("expected output to contain retry_after_seconds=45, got %q", output) + } + if !strings.Contains(output, "next_action=\"retry with backoff\"") { + t.Errorf("expected output to contain next_action=\"retry with backoff\", got %q", output) + } +} + func TestLogger_Redaction_Good(t *testing.T) { var buf bytes.Buffer l := New(Options{