Skip to content

Add Structured Error Handling Package #12

@vahiiiid

Description

@vahiiiid

🎯 Goal

Implement a structured error handling package that provides consistent, machine-readable error responses across the entire API.

📋 Description

Currently, error responses are handled ad-hoc in handlers with inconsistent formats. A dedicated error package will provide:

  • Consistent error response structure
  • Error codes for client-side handling
  • Better debugging with error details
  • Localization support (future)
  • Error wrapping and context

✅ Acceptance Criteria

1. Create Error Package Structure

internal/
  └── errors/
      ├── errors.go          # Core error types and constructors
      ├── codes.go          # Error code constants
      ├── middleware.go     # Error handling middleware
      └── errors_test.go    # Tests

2. Define Error Structure

  • Create standardized error response format:
type APIError struct {
    Code    string      `json:"code"`              // Machine-readable code (e.g., "VALIDATION_ERROR")
    Message string      `json:"message"`           // Human-readable message
    Details interface{} `json:"details,omitempty"` // Additional context (e.g., field errors)
    Status  int         `json:"-"`                 // HTTP status code (not exposed in JSON)
}

3. Define Error Codes

  • Create error code constants in codes.go:
const (
    // General errors
    CodeInternal        = "INTERNAL_ERROR"
    CodeNotFound        = "NOT_FOUND"
    CodeUnauthorized    = "UNAUTHORIZED"
    CodeForbidden       = "FORBIDDEN"
    CodeValidation      = "VALIDATION_ERROR"
    CodeConflict        = "CONFLICT"
    
    // Auth specific
    CodeInvalidCredentials = "INVALID_CREDENTIALS"
    CodeTokenExpired       = "TOKEN_EXPIRED"
    CodeTokenInvalid       = "TOKEN_INVALID"
    
    // User specific
    CodeUserNotFound      = "USER_NOT_FOUND"
    CodeUserAlreadyExists = "USER_ALREADY_EXISTS"
    CodeEmailAlreadyExists = "EMAIL_ALREADY_EXISTS"
)

4. Error Constructors

  • Implement helper functions for common errors:
// Constructor examples
func NotFound(message string) *APIError
func Unauthorized(message string) *APIError
func BadRequest(message string) *APIError
func Internal(err error) *APIError
func ValidationError(details interface{}) *APIError
func Conflict(message string) *APIError

// With details
func NotFoundWithDetails(message string, details interface{}) *APIError

5. Error Response Format

Standard JSON response for all errors:

{
  "code": "VALIDATION_ERROR",
  "message": "Invalid request data",
  "details": {
    "email": "Invalid email format",
    "password": "Password must be at least 6 characters"
  }
}

6. Error Handling Middleware

  • Create middleware in middleware.go that:
    • Catches panics and converts to 500 errors
    • Handles APIError types
    • Converts unknown errors to generic internal errors
    • Logs errors with appropriate levels
    • Returns consistent JSON responses

Example:

func ErrorHandler() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Next()
        
        if len(c.Errors) > 0 {
            err := c.Errors.Last()
            
            // Handle APIError
            if apiErr, ok := err.Err.(*APIError); ok {
                c.JSON(apiErr.Status, apiErr)
                return
            }
            
            // Handle unknown errors
            c.JSON(500, Internal(err.Err))
        }
    }
}

7. Integration with Handlers

  • Update all handlers to use new error types:

Before:

func (h *Handler) GetUser(c *gin.Context) {
    user, err := h.service.GetByID(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }
    // ...
}

After:

func (h *Handler) GetUser(c *gin.Context) {
    user, err := h.service.GetByID(id)
    if err != nil {
        _ = c.Error(errors.NotFound("User not found"))
        return
    }
    // ...
}

8. Validation Error Helpers

  • Create helper for Gin validation errors:
func FromGinValidation(err error) *APIError {
    var details = make(map[string]string)
    
    for _, fieldErr := range err.(validator.ValidationErrors) {
        details[fieldErr.Field()] = formatValidationError(fieldErr)
    }
    
    return ValidationError(details)
}

9. Update All Handlers

  • Refactor internal/user/handler.go:
    • Register handler
    • Login handler
    • GetUser handler
    • UpdateUser handler
    • DeleteUser handler
  • Update error responses to use new error package
  • Remove gin.H{"error": ...} patterns

10. Swagger Documentation

  • Update Swagger annotations to document error codes:
// @Failure 400 {object} errors.APIError "Validation error"
// @Failure 401 {object} errors.APIError "Unauthorized - invalid credentials"
// @Failure 404 {object} errors.APIError "User not found"
// @Failure 500 {object} errors.APIError "Internal server error"
  • Regenerate Swagger docs: make swag

11. Testing

  • Test error constructors
  • Test error middleware
  • Test error serialization to JSON
  • Test validation error conversion
  • Add integration tests with example requests
  • Test error logging

12. Documentation

  • Update README.md with error handling approach
  • Document all error codes in API documentation
  • Add examples of error responses
  • Update Postman collection with error examples

💡 Implementation Example

errors/errors.go

package errors

import "net/http"

type APIError struct {
    Code    string      `json:"code"`
    Message string      `json:"message"`
    Details interface{} `json:"details,omitempty"`
    Status  int         `json:"-"`
}

func (e *APIError) Error() string {
    return e.Message
}

// Constructors
func NotFound(message string) *APIError {
    return &APIError{
        Code:    CodeNotFound,
        Message: message,
        Status:  http.StatusNotFound,
    }
}

func BadRequest(message string) *APIError {
    return &APIError{
        Code:    CodeValidation,
        Message: message,
        Status:  http.StatusBadRequest,
    }
}

func Unauthorized(message string) *APIError {
    return &APIError{
        Code:    CodeUnauthorized,
        Message: message,
        Status:  http.StatusUnauthorized,
    }
}

func Internal(err error) *APIError {
    return &APIError{
        Code:    CodeInternal,
        Message: "Internal server error",
        Details: err.Error(),
        Status:  http.StatusInternalServerError,
    }
}

func ValidationError(details interface{}) *APIError {
    return &APIError{
        Code:    CodeValidation,
        Message: "Validation failed",
        Details: details,
        Status:  http.StatusBadRequest,
    }
}

Usage in Handlers

// Register
if err := c.ShouldBindJSON(&req); err != nil {
    _ = c.Error(errors.FromGinValidation(err))
    return
}

// User not found
if user == nil {
    _ = c.Error(errors.NotFound("User not found"))
    return
}

// Duplicate email
if isDuplicate {
    _ = c.Error(errors.Conflict("Email already exists"))
    return
}

// Invalid credentials
if !validPassword {
    _ = c.Error(errors.Unauthorized("Invalid email or password"))
    return
}

📊 Benefits Over Current Approach

Current With Error Package
gin.H{"error": "..."} errors.NotFound("...")
Inconsistent formats Standardized structure
No error codes Machine-readable codes
Hard to test Easy to test and mock
No error context Details field for context
Manual status codes Automatic status codes

📚 Resources

🎓 Difficulty Level

Intermediate - Requires refactoring existing handlers and understanding error propagation patterns.

🔗 Related Issues

This complements:


Note: This is a foundational improvement that will make the API more professional and easier to consume. Test thoroughly with make test and verify all endpoints return consistent error formats!

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions