Merge pull request '[agent/codex:gpt-5.4-mini] Update the code against the AX (Agent Experience) design pri...' (#14) from agent/update-the-code-against-the-ax--agent-ex into dev
This commit is contained in:
commit
fdf3a056b3
5 changed files with 109 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
47
errors.go
47
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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
46
specs/RFC.md
46
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 `<nil>`, 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 `<nil>`, 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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue