feat: add agent-oriented recovery metadata to errors
This commit is contained in:
parent
3962cb7ac3
commit
7205e913bb
4 changed files with 237 additions and 2 deletions
121
errors.go
121
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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
19
log.go
19
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 {
|
||||
|
|
|
|||
22
log_test.go
22
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{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue