559 lines
13 KiB
Markdown
559 lines
13 KiB
Markdown
|
|
# Error Wrapping Pattern in Go
|
||
|
|
|
||
|
|
## Pattern Overview
|
||
|
|
|
||
|
|
Error Wrapping adds context to errors as they propagate up the call stack, creating a chain of errors that preserves both the original error and contextual information. Go 1.13+ provides `fmt.Errorf` with `%w` verb and `errors.Unwrap` for this pattern.
|
||
|
|
|
||
|
|
## Pattern Structure
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Wrap error with context
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("operation failed: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Unwrap to get original error
|
||
|
|
originalErr := errors.Unwrap(wrappedErr)
|
||
|
|
|
||
|
|
// Check if error chain contains specific error
|
||
|
|
if errors.Is(err, ErrNotFound) {
|
||
|
|
// Handle not found
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract error of specific type from chain
|
||
|
|
var domainErr *DomainError
|
||
|
|
if errors.As(err, &domainErr) {
|
||
|
|
// Use domain error
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Real Implementation from Project
|
||
|
|
|
||
|
|
### Basic Error Wrapping
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/models/cv/cv.go
|
||
|
|
|
||
|
|
func LoadCV(lang string) (*CV, error) {
|
||
|
|
// Build file path
|
||
|
|
filePath := fmt.Sprintf("data/cv-%s.json", lang)
|
||
|
|
|
||
|
|
// Read file
|
||
|
|
data, err := os.ReadFile(filePath)
|
||
|
|
if err != nil {
|
||
|
|
// Wrap with context
|
||
|
|
return nil, fmt.Errorf("failed to read CV file: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parse JSON
|
||
|
|
var cv CV
|
||
|
|
err = json.Unmarshal(data, &cv)
|
||
|
|
if err != nil {
|
||
|
|
// Wrap with more context
|
||
|
|
return nil, fmt.Errorf("failed to parse CV JSON: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate
|
||
|
|
if err := cv.Validate(); err != nil {
|
||
|
|
// Wrap validation error
|
||
|
|
return nil, fmt.Errorf("CV validation failed: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &cv, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Domain Error Type
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/errors.go
|
||
|
|
|
||
|
|
// DomainError represents application-level errors
|
||
|
|
type DomainError struct {
|
||
|
|
Code ErrorCode // Machine-readable error code
|
||
|
|
Message string // Human-readable message
|
||
|
|
Err error // Underlying error
|
||
|
|
StatusCode int // HTTP status code
|
||
|
|
Field string // Field that caused error
|
||
|
|
}
|
||
|
|
|
||
|
|
// Error implements error interface
|
||
|
|
func (e *DomainError) Error() string {
|
||
|
|
if e.Err != nil {
|
||
|
|
return fmt.Sprintf("%s: %v", e.Message, e.Err)
|
||
|
|
}
|
||
|
|
return e.Message
|
||
|
|
}
|
||
|
|
|
||
|
|
// Unwrap returns the underlying error
|
||
|
|
func (e *DomainError) Unwrap() error {
|
||
|
|
return e.Err
|
||
|
|
}
|
||
|
|
|
||
|
|
// WithErr adds underlying error
|
||
|
|
func (e *DomainError) WithErr(err error) *DomainError {
|
||
|
|
e.Err = err
|
||
|
|
return e
|
||
|
|
}
|
||
|
|
|
||
|
|
// WithField adds field information
|
||
|
|
func (e *DomainError) WithField(field string) *DomainError {
|
||
|
|
e.Field = field
|
||
|
|
return e
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error Constructors
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/errors.go
|
||
|
|
|
||
|
|
// InvalidLanguageError creates a language validation error
|
||
|
|
func InvalidLanguageError(lang string) *DomainError {
|
||
|
|
return NewDomainError(
|
||
|
|
ErrCodeInvalidLanguage,
|
||
|
|
fmt.Sprintf("Unsupported language: %s (use 'en' or 'es')", lang),
|
||
|
|
http.StatusBadRequest,
|
||
|
|
).WithField("lang")
|
||
|
|
}
|
||
|
|
|
||
|
|
// DataNotFoundError creates a data not found error
|
||
|
|
func DataNotFoundError(dataType, lang string) *DomainError {
|
||
|
|
return NewDomainError(
|
||
|
|
ErrCodeDataNotFound,
|
||
|
|
fmt.Sprintf("%s data not found for language: %s", dataType, lang),
|
||
|
|
http.StatusInternalServerError,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// PDFGenerationError creates a PDF generation error
|
||
|
|
func PDFGenerationError(err error) *DomainError {
|
||
|
|
return NewDomainError(
|
||
|
|
ErrCodePDFGeneration,
|
||
|
|
"Failed to generate PDF. Please try again.",
|
||
|
|
http.StatusInternalServerError,
|
||
|
|
).WithErr(err)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error Handling Chain
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/cv_pages.go
|
||
|
|
|
||
|
|
func (h *CVHandler) Home(w http.ResponseWriter, r *http.Request) {
|
||
|
|
// Validate language
|
||
|
|
if err := validateLanguage(lang); err != nil {
|
||
|
|
// err is already a DomainError
|
||
|
|
h.HandleError(w, r, err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load CV data
|
||
|
|
cv, err := cvmodel.LoadCV(lang)
|
||
|
|
if err != nil {
|
||
|
|
// Wrap in DomainError with context
|
||
|
|
domErr := DataNotFoundError("CV", lang).WithErr(err)
|
||
|
|
h.HandleError(w, r, domErr)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Render template
|
||
|
|
if err := h.tmpl.Render(w, "index.html", data); err != nil {
|
||
|
|
// Wrap template error
|
||
|
|
domErr := TemplateError(err)
|
||
|
|
h.HandleError(w, r, domErr)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Centralized Error Handler
|
||
|
|
|
||
|
|
```go
|
||
|
|
// internal/handlers/errors.go
|
||
|
|
|
||
|
|
// HandleError processes errors and sends appropriate HTTP response
|
||
|
|
func (h *CVHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) {
|
||
|
|
// Try to cast to DomainError
|
||
|
|
var domErr *DomainError
|
||
|
|
if errors.As(err, &domErr) {
|
||
|
|
// Handle domain error
|
||
|
|
h.handleDomainError(w, r, domErr)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for specific errors
|
||
|
|
if errors.Is(err, context.Canceled) {
|
||
|
|
// Client disconnected
|
||
|
|
log.Printf("Request canceled: %v", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
||
|
|
// Timeout
|
||
|
|
domErr := NewDomainError(
|
||
|
|
ErrCodeTimeout,
|
||
|
|
"Request timed out",
|
||
|
|
http.StatusGatewayTimeout,
|
||
|
|
)
|
||
|
|
h.handleDomainError(w, r, domErr)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generic error
|
||
|
|
log.Printf("Unhandled error: %v", err)
|
||
|
|
domErr := NewDomainError(
|
||
|
|
ErrCodeInternalError,
|
||
|
|
"An unexpected error occurred",
|
||
|
|
http.StatusInternalServerError,
|
||
|
|
)
|
||
|
|
h.handleDomainError(w, r, domErr)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (h *CVHandler) handleDomainError(w http.ResponseWriter, r *http.Request, domErr *DomainError) {
|
||
|
|
// Log error with code
|
||
|
|
log.Printf("[ERROR] %s: %s", domErr.Code, domErr.Message)
|
||
|
|
if domErr.Err != nil {
|
||
|
|
log.Printf("[ERROR] Underlying: %v", domErr.Err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build error response
|
||
|
|
response := NewErrorResponse(
|
||
|
|
string(domErr.Code),
|
||
|
|
domErr.Message,
|
||
|
|
)
|
||
|
|
if domErr.Field != "" {
|
||
|
|
response.Error.Field = domErr.Field
|
||
|
|
}
|
||
|
|
|
||
|
|
// Send JSON response
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
w.WriteHeader(domErr.StatusCode)
|
||
|
|
json.NewEncoder(w).Encode(response)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Error Chain Example
|
||
|
|
|
||
|
|
### Full Error Propagation
|
||
|
|
|
||
|
|
```
|
||
|
|
1. File system error (os.ReadFile)
|
||
|
|
↓
|
||
|
|
2. Wrapped by model (LoadCV)
|
||
|
|
"failed to read CV file: open data/cv-xx.json: no such file"
|
||
|
|
↓
|
||
|
|
3. Wrapped by handler (Home)
|
||
|
|
DataNotFoundError("CV", "xx").WithErr(err)
|
||
|
|
↓
|
||
|
|
4. Handled by error handler
|
||
|
|
{
|
||
|
|
"success": false,
|
||
|
|
"error": {
|
||
|
|
"code": "DATA_NOT_FOUND",
|
||
|
|
"message": "CV data not found for language: xx"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error Chain in Code
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Layer 1: File system
|
||
|
|
_, err := os.ReadFile("data/cv-xx.json")
|
||
|
|
// err = &fs.PathError{Op:"open", Path:"data/cv-xx.json", Err:syscall.ENOENT}
|
||
|
|
|
||
|
|
// Layer 2: Model
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read CV file: %w", err)
|
||
|
|
}
|
||
|
|
// err = "failed to read CV file: open data/cv-xx.json: no such file or directory"
|
||
|
|
|
||
|
|
// Layer 3: Handler
|
||
|
|
if err != nil {
|
||
|
|
domErr := DataNotFoundError("CV", lang).WithErr(err)
|
||
|
|
}
|
||
|
|
// domErr = &DomainError{
|
||
|
|
// Code: "DATA_NOT_FOUND",
|
||
|
|
// Message: "CV data not found for language: xx",
|
||
|
|
// Err: [wrapped error from model],
|
||
|
|
// StatusCode: 500,
|
||
|
|
// }
|
||
|
|
|
||
|
|
// Layer 4: Error handler
|
||
|
|
h.HandleError(w, r, domErr)
|
||
|
|
// Logs full chain, sends user-friendly JSON
|
||
|
|
```
|
||
|
|
|
||
|
|
## Using errors.Is and errors.As
|
||
|
|
|
||
|
|
### errors.Is - Check Error Type
|
||
|
|
|
||
|
|
```go
|
||
|
|
func handleError(err error) {
|
||
|
|
// Check if error is or wraps specific error
|
||
|
|
if errors.Is(err, os.ErrNotExist) {
|
||
|
|
fmt.Println("File not found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if errors.Is(err, context.Canceled) {
|
||
|
|
fmt.Println("Request canceled")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if errors.Is(err, sql.ErrNoRows) {
|
||
|
|
fmt.Println("No data found")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Println("Unknown error:", err)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### errors.As - Extract Error Type
|
||
|
|
|
||
|
|
```go
|
||
|
|
func handleError(err error) {
|
||
|
|
// Extract DomainError from chain
|
||
|
|
var domErr *DomainError
|
||
|
|
if errors.As(err, &domErr) {
|
||
|
|
fmt.Printf("Domain error: code=%s, status=%d\n",
|
||
|
|
domErr.Code, domErr.StatusCode)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract PathError from chain
|
||
|
|
var pathErr *fs.PathError
|
||
|
|
if errors.As(err, &pathErr) {
|
||
|
|
fmt.Printf("Path error: op=%s, path=%s\n",
|
||
|
|
pathErr.Op, pathErr.Path)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Println("Unknown error:", err)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Custom Error Types
|
||
|
|
|
||
|
|
### Sentinel Errors
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Define sentinel errors for comparison
|
||
|
|
var (
|
||
|
|
ErrNotFound = errors.New("not found")
|
||
|
|
ErrUnauthorized = errors.New("unauthorized")
|
||
|
|
ErrInvalidInput = errors.New("invalid input")
|
||
|
|
)
|
||
|
|
|
||
|
|
// Use in code
|
||
|
|
if user == nil {
|
||
|
|
return ErrNotFound
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check with errors.Is
|
||
|
|
if errors.Is(err, ErrNotFound) {
|
||
|
|
// Handle not found
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Error with Context
|
||
|
|
|
||
|
|
```go
|
||
|
|
// ValidationError includes field information
|
||
|
|
type ValidationError struct {
|
||
|
|
Field string
|
||
|
|
Message string
|
||
|
|
Value interface{}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (e *ValidationError) Error() string {
|
||
|
|
return fmt.Sprintf("validation failed for %s: %s (value: %v)",
|
||
|
|
e.Field, e.Message, e.Value)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Usage
|
||
|
|
if len(name) == 0 {
|
||
|
|
return &ValidationError{
|
||
|
|
Field: "name",
|
||
|
|
Message: "name is required",
|
||
|
|
Value: name,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Error Wrapping Best Practices
|
||
|
|
|
||
|
|
### ✅ DO
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Add context when wrapping
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to process user %d: %w", userID, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Use %w for wrapping (preserves error chain)
|
||
|
|
return fmt.Errorf("database query failed: %w", err)
|
||
|
|
|
||
|
|
// Wrap at each layer
|
||
|
|
func LoadUser(id int) (*User, error) {
|
||
|
|
data, err := readFile(fmt.Sprintf("users/%d.json", id))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("load user %d: %w", id, err)
|
||
|
|
}
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create custom errors with context
|
||
|
|
func InvalidEmailError(email string) error {
|
||
|
|
return fmt.Errorf("invalid email format: %s", email)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check errors with errors.Is
|
||
|
|
if errors.Is(err, os.ErrNotExist) {
|
||
|
|
// Handle file not found
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract errors with errors.As
|
||
|
|
var domErr *DomainError
|
||
|
|
if errors.As(err, &domErr) {
|
||
|
|
// Use domain error
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### ❌ DON'T
|
||
|
|
|
||
|
|
```go
|
||
|
|
// DON'T use %v (loses error chain)
|
||
|
|
return fmt.Errorf("failed: %v", err) // Wrong!
|
||
|
|
return fmt.Errorf("failed: %w", err) // Correct
|
||
|
|
|
||
|
|
// DON'T ignore errors
|
||
|
|
data, _ := readFile(path) // Wrong!
|
||
|
|
|
||
|
|
// DON'T return generic errors
|
||
|
|
if invalid {
|
||
|
|
return errors.New("error") // Too generic!
|
||
|
|
}
|
||
|
|
|
||
|
|
// DON'T compare errors with ==
|
||
|
|
if err == someError { // Wrong! Use errors.Is
|
||
|
|
// ...
|
||
|
|
}
|
||
|
|
|
||
|
|
// DON'T type assert directly
|
||
|
|
domErr := err.(*DomainError) // Wrong! Use errors.As
|
||
|
|
```
|
||
|
|
|
||
|
|
## Error Logging
|
||
|
|
|
||
|
|
### Structured Logging
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (h *Handler) processRequest(r *http.Request) error {
|
||
|
|
err := h.doWork()
|
||
|
|
if err != nil {
|
||
|
|
// Log with context
|
||
|
|
log.Printf("[ERROR] Request processing failed: %v", err)
|
||
|
|
|
||
|
|
// Log underlying errors
|
||
|
|
var domErr *DomainError
|
||
|
|
if errors.As(err, &domErr) {
|
||
|
|
log.Printf("[ERROR] Code: %s, Status: %d",
|
||
|
|
domErr.Code, domErr.StatusCode)
|
||
|
|
if domErr.Err != nil {
|
||
|
|
log.Printf("[ERROR] Underlying: %v", domErr.Err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Stack Traces
|
||
|
|
|
||
|
|
```go
|
||
|
|
// For panics (recovered in middleware)
|
||
|
|
func Recovery(next http.Handler) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
defer func() {
|
||
|
|
if err := recover(); err != nil {
|
||
|
|
// Log with stack trace
|
||
|
|
log.Printf("PANIC: %v\n%s", err, debug.Stack())
|
||
|
|
|
||
|
|
http.Error(w, "Internal Server Error",
|
||
|
|
http.StatusInternalServerError)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
next.ServeHTTP(w, r)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Testing Error Handling
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestLoadCV_FileNotFound(t *testing.T) {
|
||
|
|
// Test error wrapping
|
||
|
|
_, err := LoadCV("nonexistent")
|
||
|
|
|
||
|
|
// Check error occurred
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected error, got nil")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check error message contains context
|
||
|
|
if !strings.Contains(err.Error(), "failed to read CV file") {
|
||
|
|
t.Errorf("error missing context: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check error chain contains specific error
|
||
|
|
if !errors.Is(err, os.ErrNotExist) {
|
||
|
|
t.Error("error should wrap os.ErrNotExist")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestHandleError_DomainError(t *testing.T) {
|
||
|
|
// Create domain error
|
||
|
|
domErr := InvalidLanguageError("xx")
|
||
|
|
|
||
|
|
// Test handling
|
||
|
|
w := httptest.NewRecorder()
|
||
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
|
|
|
||
|
|
handler.HandleError(w, req, domErr)
|
||
|
|
|
||
|
|
// Verify response
|
||
|
|
if w.Code != http.StatusBadRequest {
|
||
|
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||
|
|
}
|
||
|
|
|
||
|
|
var response APIResponse
|
||
|
|
json.NewDecoder(w.Body).Decode(&response)
|
||
|
|
|
||
|
|
if response.Success {
|
||
|
|
t.Error("expected success=false")
|
||
|
|
}
|
||
|
|
|
||
|
|
if response.Error.Code != "INVALID_LANGUAGE" {
|
||
|
|
t.Errorf("code = %s, want INVALID_LANGUAGE", response.Error.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Related Patterns
|
||
|
|
|
||
|
|
- **Handler Pattern**: Uses error wrapping for error handling
|
||
|
|
- **Context Pattern**: context.Canceled and context.DeadlineExceeded errors
|
||
|
|
- **Factory Pattern**: Error constructors create wrapped errors
|
||
|
|
|
||
|
|
## Further Reading
|
||
|
|
|
||
|
|
- [Go Error Handling](https://go.dev/blog/error-handling-and-go)
|
||
|
|
- [Working with Errors](https://go.dev/blog/go1.13-errors)
|
||
|
|
- [Error Handling Best Practices](https://dave.cheney.net/2016/04/27/dont-just-check-errors-handle-them-gracefully)
|