commit 3cc36801a1f3cc4d7be79b2c1fd987b70a170c21 Author: Rene Nochebuena Date: Wed Mar 18 13:09:31 2026 -0600 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/ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..54f5aae --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..221da82 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c4d1f1f --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba0a6de --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b33b48 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c90fda --- /dev/null +++ b/README.md @@ -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 diff --git a/code.go b/code.go new file mode 100644 index 0000000..0affb4c --- /dev/null +++ b/code.go @@ -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) + } +} diff --git a/compliance_test.go b/compliance_test.go new file mode 100644 index 0000000..facacab --- /dev/null +++ b/compliance_test.go @@ -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) diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..85c9b12 --- /dev/null +++ b/doc.go @@ -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 diff --git a/docs/adr/ADR-001-typed-error-codes.md b/docs/adr/ADR-001-typed-error-codes.md new file mode 100644 index 0000000..6644ee7 --- /dev/null +++ b/docs/adr/ADR-001-typed-error-codes.md @@ -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. diff --git a/docs/adr/ADR-002-stdlib-errors-compatibility.md b/docs/adr/ADR-002-stdlib-errors-compatibility.md new file mode 100644 index 0000000..15272a1 --- /dev/null +++ b/docs/adr/ADR-002-stdlib-errors-compatibility.md @@ -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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d3e970b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.nochebuena.dev/go/xerrors + +go 1.25 diff --git a/xerrors.go b/xerrors.go new file mode 100644 index 0000000..195a591 --- /dev/null +++ b/xerrors.go @@ -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, + }) +} diff --git a/xerrors_test.go b/xerrors_test.go new file mode 100644 index 0000000..0c77f5e --- /dev/null +++ b/xerrors_test.go @@ -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) + } + } +}