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