A lightweight, modern Go error library: rich context, stack traces, structured
serialization, slog/fmt.Formatter integration, HTTP/retry classification,
PII-safe logging, and an opt-in circuit breaker — all in a tight dependency
footprint (yaml + a fast JSON encoder, nothing else).
- Stdlib-first. Two direct deps in the core module:
gopkg.in/yaml.v3for YAML,github.com/goccy/go-jsonfor the serialization hot path (~2.5× faster thanencoding/json). - Correct by default.
errors.Is/errors.Aswork viaUnwrap();Newfhonors%w; every wrap captures its own stack frames. - Lazy & cached hot paths. Lazy metadata map;
Error()andStack()cached viasync.Once. After the first call,Stack()is ~1.7 ns/op, zero allocations. - Modern Go integrations.
(*Error).Formatfor%+v;(*Error).LogValueforslog;errors.Join-awareErrorGroup. - Operational features. HTTP status, retryable /
Temporary()classification, safe (PII-redacted) messages, recovery suggestions, structuredErrorContext. - Opt-in subpackages. Circuit breaker lives in
ewrap/breaker;slogadapter inewrap/slog. Importingewrapalone pulls in only the core.
go get github.com/hyp3rd/ewrapRequires Go 1.25+ (uses maps.Clone, slices.Clone, range-over-int, b.Loop).
import "github.com/hyp3rd/ewrap"
// Plain error with stack trace
err := ewrap.New("database connection failed")
// %w-aware formatted constructor
err := ewrap.Newf("query %q failed: %w", q, ioErr) // errors.Is(err, ioErr) == true
// Wrap preserves the inner cause AND captures the wrap site
err := ewrap.Wrap(ioErr, "syncing replicas")
// Nil-safe
ewrap.Wrap(nil, "ignored") == nil
ewrap.Wrapf(nil, "ignored %d", 42) == nilerr := ewrap.New("payment authorization rejected",
ewrap.WithContext(ctx, ewrap.ErrorTypeExternal, ewrap.SeverityError),
ewrap.WithHTTPStatus(http.StatusBadGateway),
ewrap.WithRetryable(true),
ewrap.WithSafeMessage("payment authorization rejected"), // omits PII
ewrap.WithRecoverySuggestion(&ewrap.RecoverySuggestion{
Message: "Inspect upstream provider's queue and retry after backoff.",
Documentation: "https://runbooks.example.com/payments/timeout",
}),
).
WithMetadata("provider", "stripe").
WithMetadata("attempt", 2)
err.Log() // emits structured fields via the configured Loggerfmt.Printf("%+v\n", err) // message + filtered stack via fmt.Formatter
// Or inspect frames programmatically
for it := err.GetStackIterator(); it.HasNext(); {
f := it.Next()
fmt.Printf("%s:%d %s\n", f.File, f.Line, f.Function)
}Stack() is computed once and cached. WithStackDepth(n) tunes capture; pass
0 to disable. NewSkip / WrapSkip add caller-skip when wrapping New/Wrap
in helpers.
errors.Is(err, ioErr) // walks the cause chain via Unwrap()
errors.As(err, &netErr)
errors.Unwrap(err)
fmt.Errorf("layered: %w", err) // also walks correctlyewrap.HTTPStatus(err) // walks chain; 0 if unset
ewrap.IsRetryable(err) // checks ewrap classification, then stdlib Temporary()
err.SafeError() // redacted variant for external sinks
err.Recovery() // typed accessor for the recovery suggestion
err.Retry() // typed accessor for retry metadata
err.GetErrorContext() // typed ErrorContext or nil*Error implements slog.LogValuer, so slog.Error("boom", "err", err)
emits the message, type, severity, component, request_id, metadata and
cause as structured fields.
For drivers that want an ewrap.Logger, the slog subpackage provides a
3-line adapter:
import (
stdslog "log/slog"
ewrapslog "github.com/hyp3rd/ewrap/slog"
)
logger := ewrapslog.New(stdslog.New(stdslog.NewJSONHandler(os.Stdout, nil)))
err := ewrap.New("boom", ewrap.WithLogger(logger))For zap, zerolog, logrus, glog, etc. — write a 5-line adapter against the
ewrap.Logger interface (3 methods: Error, Debug, Info).
pool := ewrap.NewErrorGroupPool(4)
eg := pool.Get()
defer eg.Release()
eg.Add(validate(req))
eg.Add(persist(req))
if err := eg.Join(); err != nil { // errors.Join semantics
return err
}(*ErrorGroup).ToJSON() / ToYAML() recursively serialize the whole group,
walking both *Error and standard wrapped chains so transport consumers
keep full context.
The breaker is a sibling subpackage so consumers who only want errors don't pay for it.
import "github.com/hyp3rd/ewrap/breaker"
cb := breaker.New("payments", 5, 30*time.Second)
if !cb.CanExecute() {
return ewrap.New("payments breaker open",
ewrap.WithRetryable(true))
}
if err := charge(req); err != nil {
cb.RecordFailure()
return ewrap.Wrap(err, "charging customer",
ewrap.WithHTTPStatus(http.StatusBadGateway))
}
cb.RecordSuccess()Observers receive transitions synchronously after the lock is released:
type metrics struct{ /* ... */ }
func (m *metrics) RecordTransition(name string, from, to breaker.State) {
m.gauge.WithLabelValues(name, to.String()).Inc()
}
cb := breaker.NewWithObserver("payments", 5, 30*time.Second, &metrics{})Pre-defined enums for categorization. Their String() form is what shows up
in ErrorOutput.Type / Severity, JSON, and slog fields.
ErrorTypeUnknown // -> "unknown"
ErrorTypeValidation // -> "validation"
ErrorTypeNotFound // -> "not_found"
ErrorTypePermission // -> "permission"
ErrorTypeDatabase // -> "database"
ErrorTypeNetwork // -> "network"
ErrorTypeConfiguration // -> "configuration"
ErrorTypeInternal // -> "internal"
ErrorTypeExternal // -> "external"
SeverityInfo // -> "info"
SeverityWarning // -> "warning"
SeverityError // -> "error"
SeverityCritical // -> "critical"type Logger interface {
Error(msg string, keysAndValues ...any)
Debug(msg string, keysAndValues ...any)
Info(msg string, keysAndValues ...any)
}Three methods, key-value pairs after the message. Implementations stay
goroutine-safe; (*Error).Log calls them synchronously.
Snapshot from go test -bench=. -benchmem ./test/... on Apple Silicon (Go 1.25+):
| Benchmark | ns/op | B/op | allocs/op |
|---|---|---|---|
BenchmarkNew/Simple |
1622 | 496 | 2 |
BenchmarkWrap/NestedWraps |
11433 | 1512 | 9 |
BenchmarkFormatting/ToJSON |
16947 | 2941 | 14 |
BenchmarkStackTrace/CaptureStack |
858 | 256 | 1 |
BenchmarkStackTrace/FormatStack (cached) |
1.71 | 0 | 0 |
BenchmarkCircuitBreaker/RecordFailure |
33 | 0 | 0 |
BenchmarkMetadataOperations/GetMetadata |
9 | 0 | 0 |
Notable design choices behind the numbers:
- Lazy metadata map — only allocated on the first
WithMetadatacall. - Cached
Error()/Stack()—sync.Onceguards a one-shot computation; subsequent reads are lock-free. - goccy/go-json for the serialization hot path: ~2.5× faster than
stdlib
encoding/jsonwith ~half the allocations. runtime.Callerscaptures up to 32 PCs by default, configurable viaWithStackDepth(n). The frame filter is function-prefix based, so the output starts at user code.- Breaker is allocation-free in steady state; observer/callback dispatch happens outside the lock to avoid holding it across user code.
.
├── attributes.go # WithHTTPStatus, WithRetryable, WithSafeMessage
├── context.go # ErrorContext, WithContext option
├── errors.go # Error type, New/Wrap/Newf/Wrapf, lazy paths
├── error_group.go # ErrorGroup, pool, serialization
├── format.go # ErrorOutput, ToJSON/ToYAML
├── format_verb.go # fmt.Formatter, slog.LogValuer
├── logger.go # Logger interface
├── observability.go # Observer interface (errors only)
├── retry.go # RetryInfo, WithRetry
├── stack.go # StackFrame, StackIterator
├── types.go # ErrorType, Severity, RecoverySuggestion
├── breaker/ # opt-in circuit breaker
└── slog/ # opt-in slog adapter
git clone https://github.com/hyp3rd/ewrap.git
cd ewrap
make prepare-toolchain # one-time: golangci-lint, gofumpt, govulncheck, gosec
make test # go test -v -timeout 5m -cover ./...
make test-race # go test -race ./...
make benchmark # go test -bench=. -benchmem ./test/...
make lint # gci + gofumpt + staticcheck + golangci-lint
make sec # govulncheck + gosecSee CONTRIBUTING. PRs welcome — please run make lint and
make test-race before opening one.
I'm a surfer, and a software architect with 15 years of experience designing highly available distributed production systems and developing cloud-native apps in public and private clouds. Feel free to connect with me on LinkedIn.