feat(xerrors): initial stable release v0.9.0
Structured application errors with typed codes, cause chaining, key-value context fields, and zero-import logz enrichment bridge. What's included: - `*Err` type implementing error, errors.Unwrap, json.Marshaler, ErrorCode(), and ErrorContext() - Twelve typed Code constants aligned with gRPC canonical status names - New / Wrap factory constructors plus InvalidInput / NotFound / Internal convenience constructors - Builder methods WithContext and WithError for attaching structured fields and causes - Duck-typed ErrorCode() / ErrorContext() bridge so logz auto-enriches log records without an import Tested-via: todo-api POC integration Reviewed-against: docs/adr/
This commit is contained in:
187
README.md
Normal file
187
README.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# `xerrors`
|
||||
|
||||
> Structured application errors with stable codes, cause chaining, and zero-dependency log enrichment.
|
||||
|
||||
**Module:** `code.nochebuena.dev/go/xerrors`
|
||||
**Tier:** 0 — zero external dependencies, stdlib only
|
||||
**Go:** 1.25+
|
||||
**Dependencies:** none
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`xerrors` provides a single error type — [`Err`](#err) — that carries a machine-readable [`Code`](#codes), a human-readable message, an optional cause, and optional key-value context fields.
|
||||
|
||||
The `Code` values are stable string constants aligned with gRPC status codes. They are safe to persist, transmit in API responses, and switch on programmatically. The `httputil` module uses them to map errors to HTTP status codes automatically.
|
||||
|
||||
This package does **not** handle HTTP responses, logging, or i18n. Those concerns belong to [`httputil`](../httputil) and [`logz`](../logz) respectively.
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
go get code.nochebuena.dev/go/xerrors
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
import "code.nochebuena.dev/go/xerrors"
|
||||
|
||||
// Create a structured error
|
||||
err := xerrors.New(xerrors.ErrNotFound, "user not found")
|
||||
|
||||
// With cause chain
|
||||
err := xerrors.Wrap(xerrors.ErrInternal, "failed to query database", dbErr)
|
||||
|
||||
// Convenience constructors (fmt.Sprintf-style)
|
||||
err := xerrors.NotFound("user %s not found", userID)
|
||||
|
||||
// Builder pattern — attach structured context for logging
|
||||
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||
WithContext("field", "email").
|
||||
WithContext("rule", "required")
|
||||
|
||||
// Walk the cause chain with stdlib
|
||||
var e *xerrors.Err
|
||||
if errors.As(err, &e) {
|
||||
fmt.Println(e.Code()) // ErrInvalidInput
|
||||
fmt.Println(e.Message()) // "validation failed"
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating errors
|
||||
|
||||
| Function | Code | Use when |
|
||||
|----------|------|----------|
|
||||
| `New(code, message)` | any | general purpose |
|
||||
| `Wrap(code, message, err)` | any | wrapping a lower-level error |
|
||||
| `InvalidInput(msg, args...)` | `ErrInvalidInput` | bad or missing request data |
|
||||
| `NotFound(msg, args...)` | `ErrNotFound` | resource does not exist |
|
||||
| `Internal(msg, args...)` | `ErrInternal` | unexpected server-side failure |
|
||||
|
||||
### Attaching context fields
|
||||
|
||||
Context fields are key-value pairs that enrich log records and debug output. They never appear in API responses.
|
||||
|
||||
```go
|
||||
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||
WithContext("field", "email").
|
||||
WithContext("rule", "required").
|
||||
WithContext("value", input.Email)
|
||||
```
|
||||
|
||||
`WithContext` can be chained and called multiple times. Repeating a key overwrites the previous value.
|
||||
|
||||
### Cause chaining
|
||||
|
||||
`Wrap` and `WithError` both set the underlying cause. `Err.Unwrap` is implemented, so `errors.Is` and `errors.As` walk the full chain:
|
||||
|
||||
```go
|
||||
err := xerrors.Wrap(xerrors.ErrInternal, "save failed", io.ErrUnexpectedEOF)
|
||||
|
||||
errors.Is(err, io.ErrUnexpectedEOF) // true
|
||||
|
||||
var e *xerrors.Err
|
||||
errors.As(err, &e) // true — works through fmt.Errorf("%w", ...) wrapping too
|
||||
```
|
||||
|
||||
### Reading errors
|
||||
|
||||
```go
|
||||
var e *xerrors.Err
|
||||
if errors.As(err, &e) {
|
||||
e.Code() // xerrors.Code — the typed error category
|
||||
e.Message() // string — the human-readable message
|
||||
e.Fields() // map[string]any — shallow copy of context fields
|
||||
e.Unwrap() // error — the underlying cause
|
||||
e.Detailed() // string — verbose debug string: "code: X | message: Y | cause: Z | fields: {...}"
|
||||
}
|
||||
```
|
||||
|
||||
`Fields()` always returns a non-nil map and is safe to mutate — it is a shallow copy of the internal state.
|
||||
|
||||
### JSON serialization
|
||||
|
||||
`Err` implements `json.Marshaler`. This is what `httputil` uses to write error responses:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "NOT_FOUND",
|
||||
"message": "user abc123 not found",
|
||||
"fields": {
|
||||
"id": "abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields` is omitted when empty.
|
||||
|
||||
### Structured log enrichment (duck-typing bridge)
|
||||
|
||||
`logz` automatically enriches log records when it receives an `*Err` — no import of `xerrors` needed by `logz`, and no import of `logz` needed here. The bridge works through two methods that `Err` exposes:
|
||||
|
||||
```go
|
||||
// Called by logz internally via errors.As — never call these directly.
|
||||
func (e *Err) ErrorCode() string // → "NOT_FOUND"
|
||||
func (e *Err) ErrorContext() map[string]any // → the raw fields map
|
||||
```
|
||||
|
||||
Passing an `*Err` to `logger.Error(msg, err)` automatically adds `error_code` and all context fields to the log record.
|
||||
|
||||
## Codes
|
||||
|
||||
Wire values are gRPC status code names. HTTP mapping is the transport layer's responsibility.
|
||||
|
||||
| Constant | Wire value | HTTP status |
|
||||
|----------|-----------|-------------|
|
||||
| `ErrInvalidInput` | `INVALID_ARGUMENT` | 400 |
|
||||
| `ErrUnauthorized` | `UNAUTHENTICATED` | 401 |
|
||||
| `ErrPermissionDenied` | `PERMISSION_DENIED` | 403 |
|
||||
| `ErrNotFound` | `NOT_FOUND` | 404 |
|
||||
| `ErrAlreadyExists` | `ALREADY_EXISTS` | 409 |
|
||||
| `ErrGone` | `GONE` | 410 |
|
||||
| `ErrPreconditionFailed` | `FAILED_PRECONDITION` | 412 |
|
||||
| `ErrRateLimited` | `RESOURCE_EXHAUSTED` | 429 |
|
||||
| `ErrCancelled` | `CANCELLED` | 499 |
|
||||
| `ErrInternal` | `INTERNAL` | 500 |
|
||||
| `ErrNotImplemented` | `UNIMPLEMENTED` | 501 |
|
||||
| `ErrUnavailable` | `UNAVAILABLE` | 503 |
|
||||
| `ErrDeadlineExceeded` | `DEADLINE_EXCEEDED` | 504 |
|
||||
|
||||
Wire values are **stable across versions** — do not change them. Adding new constants is non-breaking.
|
||||
|
||||
`Code.Description()` returns a short human-readable description of any code.
|
||||
|
||||
## Design decisions
|
||||
|
||||
**`Err` instead of `AppErr`** — the "App" prefix is redundant inside a package already named `xerrors`. `xerrors.Err` reads cleanly at call sites.
|
||||
|
||||
**`Code` instead of `ErrorCode`** — same reasoning. `xerrors.Code` is more concise.
|
||||
|
||||
**`Fields()` returns a defensive copy** — the internal map is not exposed directly. Callers who want read-only access to the raw map (e.g. `logz`) use `ErrorContext()`. Callers who need to manipulate the result use `Fields()`.
|
||||
|
||||
**`EnsureAppError` dropped** — auto-wrapping arbitrary errors into a structured error hides the real cause and discourages explicit error handling. Use `errors.As` to check for `*Err` and handle each case intentionally.
|
||||
|
||||
**Wire values aligned with gRPC** — switching to gRPC (or adding gRPC alongside HTTP) requires no translation layer for most codes.
|
||||
|
||||
## Ecosystem
|
||||
|
||||
```
|
||||
Tier 0: xerrors ← you are here
|
||||
↑
|
||||
Tier 1: logz (duck-types xerrors — no direct import)
|
||||
valid (depends on xerrors for error construction)
|
||||
↑
|
||||
Tier 2: httputil (maps xerrors.Code → HTTP status)
|
||||
↑
|
||||
Tier 4: httpmw, httpauth, httpserver
|
||||
```
|
||||
|
||||
Modules that consume `xerrors` errors without importing this package: `logz` (via `ErrorCode()` / `ErrorContext()` duck-typing).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in New Issue
Block a user