Instruction file imported from cdalsoniii/brightpath-coder (
.cursor/rules/124-go-error-patterns.mdc). Copyright stays with the author.
Go Error Patterns
Error Wrapping
Always wrap errors with context using fmt.Errorf and %w:
// GOOD: Adds context, preserves chain
if err := s.db.Create(&account).Error; err != nil {
return nil, fmt.Errorf("creating account: %w", err)
}
// BAD: Raw error, no context
if err := s.db.Create(&account).Error; err != nil {
return nil, err
}
// BAD: String formatting loses error chain
if err := s.db.Create(&account).Error; err != nil {
return nil, fmt.Errorf("failed: %s", err) // %s not %w
}
Sentinel Errors
Define sentinel errors for domain-specific conditions:
var (
ErrAccountNotFound = errors.New("account not found")
ErrInsufficientFunds = errors.New("insufficient funds")
ErrAccountClosed = errors.New("account is closed")
ErrRegDLimitExceeded = errors.New("savings account monthly withdrawal limit exceeded")
)
Check sentinel errors with errors.Is():
// GOOD
if errors.Is(err, ErrInsufficientFunds) {
c.JSON(http.StatusUnprocessableEntity, ...)
}
// BAD: String comparison
if err.Error() == "insufficient funds" { ... }
// BAD: Direct comparison (breaks with wrapping)
if err == ErrInsufficientFunds { ... }
Never Return (nil, nil)
// BAD: Caller can't distinguish "not found" from "success with nil"
func GetUser(id string) (*User, error) {
user := findUser(id)
return user, nil // nil user AND nil error -- ambiguous!
}
// GOOD: Return explicit error for not-found
func GetUser(id string) (*User, error) {
user := findUser(id)
if user == nil {
return nil, ErrUserNotFound
}
return user, nil
}
Error-to-HTTP Mapping
Every service-level error MUST map to a specific HTTP status code:
| Error | HTTP Status | Error Code |
|---|---|---|
ErrAccountNotFound |
404 | ACCOUNT_NOT_FOUND |
ErrInsufficientFunds |
422 | INSUFFICIENT_FUNDS |
ErrAccountClosed |
409 | ACCOUNT_CLOSED |
ErrAccountFrozen |
409 | ACCOUNT_FROZEN |
ErrInvalidAmount |
400 | INVALID_AMOUNT |
ErrDuplicateTransaction |
200 (return original) | DUPLICATE_TRANSACTION |
ErrRegDLimitExceeded |
429 | REG_D_LIMIT_EXCEEDED |
ErrAccountNotOwned |
403 | FORBIDDEN |
ErrInvalidCredentials |
401 | UNAUTHORIZED |
| Unexpected error | 500 | INTERNAL_ERROR |
Handler Error Pattern
func (h *Handler) CreateAccount(c *gin.Context) {
account, err := h.service.CreateAccount(...)
if err != nil {
switch {
case errors.Is(err, service.ErrInvalidAccountType):
response.BadRequest(c, "INVALID_ACCOUNT_TYPE", err.Error())
case errors.Is(err, service.ErrUnsupportedCurrency):
response.BadRequest(c, "UNSUPPORTED_CURRENCY", err.Error())
default:
response.InternalError(c, err)
}
return
}
response.Created(c, account)
}
Error Logging Rules
// Log at the boundary (handler), not deep in the stack
// GOOD: Handler logs the error
logger.Error().Err(err).Str("account_id", id).Msg("failed to create account")
// BAD: Service logs AND returns -- double logging
func (s *Service) Create() error {
if err := s.repo.Save(); err != nil {
log.Error().Err(err).Msg("save failed") // Don't log here
return fmt.Errorf("saving: %w", err) // Just wrap and return
}
}
Never Expose Internal Errors
// BAD: Leaks internal details
c.JSON(500, gin.H{"error": err.Error()}) // "UNIQUE constraint failed: accounts.account_number"
// GOOD: Generic message, log internally
logger.Error().Err(err).Msg("account creation failed")
c.JSON(500, gin.H{"error": "internal server error", "code": "INTERNAL_ERROR"})