feat: add agent-oriented recovery metadata to errors
Some checks failed
CI / test (push) Failing after 2s
CI / auto-fix (push) Failing after 1s
CI / auto-merge (push) Failing after 1s

This commit is contained in:
Virgil 2026-03-30 06:33:30 +00:00
parent 3962cb7ac3
commit 7205e913bb
4 changed files with 237 additions and 2 deletions

121
errors.go
View file

@ -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.

View file

@ -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
View file

@ -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 {

View file

@ -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{