188 lines
6.5 KiB
Markdown
188 lines
6.5 KiB
Markdown
|
|
# `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
|