Files
xerrors/README.md

188 lines
6.5 KiB
Markdown
Raw Normal View History

# `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