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:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "Go",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
|
||||||
|
},
|
||||||
|
"forwardPorts": [],
|
||||||
|
"postCreateCommand": "go version",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"files.autoSave": "afterDelay",
|
||||||
|
"files.autoSaveDelay": 1000,
|
||||||
|
"explorer.compactFolders": false,
|
||||||
|
"explorer.showEmptyFolders": true
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"golang.go",
|
||||||
|
"eamodio.golang-postfix-completion",
|
||||||
|
"quicktype.quicktype",
|
||||||
|
"usernamehw.errorlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "vscode"
|
||||||
|
}
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Binaries
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with go test -c
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go build
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directory
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# VCS files
|
||||||
|
COMMIT.md
|
||||||
|
RELEASE.md
|
||||||
39
CHANGELOG.md
Normal file
39
CHANGELOG.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this module will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Code` — string type alias for machine-readable error categories with stable wire values aligned to gRPC canonical status names
|
||||||
|
- Thirteen typed `Code` constants: `ErrInvalidInput` (`INVALID_ARGUMENT`), `ErrUnauthorized` (`UNAUTHENTICATED`), `ErrPermissionDenied` (`PERMISSION_DENIED`), `ErrNotFound` (`NOT_FOUND`), `ErrAlreadyExists` (`ALREADY_EXISTS`), `ErrGone` (`GONE`), `ErrPreconditionFailed` (`FAILED_PRECONDITION`), `ErrRateLimited` (`RESOURCE_EXHAUSTED`), `ErrCancelled` (`CANCELLED`), `ErrInternal` (`INTERNAL`), `ErrNotImplemented` (`UNIMPLEMENTED`), `ErrUnavailable` (`UNAVAILABLE`), `ErrDeadlineExceeded` (`DEADLINE_EXCEEDED`)
|
||||||
|
- `Code.Description() string` — human-readable description for each code; unknown codes return their raw string value
|
||||||
|
- `Err` struct — structured error type carrying a `Code`, human-readable message, optional cause, and optional key-value context fields
|
||||||
|
- `New(code Code, message string) *Err` — primary factory constructor; no cause set
|
||||||
|
- `Wrap(code Code, message string, err error) *Err` — factory constructor that wraps an existing error as the cause
|
||||||
|
- `InvalidInput(msg string, args ...any) *Err` — convenience constructor for `ErrInvalidInput`; message formatted with `fmt.Sprintf`
|
||||||
|
- `NotFound(msg string, args ...any) *Err` — convenience constructor for `ErrNotFound`
|
||||||
|
- `Internal(msg string, args ...any) *Err` — convenience constructor for `ErrInternal`
|
||||||
|
- `(*Err).WithContext(key string, value any) *Err` — chainable builder method to attach a key-value context field; overwrites existing value for the same key
|
||||||
|
- `(*Err).WithError(err error) *Err` — chainable builder method to set or replace the cause
|
||||||
|
- `(*Err).Error() string` — implements the `error` interface; format: `"CODE: message → cause"`
|
||||||
|
- `(*Err).Unwrap() error` — implements `errors.Unwrap`, enabling `errors.Is` and `errors.As` to walk the full cause chain
|
||||||
|
- `(*Err).Code() Code` — returns the typed error code
|
||||||
|
- `(*Err).Message() string` — returns the human-readable message
|
||||||
|
- `(*Err).Fields() map[string]any` — returns a safe shallow copy of all attached context fields; always non-nil
|
||||||
|
- `(*Err).Detailed() string` — verbose debug string including code, message, cause, and fields
|
||||||
|
- `(*Err).ErrorCode() string` — duck-type bridge satisfying logz's internal `errorWithCode` interface; enables automatic `error_code` log field enrichment without importing logz
|
||||||
|
- `(*Err).ErrorContext() map[string]any` — duck-type bridge satisfying logz's internal `errorWithContext` interface; returns the live internal map (read-only)
|
||||||
|
- `(*Err).MarshalJSON() ([]byte, error)` — implements `json.Marshaler`; output: `{"code":"...","message":"...","fields":{...}}`
|
||||||
|
|
||||||
|
### Design Notes
|
||||||
|
|
||||||
|
- Error codes are string type aliases with stable wire values — safe to serialize, persist, and compare across service versions; HTTP status mapping is deliberately excluded and belongs in the transport layer.
|
||||||
|
- `*Err` satisfies `errors.Unwrap`, `json.Marshaler`, and two private duck-type interfaces (`ErrorCode`, `ErrorContext`) that logz inspects via `errors.As`, decoupling the two packages without any import between them.
|
||||||
|
- Zero external dependencies — stdlib only (`encoding/json`, `fmt`); Tier 0 of the micro-lib stack.
|
||||||
|
|
||||||
|
[0.9.0]: https://code.nochebuena.dev/go/xerrors/releases/tag/v0.9.0
|
||||||
108
CLAUDE.md
Normal file
108
CLAUDE.md
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# xerrors
|
||||||
|
|
||||||
|
Structured application errors with stable typed codes, cause chaining, and key-value context fields.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`xerrors` provides a single error type — `*Err` — that carries a machine-readable
|
||||||
|
`Code`, a human-readable message, an optional cause, and optional key-value fields.
|
||||||
|
It replaces ad-hoc string errors and sentinel variables with a consistent,
|
||||||
|
structured, JSON-serialisable error model that works across service boundaries,
|
||||||
|
log pipelines, and HTTP transports.
|
||||||
|
|
||||||
|
## Tier & Dependencies
|
||||||
|
|
||||||
|
**Tier:** 0
|
||||||
|
**Imports:** `encoding/json`, `fmt` (stdlib only)
|
||||||
|
**Must NOT import:** `logz`, `rbac`, `launcher`, or any other micro-lib module.
|
||||||
|
The logz bridge is achieved via duck-typed private interfaces — no import required.
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- Typed error codes as a `string` type alias — stable wire values aligned with gRPC
|
||||||
|
status names. See `docs/adr/ADR-001-typed-error-codes.md`.
|
||||||
|
- `*Err` implements `Unwrap`, `ErrorCode`, `ErrorContext`, and `json.Marshaler` for
|
||||||
|
full stdlib compatibility and automatic log enrichment. See
|
||||||
|
`docs/adr/ADR-002-stdlib-errors-compatibility.md`.
|
||||||
|
- Twelve codes cover the gRPC canonical set (`INVALID_ARGUMENT`, `NOT_FOUND`,
|
||||||
|
`INTERNAL`, `ALREADY_EXISTS`, `PERMISSION_DENIED`, `UNAUTHENTICATED`, `GONE`,
|
||||||
|
`FAILED_PRECONDITION`, `RESOURCE_EXHAUSTED`, `CANCELLED`, `UNIMPLEMENTED`,
|
||||||
|
`UNAVAILABLE`, `DEADLINE_EXCEEDED`).
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**Creating errors:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Primary factory
|
||||||
|
err := xerrors.New(xerrors.ErrNotFound, "user not found")
|
||||||
|
|
||||||
|
// Convenience constructors (most common codes)
|
||||||
|
err := xerrors.NotFound("user %s not found", userID)
|
||||||
|
err := xerrors.InvalidInput("email is required")
|
||||||
|
err := xerrors.Internal("unexpected database state")
|
||||||
|
|
||||||
|
// Wrapping a cause
|
||||||
|
err := xerrors.Wrap(xerrors.ErrInternal, "failed to query database", dbErr)
|
||||||
|
|
||||||
|
// Builder pattern for structured context
|
||||||
|
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||||
|
WithContext("field", "email").
|
||||||
|
WithContext("rule", "required").
|
||||||
|
WithError(cause)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Inspecting errors:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
var e *xerrors.Err
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
switch e.Code() {
|
||||||
|
case xerrors.ErrNotFound:
|
||||||
|
// handle 404
|
||||||
|
case xerrors.ErrInvalidInput:
|
||||||
|
// handle 400
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause chain traversal:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// errors.Is and errors.As walk through *Err via Unwrap
|
||||||
|
if errors.Is(err, sql.ErrNoRows) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSON serialisation (API responses):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// *Err marshals to: {"code":"NOT_FOUND","message":"user not found","fields":{...}}
|
||||||
|
json.NewEncoder(w).Encode(err)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatic log enrichment (no extra code needed):**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// If err is *Err, logz appends error_code and context fields automatically
|
||||||
|
logger.Error("request failed", err)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to Avoid
|
||||||
|
|
||||||
|
- Do not match on `err.Error()` strings. Always use `errors.As` + `e.Code()`.
|
||||||
|
- Do not add HTTP status code logic to this package. HTTP mapping belongs in the
|
||||||
|
transport layer.
|
||||||
|
- Do not add new code constants unless they map to a gRPC canonical status name.
|
||||||
|
- Do not import `logz` from this package. The duck-type bridge (`ErrorCode`,
|
||||||
|
`ErrorContext`) keeps the two packages decoupled.
|
||||||
|
- `ErrorContext()` returns the live internal map — do not mutate it. Use `Fields()`
|
||||||
|
if you need a safe copy.
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
- `compliance_test.go` uses compile-time nil-pointer assertions to enforce that
|
||||||
|
`*Err` satisfies the `error`, `Unwrap`, `ErrorCode`, `ErrorContext`, and
|
||||||
|
`json.Marshaler` contracts. These assertions have zero runtime cost.
|
||||||
|
- `xerrors_test.go` covers construction, chaining, builder methods, and
|
||||||
|
`errors.Is`/`errors.As` behaviour.
|
||||||
|
- No test setup is needed — all tests use plain Go with no external dependencies.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NOCHEBUENADEV
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
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
|
||||||
97
code.go
Normal file
97
code.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package xerrors
|
||||||
|
|
||||||
|
// Code is the machine-readable error category.
|
||||||
|
// Wire values are stable across versions and are identical to gRPC status code names.
|
||||||
|
// HTTP mapping is the responsibility of the transport layer, not this package.
|
||||||
|
type Code string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ErrInvalidInput indicates the request contains malformed or invalid data.
|
||||||
|
// The caller should fix the input before retrying.
|
||||||
|
ErrInvalidInput Code = "INVALID_ARGUMENT"
|
||||||
|
|
||||||
|
// ErrUnauthorized indicates the request lacks valid authentication credentials.
|
||||||
|
// The caller should authenticate and retry.
|
||||||
|
ErrUnauthorized Code = "UNAUTHENTICATED"
|
||||||
|
|
||||||
|
// ErrPermissionDenied indicates the authenticated caller lacks permission for the operation.
|
||||||
|
// Authentication is not the issue — the caller is authenticated but not authorised.
|
||||||
|
ErrPermissionDenied Code = "PERMISSION_DENIED"
|
||||||
|
|
||||||
|
// ErrNotFound indicates the requested resource does not exist.
|
||||||
|
ErrNotFound Code = "NOT_FOUND"
|
||||||
|
|
||||||
|
// ErrAlreadyExists indicates a resource with the same identifier already exists.
|
||||||
|
// Use for creation conflicts (e.g. duplicate email on sign-up).
|
||||||
|
// For state-based conflicts not related to creation, use ErrPreconditionFailed.
|
||||||
|
ErrAlreadyExists Code = "ALREADY_EXISTS"
|
||||||
|
|
||||||
|
// ErrGone indicates the resource existed but has been permanently removed.
|
||||||
|
// Unlike ErrNotFound, this signals the caller should not retry — the resource
|
||||||
|
// is gone for good (e.g. a soft-deleted record that has been purged).
|
||||||
|
ErrGone Code = "GONE"
|
||||||
|
|
||||||
|
// ErrPreconditionFailed indicates the operation was rejected because a required
|
||||||
|
// condition was not met. The input is valid but a business rule blocks the action
|
||||||
|
// (e.g. "cannot delete an account with active subscriptions", or an optimistic-lock
|
||||||
|
// mismatch). Different from ErrAlreadyExists (duplicate creation) and
|
||||||
|
// ErrInvalidInput (bad data).
|
||||||
|
ErrPreconditionFailed Code = "FAILED_PRECONDITION"
|
||||||
|
|
||||||
|
// ErrRateLimited indicates the caller has exceeded a rate limit or exhausted a quota.
|
||||||
|
ErrRateLimited Code = "RESOURCE_EXHAUSTED"
|
||||||
|
|
||||||
|
// ErrCancelled indicates the operation was cancelled, typically because the caller
|
||||||
|
// disconnected or the request context was cancelled.
|
||||||
|
// Useful for translating context.Canceled to a structured error at service boundaries.
|
||||||
|
ErrCancelled Code = "CANCELLED"
|
||||||
|
|
||||||
|
// ErrInternal indicates an unexpected server-side failure.
|
||||||
|
// This code should not be used when a more specific code applies.
|
||||||
|
ErrInternal Code = "INTERNAL"
|
||||||
|
|
||||||
|
// ErrNotImplemented indicates the requested operation has not been implemented.
|
||||||
|
ErrNotImplemented Code = "UNIMPLEMENTED"
|
||||||
|
|
||||||
|
// ErrUnavailable indicates the service is temporarily unable to handle requests.
|
||||||
|
// The caller may retry with backoff.
|
||||||
|
ErrUnavailable Code = "UNAVAILABLE"
|
||||||
|
|
||||||
|
// ErrDeadlineExceeded indicates the operation timed out before completing.
|
||||||
|
ErrDeadlineExceeded Code = "DEADLINE_EXCEEDED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Description returns a human-readable description for the code.
|
||||||
|
// Unknown codes return their raw string value.
|
||||||
|
func (c Code) Description() string {
|
||||||
|
switch c {
|
||||||
|
case ErrInvalidInput:
|
||||||
|
return "Invalid input provided"
|
||||||
|
case ErrUnauthorized:
|
||||||
|
return "Authentication required"
|
||||||
|
case ErrPermissionDenied:
|
||||||
|
return "Insufficient permissions"
|
||||||
|
case ErrNotFound:
|
||||||
|
return "Resource not found"
|
||||||
|
case ErrAlreadyExists:
|
||||||
|
return "Resource already exists"
|
||||||
|
case ErrGone:
|
||||||
|
return "Resource permanently deleted"
|
||||||
|
case ErrPreconditionFailed:
|
||||||
|
return "Precondition not met"
|
||||||
|
case ErrRateLimited:
|
||||||
|
return "Rate limit exceeded"
|
||||||
|
case ErrCancelled:
|
||||||
|
return "Request cancelled"
|
||||||
|
case ErrInternal:
|
||||||
|
return "Internal error"
|
||||||
|
case ErrNotImplemented:
|
||||||
|
return "Not implemented"
|
||||||
|
case ErrUnavailable:
|
||||||
|
return "Service unavailable"
|
||||||
|
case ErrDeadlineExceeded:
|
||||||
|
return "Deadline exceeded"
|
||||||
|
default:
|
||||||
|
return string(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
compliance_test.go
Normal file
24
compliance_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package xerrors_test
|
||||||
|
|
||||||
|
import "code.nochebuena.dev/go/xerrors"
|
||||||
|
|
||||||
|
// Compile-time contract verification.
|
||||||
|
//
|
||||||
|
// These assertions are zero-cost at runtime — the nil pointers are never
|
||||||
|
// dereferenced. If any method is removed or its signature changes, the build
|
||||||
|
// fails immediately here rather than at a distant call site in another module.
|
||||||
|
|
||||||
|
// richError is the shape that logz and other consumers expect from any structured
|
||||||
|
// error produced by this package. Adding methods to Err is non-breaking. Removing
|
||||||
|
// any of these or changing their signature is a breaking change.
|
||||||
|
var _ interface {
|
||||||
|
error
|
||||||
|
Unwrap() error
|
||||||
|
ErrorCode() string
|
||||||
|
ErrorContext() map[string]any
|
||||||
|
} = (*xerrors.Err)(nil)
|
||||||
|
|
||||||
|
// jsonMarshaler verifies Err implements json.Marshaler.
|
||||||
|
var _ interface {
|
||||||
|
MarshalJSON() ([]byte, error)
|
||||||
|
} = (*xerrors.Err)(nil)
|
||||||
43
doc.go
Normal file
43
doc.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/*
|
||||||
|
Package xerrors provides structured application errors with stable machine-readable
|
||||||
|
codes, human-readable messages, cause chaining, and key-value context fields.
|
||||||
|
|
||||||
|
Each error carries a [Code] that maps to a well-known category (invalid input,
|
||||||
|
not found, internal, etc.) and is stable across versions — safe to persist,
|
||||||
|
transmit in API responses, or switch on programmatically.
|
||||||
|
|
||||||
|
# Basic usage
|
||||||
|
|
||||||
|
err := xerrors.New(xerrors.ErrNotFound, "user not found")
|
||||||
|
|
||||||
|
// With cause chaining
|
||||||
|
err := xerrors.Wrap(xerrors.ErrInternal, "failed to query database", dbErr)
|
||||||
|
|
||||||
|
// Convenience constructors
|
||||||
|
err := xerrors.NotFound("user %s not found", userID)
|
||||||
|
|
||||||
|
// Builder pattern
|
||||||
|
err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||||
|
WithContext("field", "email").
|
||||||
|
WithContext("tag", "required")
|
||||||
|
|
||||||
|
# Cause chaining
|
||||||
|
|
||||||
|
[Err.Unwrap] is implemented, so [errors.Is] and [errors.As] walk the full
|
||||||
|
cause chain:
|
||||||
|
|
||||||
|
if errors.Is(err, io.ErrUnexpectedEOF) { ... }
|
||||||
|
|
||||||
|
var e *xerrors.Err
|
||||||
|
if errors.As(err, &e) {
|
||||||
|
log.Println(e.Code())
|
||||||
|
}
|
||||||
|
|
||||||
|
# Structured logging (duck-typing bridge)
|
||||||
|
|
||||||
|
[Err.ErrorCode] and [Err.ErrorContext] satisfy the private interfaces that logz
|
||||||
|
defines internally. Passing an *Err to logger.Error automatically enriches the
|
||||||
|
log record with error_code and context fields — without xerrors importing logz
|
||||||
|
or logz importing xerrors.
|
||||||
|
*/
|
||||||
|
package xerrors
|
||||||
43
docs/adr/ADR-001-typed-error-codes.md
Normal file
43
docs/adr/ADR-001-typed-error-codes.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ADR-001: Typed Error Codes
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Services must communicate failure categories to callers — across HTTP responses,
|
||||||
|
log records, and internal service-to-service calls — in a way that is stable,
|
||||||
|
machine-readable, and meaningful to both humans and programs. Stringly-typed errors
|
||||||
|
(string matching on `err.Error()`) are fragile: message text can change, comparisons
|
||||||
|
are case-sensitive, and there is no compiler enforcement. Pure numeric codes (like
|
||||||
|
HTTP status codes) are opaque without a lookup table.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`Code` is declared as `type Code string`. Twelve constants are defined that map
|
||||||
|
directly to gRPC status code names (e.g. `ErrInvalidInput = "INVALID_ARGUMENT"`,
|
||||||
|
`ErrNotFound = "NOT_FOUND"`, `ErrInternal = "INTERNAL"`). Wire values are stable
|
||||||
|
across package versions and are safe to persist, transmit in API responses, or
|
||||||
|
switch on programmatically.
|
||||||
|
|
||||||
|
A single `New(code Code, message string) *Err` factory is the primary constructor;
|
||||||
|
per-code constructors (`NotFound`, `InvalidInput`, `Internal`) are provided as
|
||||||
|
convenience wrappers for the most common cases only. No separate constructor type
|
||||||
|
exists per error code — the `Code` field carries that distinction.
|
||||||
|
|
||||||
|
HTTP mapping is intentionally **not** performed in this package. The transport
|
||||||
|
layer (e.g. an HTTP middleware) is responsible for translating `Code` values to
|
||||||
|
HTTP status codes. This keeps `xerrors` free of HTTP knowledge.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Callers switch on `err.Code()` rather than parsing `err.Error()` strings.
|
||||||
|
Message text can be changed freely without breaking any switch statement.
|
||||||
|
- `Code` values are safe to log, serialise to JSON, and embed in API contracts.
|
||||||
|
- The string representation (`"NOT_FOUND"`, etc.) is readable in logs and JSON
|
||||||
|
payloads without a separate lookup.
|
||||||
|
- Adding new codes is non-breaking. Removing or renaming an existing code is a
|
||||||
|
breaking change — because callers may switch on it.
|
||||||
|
- The twelve initial codes cover the gRPC canonical set; codes outside that set
|
||||||
|
are not defined here, keeping the surface small and the mapping to gRPC/HTTP
|
||||||
|
unambiguous.
|
||||||
52
docs/adr/ADR-002-stdlib-errors-compatibility.md
Normal file
52
docs/adr/ADR-002-stdlib-errors-compatibility.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# ADR-002: stdlib errors Compatibility
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Go's `errors` package defines two key behaviours that all well-behaved error types
|
||||||
|
must support: `errors.Is` for sentinel comparison and `errors.As` for type
|
||||||
|
assertion down a cause chain. Code that wraps errors (e.g. a repository that wraps
|
||||||
|
a database error) must not break these traversal mechanisms when it introduces its
|
||||||
|
own error type.
|
||||||
|
|
||||||
|
Additionally, consumers — particularly HTTP handlers and log enrichers — need to
|
||||||
|
extract typed information (`Code`, key-value fields) without performing unsafe type
|
||||||
|
assertions directly in business logic.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`*Err` implements `Unwrap() error`, which exposes the optional cause set via
|
||||||
|
`Wrap(code, msg, cause)` or `.WithError(cause)`. This makes the full cause chain
|
||||||
|
visible to `errors.Is` and `errors.As`.
|
||||||
|
|
||||||
|
Two private duck-typing interfaces are satisfied without any import dependency on
|
||||||
|
the consumer packages:
|
||||||
|
|
||||||
|
- `ErrorCode() string` — returns the string value of the `Code`. logz checks for
|
||||||
|
this interface via `errors.As` and, when found, appends an `error_code` field to
|
||||||
|
the log record automatically.
|
||||||
|
- `ErrorContext() map[string]any` — returns the raw context fields map. logz checks
|
||||||
|
for this interface via `errors.As` and appends all key-value pairs to the log record.
|
||||||
|
|
||||||
|
`*Err` also implements `json.Marshaler`, producing
|
||||||
|
`{"code":"...","message":"...","fields":{...}}` suitable for direct use in API
|
||||||
|
error responses.
|
||||||
|
|
||||||
|
The compliance test (`compliance_test.go`) uses compile-time nil-pointer assertions
|
||||||
|
to enforce these contracts. If any method is removed or its signature changes, the
|
||||||
|
build fails immediately rather than at a distant call site in another module.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `errors.Is(err, io.ErrUnexpectedEOF)` and `errors.As(err, &target)` work through
|
||||||
|
`*Err` boundaries without any special casing.
|
||||||
|
- logz and xerrors are fully decoupled at the import level: neither imports the
|
||||||
|
other. The duck-type bridge is maintained by the private interfaces.
|
||||||
|
- `Fields()` returns a shallow copy for safe external use; `ErrorContext()` returns
|
||||||
|
the raw map for logz's internal read-only use — a deliberate split to avoid
|
||||||
|
allocating a copy on every log call.
|
||||||
|
- The `MarshalJSON` shape (`code`, `message`, `fields`) is part of the public API
|
||||||
|
contract. Changing field names is a breaking change for any caller that depends on
|
||||||
|
the JSON representation.
|
||||||
165
xerrors.go
Normal file
165
xerrors.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package xerrors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Err is a structured application error carrying a [Code], a human-readable
|
||||||
|
// message, an optional cause, and optional key-value context fields.
|
||||||
|
//
|
||||||
|
// It implements the standard error interface, [errors.Unwrap] for cause chaining,
|
||||||
|
// and [json.Marshaler] for API responses. It also satisfies the private duck-typing
|
||||||
|
// interfaces that logz uses internally to enrich log records — without either
|
||||||
|
// package importing the other.
|
||||||
|
//
|
||||||
|
// Use the builder methods [Err.WithContext] and [Err.WithError] to attach
|
||||||
|
// additional information after construction:
|
||||||
|
//
|
||||||
|
// err := xerrors.New(xerrors.ErrInvalidInput, "validation failed").
|
||||||
|
// WithContext("field", "email").
|
||||||
|
// WithContext("rule", "required").
|
||||||
|
// WithError(cause)
|
||||||
|
type Err struct {
|
||||||
|
code Code
|
||||||
|
message string
|
||||||
|
err error
|
||||||
|
fields map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates an Err with the given code and message. No cause is set.
|
||||||
|
func New(code Code, message string) *Err {
|
||||||
|
return &Err{
|
||||||
|
code: code,
|
||||||
|
message: message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap creates an Err that wraps an existing error with a code and message.
|
||||||
|
// The wrapped error is accessible via [errors.Is], [errors.As], and [Err.Unwrap].
|
||||||
|
func Wrap(code Code, message string, err error) *Err {
|
||||||
|
return &Err{
|
||||||
|
code: code,
|
||||||
|
message: message,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidInput creates an Err with [ErrInvalidInput] code.
|
||||||
|
// msg is formatted with args using [fmt.Sprintf] rules.
|
||||||
|
func InvalidInput(msg string, args ...any) *Err {
|
||||||
|
return New(ErrInvalidInput, fmt.Sprintf(msg, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFound creates an Err with [ErrNotFound] code.
|
||||||
|
// msg is formatted with args using [fmt.Sprintf] rules.
|
||||||
|
func NotFound(msg string, args ...any) *Err {
|
||||||
|
return New(ErrNotFound, fmt.Sprintf(msg, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal creates an Err with [ErrInternal] code.
|
||||||
|
// msg is formatted with args using [fmt.Sprintf] rules.
|
||||||
|
func Internal(msg string, args ...any) *Err {
|
||||||
|
return New(ErrInternal, fmt.Sprintf(msg, args...))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithContext adds a key-value pair to the error's context fields and returns
|
||||||
|
// the receiver for chaining. Calling it multiple times with the same key
|
||||||
|
// overwrites the previous value.
|
||||||
|
func (e *Err) WithContext(key string, value any) *Err {
|
||||||
|
if e.fields == nil {
|
||||||
|
e.fields = make(map[string]any)
|
||||||
|
}
|
||||||
|
e.fields[key] = value
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithError sets the underlying cause and returns the receiver for chaining.
|
||||||
|
func (e *Err) WithError(err error) *Err {
|
||||||
|
e.err = err
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface.
|
||||||
|
// Format: "INVALID_ARGUMENT: username is required → original cause"
|
||||||
|
func (e *Err) Error() string {
|
||||||
|
base := fmt.Sprintf("%s: %s", e.code, e.message)
|
||||||
|
if e.err != nil {
|
||||||
|
base = fmt.Sprintf("%s → %v", base, e.err)
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap returns the underlying cause, enabling [errors.Is] and [errors.As]
|
||||||
|
// to walk the full cause chain.
|
||||||
|
func (e *Err) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code returns the typed error code.
|
||||||
|
func (e *Err) Code() Code {
|
||||||
|
return e.code
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message returns the human-readable error message.
|
||||||
|
func (e *Err) Message() string {
|
||||||
|
return e.message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields returns a shallow copy of the context fields.
|
||||||
|
// Returns an empty (non-nil) map if no fields have been set.
|
||||||
|
func (e *Err) Fields() map[string]any {
|
||||||
|
if len(e.fields) == 0 {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(e.fields))
|
||||||
|
for k, v := range e.fields {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed returns a verbose string useful for debugging.
|
||||||
|
// Format: "code: X | message: Y | cause: Z | fields: {...}"
|
||||||
|
func (e *Err) Detailed() string {
|
||||||
|
s := fmt.Sprintf("code: %s | message: %s", e.code, e.message)
|
||||||
|
if e.err != nil {
|
||||||
|
s = fmt.Sprintf("%s | cause: %v", s, e.err)
|
||||||
|
}
|
||||||
|
if len(e.fields) > 0 {
|
||||||
|
s = fmt.Sprintf("%s | fields: %v", s, e.fields)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCode returns the string value of the error code.
|
||||||
|
//
|
||||||
|
// This method satisfies the private errorWithCode interface that logz defines
|
||||||
|
// internally. Passing an *Err to logger.Error automatically enriches the log
|
||||||
|
// record with an error_code field — without xerrors importing logz.
|
||||||
|
func (e *Err) ErrorCode() string {
|
||||||
|
return string(e.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorContext returns the raw context fields map.
|
||||||
|
//
|
||||||
|
// This method satisfies the private errorWithContext interface that logz defines
|
||||||
|
// internally. The returned map is used read-only by logz; callers who need a
|
||||||
|
// safe copy should use [Err.Fields] instead.
|
||||||
|
func (e *Err) ErrorContext() map[string]any {
|
||||||
|
return e.fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements [json.Marshaler].
|
||||||
|
// Output: {"code":"NOT_FOUND","message":"user not found","fields":{"id":"42"}}
|
||||||
|
func (e *Err) MarshalJSON() ([]byte, error) {
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Fields map[string]any `json:"fields,omitempty"`
|
||||||
|
}{
|
||||||
|
Code: string(e.code),
|
||||||
|
Message: e.message,
|
||||||
|
Fields: e.fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
272
xerrors_test.go
Normal file
272
xerrors_test.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package xerrors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
err := New(ErrInvalidInput, "test message")
|
||||||
|
|
||||||
|
if err.code != ErrInvalidInput {
|
||||||
|
t.Errorf("expected code %s, got %s", ErrInvalidInput, err.code)
|
||||||
|
}
|
||||||
|
if err.message != "test message" {
|
||||||
|
t.Errorf("expected message %q, got %q", "test message", err.message)
|
||||||
|
}
|
||||||
|
if err.err != nil {
|
||||||
|
t.Errorf("expected nil cause, got %v", err.err)
|
||||||
|
}
|
||||||
|
if err.fields != nil {
|
||||||
|
t.Errorf("expected nil fields, got %v", err.fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrap(t *testing.T) {
|
||||||
|
cause := errors.New("original error")
|
||||||
|
err := Wrap(ErrInternal, "wrapped message", cause)
|
||||||
|
|
||||||
|
if err.code != ErrInternal {
|
||||||
|
t.Errorf("expected code %s, got %s", ErrInternal, err.code)
|
||||||
|
}
|
||||||
|
if err.message != "wrapped message" {
|
||||||
|
t.Errorf("expected message %q, got %q", "wrapped message", err.message)
|
||||||
|
}
|
||||||
|
if err.err != cause {
|
||||||
|
t.Errorf("expected cause %v, got %v", cause, err.err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvenienceConstructors(t *testing.T) {
|
||||||
|
t.Run("InvalidInput", func(t *testing.T) {
|
||||||
|
err := InvalidInput("field %s is required", "email")
|
||||||
|
if err.code != ErrInvalidInput {
|
||||||
|
t.Errorf("expected code %s, got %s", ErrInvalidInput, err.code)
|
||||||
|
}
|
||||||
|
if err.message != "field email is required" {
|
||||||
|
t.Errorf("unexpected message: %s", err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NotFound", func(t *testing.T) {
|
||||||
|
err := NotFound("user %d not found", 42)
|
||||||
|
if err.code != ErrNotFound {
|
||||||
|
t.Errorf("expected code %s, got %s", ErrNotFound, err.code)
|
||||||
|
}
|
||||||
|
if err.message != "user 42 not found" {
|
||||||
|
t.Errorf("unexpected message: %s", err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Internal", func(t *testing.T) {
|
||||||
|
err := Internal("db error: %v", errors.New("conn lost"))
|
||||||
|
if err.code != ErrInternal {
|
||||||
|
t.Errorf("expected code %s, got %s", ErrInternal, err.code)
|
||||||
|
}
|
||||||
|
if err.message != "db error: conn lost" {
|
||||||
|
t.Errorf("unexpected message: %s", err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_Error(t *testing.T) {
|
||||||
|
t.Run("without cause", func(t *testing.T) {
|
||||||
|
err := New(ErrNotFound, "user not found")
|
||||||
|
want := "NOT_FOUND: user not found"
|
||||||
|
if err.Error() != want {
|
||||||
|
t.Errorf("expected %q, got %q", want, err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with cause", func(t *testing.T) {
|
||||||
|
err := New(ErrAlreadyExists, "conflict occurred").WithError(errors.New("db error"))
|
||||||
|
want := "ALREADY_EXISTS: conflict occurred → db error"
|
||||||
|
if err.Error() != want {
|
||||||
|
t.Errorf("expected %q, got %q", want, err.Error())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_Unwrap(t *testing.T) {
|
||||||
|
sentinel := errors.New("sentinel")
|
||||||
|
wrapped := Wrap(ErrInternal, "something failed", sentinel)
|
||||||
|
|
||||||
|
if !errors.Is(wrapped, sentinel) {
|
||||||
|
t.Error("errors.Is should find sentinel through Unwrap")
|
||||||
|
}
|
||||||
|
|
||||||
|
var target *Err
|
||||||
|
outer := fmt.Errorf("outer: %w", wrapped)
|
||||||
|
if !errors.As(outer, &target) {
|
||||||
|
t.Error("errors.As should find *Err through fmt.Errorf wrapping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_Detailed(t *testing.T) {
|
||||||
|
err := New(ErrInvalidInput, "invalid name").
|
||||||
|
WithContext("field", "name").
|
||||||
|
WithError(errors.New("too short"))
|
||||||
|
|
||||||
|
d := err.Detailed()
|
||||||
|
|
||||||
|
if !strings.Contains(d, "code: INVALID_ARGUMENT") {
|
||||||
|
t.Errorf("Detailed missing code, got: %s", d)
|
||||||
|
}
|
||||||
|
if !strings.Contains(d, "message: invalid name") {
|
||||||
|
t.Errorf("Detailed missing message, got: %s", d)
|
||||||
|
}
|
||||||
|
if !strings.Contains(d, "cause: too short") {
|
||||||
|
t.Errorf("Detailed missing cause, got: %s", d)
|
||||||
|
}
|
||||||
|
if !strings.Contains(d, "fields:") {
|
||||||
|
t.Errorf("Detailed missing fields, got: %s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_Accessors(t *testing.T) {
|
||||||
|
err := New(ErrInvalidInput, "bad input").
|
||||||
|
WithContext("k", "v")
|
||||||
|
|
||||||
|
if err.Code() != ErrInvalidInput {
|
||||||
|
t.Errorf("Code() = %s, want %s", err.Code(), ErrInvalidInput)
|
||||||
|
}
|
||||||
|
if err.Message() != "bad input" {
|
||||||
|
t.Errorf("Message() = %q, want %q", err.Message(), "bad input")
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := err.Fields()
|
||||||
|
if fields["k"] != "v" {
|
||||||
|
t.Errorf("Fields()[k] = %v, want v", fields["k"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_Fields_DefensiveCopy(t *testing.T) {
|
||||||
|
err := New(ErrInternal, "err").WithContext("key", "original")
|
||||||
|
fields := err.Fields()
|
||||||
|
fields["key"] = "mutated"
|
||||||
|
|
||||||
|
// The internal state must not be affected.
|
||||||
|
if err.fields["key"] != "original" {
|
||||||
|
t.Error("Fields() returned the internal map directly; mutation affected the error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_Fields_EmptyMap(t *testing.T) {
|
||||||
|
err := New(ErrInternal, "no fields")
|
||||||
|
fields := err.Fields()
|
||||||
|
if fields == nil {
|
||||||
|
t.Error("Fields() must return a non-nil map even when no fields are set")
|
||||||
|
}
|
||||||
|
if len(fields) != 0 {
|
||||||
|
t.Errorf("Fields() must return an empty map, got %v", fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_WithContext_Chaining(t *testing.T) {
|
||||||
|
err := New(ErrInvalidInput, "multi-field error").
|
||||||
|
WithContext("field1", "a").
|
||||||
|
WithContext("field2", "b")
|
||||||
|
|
||||||
|
if err.fields["field1"] != "a" || err.fields["field2"] != "b" {
|
||||||
|
t.Errorf("WithContext chaining failed, fields: %v", err.fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_WithContext_Overwrite(t *testing.T) {
|
||||||
|
err := New(ErrInvalidInput, "msg").
|
||||||
|
WithContext("key", "first").
|
||||||
|
WithContext("key", "second")
|
||||||
|
|
||||||
|
if err.fields["key"] != "second" {
|
||||||
|
t.Errorf("expected overwrite to second, got %v", err.fields["key"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_MarshalJSON(t *testing.T) {
|
||||||
|
t.Run("with fields", func(t *testing.T) {
|
||||||
|
err := New(ErrNotFound, "user not found").WithContext("id", "42")
|
||||||
|
b, jsonErr := json.Marshal(err)
|
||||||
|
if jsonErr != nil {
|
||||||
|
t.Fatalf("MarshalJSON error: %v", jsonErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out map[string]any
|
||||||
|
if jsonErr = json.Unmarshal(b, &out); jsonErr != nil {
|
||||||
|
t.Fatalf("unmarshal error: %v", jsonErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out["code"] != "NOT_FOUND" {
|
||||||
|
t.Errorf("json code = %v, want NOT_FOUND", out["code"])
|
||||||
|
}
|
||||||
|
if out["message"] != "user not found" {
|
||||||
|
t.Errorf("json message = %v, want 'user not found'", out["message"])
|
||||||
|
}
|
||||||
|
if out["fields"] == nil {
|
||||||
|
t.Error("json fields key missing")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("without fields omitempty", func(t *testing.T) {
|
||||||
|
err := New(ErrInternal, "boom")
|
||||||
|
b, jsonErr := json.Marshal(err)
|
||||||
|
if jsonErr != nil {
|
||||||
|
t.Fatalf("MarshalJSON error: %v", jsonErr)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(b), "fields") {
|
||||||
|
t.Errorf("json should omit fields key when empty, got: %s", b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_DuckTyping_ErrorCode(t *testing.T) {
|
||||||
|
err := New(ErrPermissionDenied, "not allowed")
|
||||||
|
if err.ErrorCode() != "PERMISSION_DENIED" {
|
||||||
|
t.Errorf("ErrorCode() = %s, want PERMISSION_DENIED", err.ErrorCode())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_DuckTyping_ErrorContext(t *testing.T) {
|
||||||
|
err := New(ErrInvalidInput, "msg").WithContext("field", "email")
|
||||||
|
ctx := err.ErrorContext()
|
||||||
|
if ctx["field"] != "email" {
|
||||||
|
t.Errorf("ErrorContext()[field] = %v, want email", ctx["field"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestErr_DuckTyping_ErrorContext_Nil(t *testing.T) {
|
||||||
|
err := New(ErrInternal, "no fields")
|
||||||
|
// ErrorContext returns the raw internal map — nil is acceptable here
|
||||||
|
// (logz handles nil maps in its enrichment loop).
|
||||||
|
_ = err.ErrorContext()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCode_Description(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
code Code
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ErrInvalidInput, "Invalid input provided"},
|
||||||
|
{ErrUnauthorized, "Authentication required"},
|
||||||
|
{ErrPermissionDenied, "Insufficient permissions"},
|
||||||
|
{ErrNotFound, "Resource not found"},
|
||||||
|
{ErrAlreadyExists, "Resource already exists"},
|
||||||
|
{ErrGone, "Resource permanently deleted"},
|
||||||
|
{ErrPreconditionFailed, "Precondition not met"},
|
||||||
|
{ErrRateLimited, "Rate limit exceeded"},
|
||||||
|
{ErrCancelled, "Request cancelled"},
|
||||||
|
{ErrInternal, "Internal error"},
|
||||||
|
{ErrNotImplemented, "Not implemented"},
|
||||||
|
{ErrUnavailable, "Service unavailable"},
|
||||||
|
{ErrDeadlineExceeded, "Deadline exceeded"},
|
||||||
|
{Code("CUSTOM_CODE"), "CUSTOM_CODE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.code.Description(); got != tt.want {
|
||||||
|
t.Errorf("Code(%s).Description() = %q, want %q", tt.code, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user