Files
cv-site/doc/_go-learning/patterns/04-error-wrapping.md
T
juanatsap d95c62bad4 refactor: remove outdated server design documentation
Remove 557-line server-design.md from _go-learning/architecture - content is now covered in updated architecture documentation with real implementation examples and test coverage.
2025-12-02 20:25:05 +00:00

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)