chore: initial plan
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
# Error Creation
|
||||
|
||||
## Errors as Values
|
||||
|
||||
Go treats errors as ordinary values implementing the `error` interface:
|
||||
|
||||
```go
|
||||
type error interface {
|
||||
Error() string
|
||||
}
|
||||
```
|
||||
|
||||
This means errors are returned, not thrown. Every function that can fail returns an `error` as its last return value, and every caller must check it.
|
||||
|
||||
```go
|
||||
// ✗ Bad — silently discarding errors
|
||||
data, _ := os.ReadFile("config.yaml")
|
||||
|
||||
// ✗ Bad — only checking in some branches
|
||||
result, err := doSomething()
|
||||
fmt.Println(result) // using result without checking err
|
||||
|
||||
// ✓ Good — always check before using other return values
|
||||
data, err := os.ReadFile("config.yaml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading config: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Error String Conventions
|
||||
|
||||
Error strings MUST be lowercase, without trailing punctuation, and should not duplicate the context that wrapping will add.
|
||||
|
||||
```go
|
||||
// ✗ Bad — capitalized, punctuation, redundant prefix
|
||||
return errors.New("Failed to connect to database.")
|
||||
return fmt.Errorf("UserService: failed to fetch user: %w", err)
|
||||
|
||||
// ✓ Good — lowercase, no punctuation, concise
|
||||
return errors.New("connection refused")
|
||||
return fmt.Errorf("fetching user: %w", err)
|
||||
```
|
||||
|
||||
When errors are wrapped through multiple layers, each layer adds its own prefix. The result reads like a chain:
|
||||
|
||||
```
|
||||
creating order: charging card: connecting to payment gateway: connection refused
|
||||
```
|
||||
|
||||
## Creating Errors
|
||||
|
||||
### `errors.New` — static error messages
|
||||
|
||||
```go
|
||||
var ErrNotFound = errors.New("not found")
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
```
|
||||
|
||||
### `fmt.Errorf` — dynamic error messages
|
||||
|
||||
```go
|
||||
import "github.com/samber/oops"
|
||||
|
||||
// ✗ Avoid at log/APM boundaries — each user/tenant combo becomes a unique group
|
||||
return fmt.Errorf("user %s not found in tenant %s", userID, tenantID)
|
||||
|
||||
// ✓ Prefer for grouped production errors — static message, variable data as structured attributes
|
||||
return oops.With("user_id", userID).With("tenant_id", tenantID).Errorf("user not found")
|
||||
```
|
||||
|
||||
See [Low-Cardinality Error Messages](#low-cardinality-error-messages) for why this matters.
|
||||
|
||||
### Decision table: which error strategy to use
|
||||
|
||||
| Situation | Strategy | Example |
|
||||
| --- | --- | --- |
|
||||
| Caller needs to match a specific condition | Sentinel error (`errors.New` as package var) | `var ErrNotFound = errors.New("not found")` |
|
||||
| Caller needs to extract structured data | Custom error type | `type ValidationError struct { Field, Msg string }` |
|
||||
| Error is purely informational, not matched on | `fmt.Errorf` or `errors.New` | `fmt.Errorf("connecting to %s: %w", addr, err)` |
|
||||
| Need stack traces, user context, structured attrs | `samber/oops` | See [Why Use samber/oops](./error-handling.md#why-use-samberoops) |
|
||||
|
||||
## Low-Cardinality Error Messages
|
||||
|
||||
APM and log aggregation tools (Datadog, Loki, Sentry) commonly group events by the logged message or exception fingerprint. When the stable log message contains variable data, every unique combination can create a separate group — dashboards become noisy and alerting breaks.
|
||||
|
||||
```go
|
||||
import "github.com/samber/oops"
|
||||
|
||||
// ✗ Bad at the log boundary — each file/line combo can create a unique group
|
||||
fmt.Errorf("error in %s at line %d of the csv", csvPath, line)
|
||||
|
||||
// ✓ Good (stdlib) — static error, structured attributes at the log site
|
||||
err := errors.New("csv parsing error")
|
||||
// ... later, at the logging boundary:
|
||||
slog.Error("csv parsing failed", "error", err, "csv_file_path", csvPath, "csv_file_line", line)
|
||||
|
||||
// ✓ Good (samber/oops, external dependency) — attributes travel with the error
|
||||
oops.With("csv_file_path", csvPath).With("csv_file_line", line).Errorf("csv parsing error")
|
||||
```
|
||||
|
||||
The stdlib approach works but scatters context: the error travels up the stack and the handler logging it may no longer have access to the variable data. `samber/oops` (external dependency `github.com/samber/oops`) solves this by attaching structured attributes directly to the error, so they're available wherever the error is eventually logged.
|
||||
|
||||
**Static wrapping prefixes are fine** — `fmt.Errorf("fetching user: %w", err)` is low-cardinality because the prefix never changes. Dynamic context in returned errors is sometimes useful for CLI output or debugging, but production logging should keep the grouping message stable and attach IDs, paths, counts, and other variable data as structured attributes.
|
||||
|
||||
## Custom Error Types
|
||||
|
||||
Create custom error types when callers need to extract structured data from errors.
|
||||
|
||||
```go
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// Usage
|
||||
func validateAge(age int) error {
|
||||
if age < 0 {
|
||||
return &ValidationError{Field: "age", Message: "must be non-negative"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Custom types that wrap other errors
|
||||
|
||||
Implement `Unwrap()` so `errors.Is` and `errors.As` can traverse the chain:
|
||||
|
||||
```go
|
||||
type QueryError struct {
|
||||
Query string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *QueryError) Error() string {
|
||||
return fmt.Sprintf("query %q: %v", e.Query, e.Err)
|
||||
}
|
||||
|
||||
func (e *QueryError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,129 @@
|
||||
# Error Handling Patterns and Logging
|
||||
|
||||
## The Single Handling Rule
|
||||
|
||||
An error MUST be handled exactly once: either log it or return it, never both. Doing both causes duplicate log entries and makes debugging harder.
|
||||
|
||||
```go
|
||||
// ✗ Bad — logs AND returns (duplicate noise)
|
||||
func processOrder(id string) error {
|
||||
err := chargeCard(id)
|
||||
if err != nil {
|
||||
log.Printf("failed to charge card: %v", err)
|
||||
return fmt.Errorf("charging card: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✓ Good — return with context, let the caller decide
|
||||
func processOrder(id string) error {
|
||||
err := chargeCard(id)
|
||||
if err != nil {
|
||||
return oops.
|
||||
With("order_id", id).
|
||||
Wrapf(err, "charging card")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✓ Good — handle at the top level (HTTP handler, main, etc.)
|
||||
func handleOrder(w http.ResponseWriter, r *http.Request) {
|
||||
err := processOrder(r.FormValue("id"))
|
||||
if err != nil {
|
||||
slog.Error("order failed", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
```
|
||||
|
||||
## Panic and Recover
|
||||
|
||||
### When to panic
|
||||
|
||||
Panic MUST only be used for truly unrecoverable states — programmer errors, impossible conditions, or corrupt invariants. NEVER use panic for expected failures like network timeouts or missing files.
|
||||
|
||||
```go
|
||||
// ✓ Acceptable — programmer error in initialization
|
||||
func MustCompileRegex(pattern string) *regexp.Regexp {
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid regex %q: %v", pattern, err))
|
||||
}
|
||||
return re
|
||||
}
|
||||
|
||||
// ✗ Bad — panic for a normal failure
|
||||
func GetUser(id string) *User {
|
||||
user, err := db.Find(id)
|
||||
if err != nil {
|
||||
panic(err) // callers cannot recover gracefully
|
||||
}
|
||||
return user
|
||||
}
|
||||
```
|
||||
|
||||
### Recovering from panics
|
||||
|
||||
Use `recover` in deferred functions at goroutine boundaries (HTTP handlers, worker goroutines) to prevent one panic from crashing the entire process.
|
||||
|
||||
```go
|
||||
func safeHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic recovered",
|
||||
"panic", r,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For structured panic recovery with `samber/oops`, see the `samber/cc-skills-golang@golang-samber-oops` skill.
|
||||
|
||||
## Why Use `samber/oops`
|
||||
|
||||
- **Stack traces** — you see `"connection refused"` but need to know where it originated
|
||||
- **Structured context** — user ID, tenant ID, or request metadata attached to the error
|
||||
- **Error codes** — machine-readable identifiers for monitoring dashboards
|
||||
- **Public/private separation** — safe message to show end users
|
||||
- ...
|
||||
|
||||
`samber/oops` is a **drop-in replacement** that fills these gaps. Every `oops` error implements the standard `error` interface, works with `errors.Is`/`errors.As`, and adds structured attributes:
|
||||
|
||||
```go
|
||||
// ✗ Before — standard errors, no context
|
||||
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) error {
|
||||
err := s.db.Insert(ctx, req.Order)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inserting order: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✓ After — samber/oops, rich context for debugging
|
||||
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) error {
|
||||
err := s.db.Insert(ctx, req.Order)
|
||||
if err != nil {
|
||||
return oops.
|
||||
In("order-service").
|
||||
Code("order_insert_failed").
|
||||
User(req.UserID).
|
||||
With("order_id", req.Order.ID).
|
||||
Wrapf(err, "inserting order")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
When this error is logged, you get the stack trace, user ID, order ID, domain, error code, and the full error chain — all structured and machine-parseable.
|
||||
|
||||
## Logging Errors with `slog`
|
||||
|
||||
→ See `samber/cc-skills-golang@golang-observability` skill for comprehensive structured logging guidance, including `slog` setup, log levels, log handlers, HTTP middleware, and cost considerations.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Error Wrapping and Inspection
|
||||
|
||||
## Error Wrapping with `%w`
|
||||
|
||||
Wrapping preserves the original error in a chain that callers can inspect with `errors.Is` and `errors.As`. Errors SHOULD be wrapped at each layer to build a readable chain.
|
||||
|
||||
```go
|
||||
// ✓ Good — wraps with context, preserves the chain
|
||||
func (s *UserService) GetUser(id string) (*User, error) {
|
||||
user, err := s.repo.FindByID(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting user %s: %w", id, err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
```
|
||||
|
||||
### `%w` vs `%v`: controlling exposure
|
||||
|
||||
Use `%w` within your module to preserve the error chain. Use `%v` at public API / system boundaries to prevent callers from depending on internal error types.
|
||||
|
||||
```go
|
||||
// Internal layer — wrap to preserve chain
|
||||
func (r *repo) fetch(id string) error {
|
||||
return fmt.Errorf("querying database: %w", err)
|
||||
}
|
||||
|
||||
// Public API boundary — break chain to hide internals
|
||||
func (s *PublicService) GetItem(id string) error {
|
||||
err := s.repo.fetch(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("item unavailable: %v", err) // %v — callers cannot unwrap
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## Inspecting Errors: `errors.Is` and `errors.As`
|
||||
|
||||
### `errors.Is` — match against a sentinel value
|
||||
|
||||
```go
|
||||
// ✗ Bad — direct comparison breaks on wrapped errors
|
||||
if err == sql.ErrNoRows {
|
||||
|
||||
// ✓ Good — traverses the entire error chain
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
```
|
||||
|
||||
### `errors.As / errors.AsType` — extract a typed error from the chain
|
||||
|
||||
```go
|
||||
// ✗ Bad — type assertion breaks on wrapped errors
|
||||
if ve, ok := err.(*ValidationError); ok {
|
||||
|
||||
// ✓ Good — traverses the entire error chain
|
||||
var ve *ValidationError
|
||||
if errors.As(err, &ve) {
|
||||
log.Printf("validation failed on field %s: %s", ve.Field, ve.Msg)
|
||||
}
|
||||
|
||||
// ✓ Better (Go 1.26+) — same behavior, simpler syntax
|
||||
if ve, ok := errors.AsType[*ValidationError](err); ok {
|
||||
log.Printf("validation failed on field %s: %s", ve.Field, ve.Msg)
|
||||
}
|
||||
```
|
||||
|
||||
## Combining Errors with `errors.Join`
|
||||
|
||||
`errors.Join` (Go 1.20+) combines multiple independent errors into one. The combined error works with `errors.Is` and `errors.As` — each inner error is inspectable.
|
||||
|
||||
### Use case: validating multiple fields
|
||||
|
||||
```go
|
||||
func validateUser(u User) error {
|
||||
var errs []error
|
||||
|
||||
if u.Name == "" {
|
||||
errs = append(errs, errors.New("name is required"))
|
||||
}
|
||||
if u.Email == "" {
|
||||
errs = append(errs, errors.New("email is required"))
|
||||
}
|
||||
|
||||
return errors.Join(errs...) // returns nil if errs is empty
|
||||
}
|
||||
```
|
||||
|
||||
### Use case: parallel operations with independent failures
|
||||
|
||||
```go
|
||||
func closeAll(closers ...io.Closer) error {
|
||||
var errs []error
|
||||
for _, c := range closers {
|
||||
if err := c.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
```
|
||||
|
||||
### `errors.Is` works through joined errors
|
||||
|
||||
```go
|
||||
err := errors.Join(ErrNotFound, ErrUnauthorized)
|
||||
|
||||
errors.Is(err, ErrNotFound) // true
|
||||
errors.Is(err, ErrUnauthorized) // true
|
||||
```
|
||||
Reference in New Issue
Block a user