derrors

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 7, 2025 License: Apache-2.0 Imports: 3 Imported by: 0

README

derrors — predictable, fast, boring errors for Go

A small, opinionated toolkit for application errors in services. It gives you:

  • A tiny, transport‑agnostic *derrors.Error type you can pass anywhere.
  • A status mapper that deterministically converts (Code, Reason){HTTP, gRPC} with longest‑prefix matching over dotted reasons (supports * per segment).
  • Thin adapters for HTTP (httpx) and gRPC (grpcx) so your wire formats are consistent.
  • Versioned contracts in api/derrors/v1: JSON Schema for the HTTP View and Protobuf for the rich Descriptor.

The philosophy is simple: keep domain errors small, put transport decisions at the edge, and make mapping explicit and testable.


Contents


Why this exists

Service code shouldn’t reinvent error handling in every handler. We want:

  • Predictability — the same domain error maps to the same HTTP/gRPC status everywhere.
  • Separation of concerns — domain errors avoid transport baggage (status codes, correlation, trace).
  • Explained behavior — you can inspect exactly why a certain status was chosen (override vs prefix vs default).
  • Performance — hot paths must be allocation‑free and very fast.

derrors is a practical distillation of those goals.


Install

go get dirpx.dev/derrors@latest

The repo is split into small packages (code, reason, mapper, httpx, grpcx) you can import individually.


At a glance

e := &derrors.Error{
Code:    code.Unavailable,
Reason:  reason.MustParse("storage.pg.connect_timeout"),
Message: "temporarily unavailable",
}

// Map to HTTP/gRPC statuses:
st := m.Status(e.Code, e.Reason) // override > prefix(LPM) > default > fallback

// HTTP:
httpx.Writer{Mapper: m}.Write(w, e, httpx.Meta{
Correlation: correlationIDFrom(ctx),
TraceID:     traceIDFrom(ctx),
SpanID:      spanIDFrom(ctx),
// RetryAfterSeconds, Links, Fields if needed
})

// gRPC (interceptor):
srv := grpc.NewServer(grpc.UnaryInterceptor(
grpcx.UnaryServerInterceptor(m, func(ctx context.Context, e *derrors.Error) grpcx.Extras {
return grpcx.Extras{
CorrelationID: correlationIDFrom(ctx),
TraceID:       traceIDFrom(ctx),
SpanID:        spanIDFrom(ctx),
// Retry/Quota/Violations/Links/Causes/Env/Tags if you have them
}
}),
))

Core types

*derrors.Error

A small value‑like error that owns only what the error itself should own.

type Error struct {
Code    code.Code      // business code (e.g., "unavailable", "invalid")
Reason  reason.Reason  // dotted reason ("storage.pg.connect_timeout"), optional
Message string         // short, safe, human‑readable message
Details map[string]any // structured ad‑hoc details (safe keys only)
Cause   error          // wrapped technical cause
}

func (e *Error) Error() string
func (e *Error) Unwrap() error
func (e *Error) WithReason(r reason.Reason) *Error // copy‑on‑write
func (e *Error) WithMessage(msg string) *Error     // copy‑on‑write
func (e *Error) WithDetail(k string, v any) *Error // copy‑on‑write

No transport fields (status, correlation, trace/span) live here. Adapters inject those at the boundary.


Codes & Reasons

  • Codes (code.Code) are short business labels like "unavailable", "invalid", "conflict", etc. You own the set.
  • Reasons (reason.Reason) are normalized dotted paths used for routing and mapping:
    • Segments: a-z, digits, _ in the middle; start with a-z.
    • Examples: storage.pg.connect_timeout, auth.jwt.verify.
    • Mapper prefixes may include * to match exactly one segment: auth.*.verify.

Helpers:

r := reason.MustParse("storage.pg.connect_timeout")
s := r.String()     // canonical lowercase dotted form

Status mapper

Deterministic mapping from (Code, Reason) to {HTTP, gRPC} with this precedence:

  1. Override — per‑code hard override.
  2. Prefix — longest prefix match over Reason using a segment trie (segments separated by ., * matches one segment).
  3. Default — per‑code default mapping.
  4. Fallback — global fallback (e.g., 500/Internal).

Configure once:

m, _ := mapper.New(
  mapper.WithHTTPDefaults(map[code.Code]int{
    code.Unavailable: 503,
    code.Invalid:     400,
  }),
  mapper.WithGRPCDefaults(map[code.Code]codes.Code{
    code.Unavailable: codes.Unavailable,
    code.Invalid:     codes.InvalidArgument,
  }),

  // Highest priority:
  mapper.WithHTTPOverride(code.Canceled, 408),
  mapper.WithGRPCOverride(code.Canceled, codes.Canceled),

  // Prefix rules (segment‑aware LPM; "*" matches one segment)
  mapper.WithHTTPPrefix(code.Unavailable, "storage.pg", 503),
  mapper.WithGRPCPrefix(code.Unavailable, "storage.pg", codes.Unavailable),
)

Explain decisions (great for unit tests and debugging):

code="unavailable" reason="storage.pg.connect_timeout"
http: source=prefix pattern="storage.pg" -> 503
grpc: source=prefix pattern="storage.pg" -> UNAVAILABLE(14)

Under the hood: a compact segment trie explores exact and wildcard branches. The hot path is allocation‑free.


HTTP adapter (httpx)

httpx.Writer renders the HTTP View (public JSON) and sets the HTTP status from the mapper.

Input:

  • *derrors.Error
  • httpx.Meta with optional Correlation, TraceID, SpanID, RetryAfterSeconds, Links, Fields

Output:

  • Body matches api/derrors/v1/error.view.schema.json exactly.
  • Status is Mapper.Status(err.Code, err.Reason).HTTP.
  • Retry-After header is set if RetryAfterSeconds > 0.

Encoding options:

  • Go struct + encoding/json (simple), or
  • derrors.v1.ErrorView + protojson (exact parity with schema; snake_case via json_name).

The provided implementation uses protojson for 1:1 parity.


gRPC adapter (grpcx)

grpcx.UnaryServerInterceptor converts any returned *derrors.Error into:

  • status.Code from the mapper (Mapper.Status(...).GRPC)
  • status.WithDetails(derrors.v1.ErrorDescriptor) — the rich descriptor (protobuf) carrying code, reason, message, mapped HTTP/gRPC, correlation, trace/span, and optional retry/quota/violations/links/causes/env/tags.

Helper for tests:

desc, ok := grpcx.ExtractDescriptor(err) // read ErrorDescriptor back from details

Contracts (wire formats)

The library ships contracts under api/derrors/v1:

  • error.view.schema.json — JSON Schema for the HTTP View (public payload).
  • error.proto — Protobuf for the rich ErrorDescriptor (gRPC details / logs / buses).
  • (Optional) error.view.proto — Protobuf for ErrorView if you prefer to emit HTTP JSON via protojson.

Keep contracts versioned. Add optional fields freely; bump v1 → v2 for breaking semantics.


End‑to‑end examples

HTTP handler
func createOrder(w http.ResponseWriter, r *http.Request) {
  if err := svc.Create(r.Context()); err != nil {
    de := &derrors.Error{
      Code:    code.Unavailable,
      Reason:  reason.MustParse("storage.pg.connect_timeout"),
      Message: "temporarily unavailable",
      Cause:   err,
    }
    httpx.Writer{Mapper: m}.Write(w, de, httpx.Meta{
      Correlation: idFromHeaders(r),
      TraceID:     traceIDFrom(r.Context()),
      SpanID:      spanIDFrom(r.Context()),
      // RetryAfterSeconds: 5,
      // Links, Fields...
    })
    return
  }
  w.WriteHeader(http.StatusCreated)
}
gRPC server
srv := grpc.NewServer(grpc.UnaryInterceptor(
  grpcx.UnaryServerInterceptor(m, func(ctx context.Context, e *derrors.Error) grpcx.Extras {
    return grpcx.Extras{
      CorrelationID: correlationIDFrom(ctx),
      TraceID:       traceIDFrom(ctx),
      SpanID:        spanIDFrom(ctx),
      // Retry/Quota/Violations/Links/Causes/Env/Tags if needed
    }
  }),
))
Mapper tests
func TestExplain(t *testing.T) {
  out := m.Explain(code.Unavailable, reason.MustParse("storage.pg.connect_timeout"))
  require.Contains(t, out, `source=prefix`)
}

Performance

Hot‑path targets (observed on a modern laptop; your results may vary):

  • BenchmarkMapperStatus_Default ~ 15–25 ns/op, 0 alloc/op
  • BenchmarkMapperStatus_Override ~ 10–20 ns/op, 0 alloc/op
  • BenchmarkMapperStatus_PrefixHit ~ 80–120 ns/op, 0 alloc/op
  • Trie match (depth 4–8, with/without *) ~ 80–300 ns/op, 0 alloc/op

Why it’s fast:

  • Segment trie explores exact + wildcard branches with a tiny DFS and no heap churn on steady‑state.
  • Mapper keeps prebuilt tries per code and uses straight‑line checks (override → trie → default).

Testing & fuzzing

We include:

  • Unit tests for mapper precedence and invalid inputs.
  • Golden test fixing the Explain() output format.
  • Fuzz test that differential‑checks trie matching against a naive longest‑prefix matcher (catches wildcard edge cases).

Useful commands:

# run all tests
go test ./...

# race detector
go test -race ./...

# trie fuzzing (package-by-package)
go test ./mapper/internal/segmenttrie -run=^$ -fuzz=Fuzz -fuzztime=30s

# benchmarks
go test ./mapper/internal/segmenttrie -bench=. -benchmem
go test ./mapper -bench=BenchmarkMapperStatus -benchmem

Buf & codegen

We use Buf v2 with module rooted at api/. Typical workflow:

# format, lint, generate
make proto-format
make proto-lint
make proto-gen

# check breaking changes vs main
make proto-breaking BASE=origin/main

Generation target can be colocated (out: api) or a separate artifacts dir (out: gen/go). Choose one and keep imports consistent.


Project layout

api/
  derrors/
    v1/
      error.proto               # rich ErrorDescriptor (protobuf)
      error.view.schema.json    # HTTP ErrorView (JSON Schema)
      error.schema.json         # rich ErrorDescriptor (JSON Schema for logs/bus)
      # optional: error.view.proto (proto View for protojson HTTP)

httpx/
  httpx.go                      # HTTP writer → View JSON (protojson)

grpcx/
  grpcx.go                      # gRPC interceptor → Status + Details(Descriptor)

mapper/
  builder.go
  defaults.go
  mapper.go
  helpers.go
  doc.go
  explain_golden_test.go
  mapper_test.go
  internal/segmenttrie/
    trie.go
    trie_test.go
    trie_bench_test.go

code/, reason/
  code.go, codes.go, reason.go, tests, docs

apis/
  mapper.go, status.go, interfaces... (transport‑agnostic)

Versioning & compatibility

  • Contracts live in api/derrors/v1. Additive changes are OK (optional fields). For breaking changes, fork to v2.
  • The Go API follows semver; breaking changes will bump the major version tag.

FAQ

Why separate View (HTTP) and Descriptor (gRPC)?
They serve different audiences. View is a slim public payload; Descriptor is rich and portable for details/logs/internal tooling. Keeping them separate avoids over‑exposing internals to HTTP clients.

Do I have to use proto for HTTP?
No. We use protojson for strict parity with the schema, but you can emit a plain Go struct if you prefer. The contract stays the same.

Where do correlation/trace/span belong?
In adapters (httpx/grpcx) and logs — not in the domain error.

Can I override status per service?
Yes. Use per‑code overrides and reason‑prefix rules. Precedence is explicit and testable.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Error

type Error struct {
	// Code is the primary classification of the error, e.g. "unavailable",
	// "invalid", "not_found". Must be a normalized code from derrors/code.
	Code code.Code

	// Reason refines the Code with a machine-usable marker, e.g.
	// "storage.pg.connect_timeout" or "auth.jwt.expired".
	// May be empty when the Code is descriptive enough.
	Reason reason.Reason

	// Message is a human-readable explanation. This is what should end up
	// in logs or in the "message" field of an HTTP error response.
	Message string

	// Details is an optional, shallow map of extra fields. Use this to expose
	// structured error data to API clients (ids, limits, resource names, etc.).
	// The map is treated as immutable: WithDetail/WithDetails always copy it.
	Details map[string]any

	// Cause holds the wrapped underlying error (if any). This is used for
	// errors.Is / errors.As and for debugging in lower layers.
	Cause error
}

Error is the canonical rich error type for dirpx.

It carries:

  • Code: high-level, normalized error code (required);
  • Reason: optional, more specific machine-friendly cause;
  • Message: human-oriented description (what went wrong);
  • Details: arbitrary key/value payload (for logging / HTTP body);
  • Cause: wrapped underlying error for debugging / unwrapping.

All mutation helpers (WithX) return a shallow copy, so Error instances can be safely shared and modified in a functional style.

func E

func E(c code.Code, msg string, opts ...Option) *Error

E is a convenience constructor for Error.

Usage:

return derrors.E(code.Unavailable, "storage is down",
    derrors.WithReasonOption("storage.pg.connect_timeout"),
    derrors.WithDetailOption("host", "db:5432"),
)

It always returns a *new* Error and applies all provided options in order.

func (*Error) Error

func (e *Error) Error() string

Error implements the built-in error interface.

The format is:

<code>: <message>

or, when Reason is present:

<code>:<reason>: <message>

This makes the error both human- and machine-scannable in logs.

func (*Error) Unwrap

func (e *Error) Unwrap() error

Unwrap returns the underlying cause, enabling errors.Is / errors.As chains.

func (*Error) WithCause

func (e *Error) WithCause(err error) *Error

WithCause returns a shallow copy of e with the given underlying cause attached. If err is nil, the original error is returned unchanged.

func (*Error) WithDetail

func (e *Error) WithDetail(k string, v any) *Error

WithDetail returns a shallow copy of e with one extra key/value in Details.

The method always copies the map to preserve immutability. This prevents surprising modifications across goroutines or shared error values.

func (*Error) WithDetails

func (e *Error) WithDetails(kv map[string]any) *Error

WithDetails returns a shallow copy of e with all provided kv merged into Details.

If the Error already has Details, both maps are copied and merged, with kv taking precedence on key conflicts.

func (*Error) WithMessage

func (e *Error) WithMessage(msg string) *Error

WithMessage returns a shallow copy of e with a replaced human message. Useful when you want to keep the Code/Reason but present the message in a different language or context.

func (*Error) WithReason

func (e *Error) WithReason(r reason.Reason) *Error

WithReason returns a shallow copy of e with the given Reason set. The original error is not modified.

type Option

type Option func(*Error) *Error

Option is a functional option for constructing or transforming an Error. It always takes an *Error and returns a (possibly new) *Error.

func WithCauseOption

func WithCauseOption(err error) Option

WithCauseOption attaches a cause on construction. Intended to be used with E(...).

func WithDetailOption

func WithDetailOption(k string, v any) Option

WithDetailOption adds a single detail key/value on construction. Intended to be used with E(...).

func WithDetailsOption

func WithDetailsOption(kv map[string]any) Option

WithDetailsOption merges multiple detail key/values on construction. Intended to be used with E(...).

func WithReasonOption

func WithReasonOption(r reason.Reason) Option

WithReasonOption sets the Reason on the error being constructed. Intended to be used with E(...).

Directories

Path Synopsis
api
Package apis defines the public Go-level contracts for dirpx error handling.
Package apis defines the public Go-level contracts for dirpx error handling.
Package code provides parsing, normalization and validation for derrors error codes.
Package code provides parsing, normalization and validation for derrors error codes.
Package mapper provides deterministic, immutable mappings from logical derrors codes (dirpx.dev/derrors/code) and optional reasons (dirpx.dev/derrors/reason) to transport-level statuses for HTTP and gRPC.
Package mapper provides deterministic, immutable mappings from logical derrors codes (dirpx.dev/derrors/code) and optional reasons (dirpx.dev/derrors/reason) to transport-level statuses for HTTP and gRPC.
Package reason defines an optional, structured refinement for derrors codes.
Package reason defines an optional, structured refinement for derrors codes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL