# Error Handling Standardization Plan ## Current State Analysis **Issues Identified**: - Inconsistent `gin.H{"error": ...}` scattered across handlers - No clear separation between internal and user-facing errors - HTTP status codes set manually in each handler - Error messages exposed directly to clients - No structured error logging with context ## Target Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ HTTP Transport Layer │ │ (Gin Handlers - no error logic) │ └──────────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Application Use Cases │ │ (Return domain errors, no HTTP knowledge) │ └──────────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Domain Services │ │ (Business logic, domain errors) │ └──────────────────────┬──────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Infrastructure (DB, External APIs) │ │ (Wrap external errors to domain) │ └─────────────────────────────────────────────────────────────┘ ``` ## Phase 1: Domain Error Types (Week 1) ### 1.1 Core Error Types **File**: `internal/domain/errors/errors.go` ```go package errors import "fmt" type ErrorCode string const ( // Authentication errors ErrCodeInvalidCredentials ErrorCode = "AUTH_INVALID_CREDENTIALS" ErrCodeTokenExpired ErrorCode = "AUTH_TOKEN_EXPIRED" ErrCodeTokenInvalid ErrorCode = "AUTH_TOKEN_INVALID" ErrCodeUnauthorized ErrorCode = "AUTH_UNAUTHORIZED" // Configuration errors ErrCodeConfigNotFound ErrorCode = "CONFIG_NOT_FOUND" ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID" ErrCodeConfigValidation ErrorCode = "CONFIG_VALIDATION_FAILED" // Provider errors ErrCodeProviderNotFound ErrorCode = "PROVIDER_NOT_FOUND" ErrCodeProviderUnavailable ErrorCode = "PROVIDER_UNAVAILABLE" ErrCodeProviderRateLimited ErrorCode = "PROVIDER_RATE_LIMITED" // Request errors ErrCodeInvalidRequest ErrorCode = "REQUEST_INVALID" ErrCodeMissingField ErrorCode = "REQUEST_MISSING_FIELD" ErrCodeInvalidFormat ErrorCode = "REQUEST_INVALID_FORMAT" // Storage errors ErrCodeStorageFailure ErrorCode = "STORAGE_FAILURE" ErrCodeNotFound ErrorCode = "RESOURCE_NOT_FOUND" ErrCodeConflict ErrorCode = "RESOURCE_CONFLICT" // Internal errors ErrCodeInternal ErrorCode = "INTERNAL_ERROR" ErrCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED" ) // DomainError is the base error type for the application type DomainError struct { Code ErrorCode Message string Details map[string]interface{} Cause error HTTPStatus int } func (e *DomainError) Error() string { if e.Cause != nil { return fmt.Sprintf("%s: %s (caused by: %v)", e.Code, e.Message, e.Cause) } return fmt.Sprintf("%s: %s", e.Code, e.Message) } func (e *DomainError) Unwrap() error { return e.Cause } // Error constructors for common cases func NewInvalidCredentials(msg string) *DomainError { return &DomainError{ Code: ErrCodeInvalidCredentials, Message: msg, HTTPStatus: 401, } } func NewConfigNotFound(resource string) *DomainError { return &DomainError{ Code: ErrCodeConfigNotFound, Message: fmt.Sprintf("configuration not found: %s", resource), HTTPStatus: 404, Details: map[string]interface{}{"resource": resource}, } } func NewProviderUnavailable(provider string, cause error) *DomainError { return &DomainError{ Code: ErrCodeProviderUnavailable, Message: fmt.Sprintf("provider %s is unavailable", provider), HTTPStatus: 503, Cause: cause, Details: map[string]interface{}{"provider": provider}, } } func NewValidationError(field string, msg string) *DomainError { return &DomainError{ Code: ErrCodeConfigValidation, Message: fmt.Sprintf("validation failed for %s: %s", field, msg), HTTPStatus: 400, Details: map[string]interface{}{"field": field}, } } func NewInternalError(cause error) *DomainError { return &DomainError{ Code: ErrCodeInternal, Message: "an internal error occurred", HTTPStatus: 500, Cause: cause, } } ``` ### 1.2 Error Response DTO **File**: `internal/application/dto/error.go` ```go package dto // ErrorResponse is the standardized error response for clients type ErrorResponse struct { Success bool `json:"success" example:"false"` Error ErrorInfo `json:"error"` RequestID string `json:"request_id,omitempty"` } type ErrorInfo struct { Code string `json:"code" example:"AUTH_INVALID_CREDENTIALS"` Message string `json:"message" example:"Invalid credentials provided"` Details map[string]interface{} `json:"details,omitempty"` } // InternalErrorResponse is returned for 500 errors (no sensitive info) type InternalErrorResponse struct { Success bool `json:"success"` Error string `json:"error"` RequestID string `json:"request_id"` } ``` ## Phase 2: Error Middleware (Week 1) ### 2.1 Error Handler Middleware **File**: `internal/api/middleware/error_handler.go` ```go package middleware import ( "net/http" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" domainerrors "github.com/echyai/cliproxyapi/internal/domain/errors" "github.com/echyai/cliproxyapi/internal/application/dto" ) // ErrorHandler returns a middleware that handles domain errors func ErrorHandler(logger *logrus.Logger) gin.HandlerFunc { return func(c *gin.Context) { c.Next() // Check if there are any errors if len(c.Errors) == 0 { return } // Get the last error err := c.Errors.Last().Err requestID := c.GetString("request_id") // Handle domain errors if domainErr, ok := err.(*domainerrors.DomainError); ok { handleDomainError(c, domainErr, requestID, logger) return } // Handle wrapped domain errors var domainErr *domainerrors.DomainError if errors.As(err, &domainErr) { handleDomainError(c, domainErr, requestID, logger) return } // Unknown error - log full details but return generic message logger.WithError(err). WithField("request_id", requestID). WithField("path", c.Request.URL.Path). Error("Unhandled error") c.JSON(http.StatusInternalServerError, dto.InternalErrorResponse{ Success: false, Error: "An unexpected error occurred", RequestID: requestID, }) } } func handleDomainError(c *gin.Context, err *domainerrors.DomainError, requestID string, logger *logrus.Logger) { // Log with appropriate level based on status code entry := logger.WithError(err). WithField("request_id", requestID). WithField("error_code", err.Code). WithField("path", c.Request.URL.Path) if err.HTTPStatus >= 500 { entry.Error("Server error") } else if err.HTTPStatus >= 400 { entry.Warn("Client error") } // Return structured error response c.JSON(err.HTTPStatus, dto.ErrorResponse{ Success: false, Error: dto.ErrorInfo{ Code: string(err.Code), Message: err.Message, Details: err.Details, }, RequestID: requestID, }) } ``` ### 2.2 Request ID Middleware **File**: `internal/api/middleware/request_id.go` ```go package middleware import ( "github.com/gin-gonic/gin" "github.com/google/uuid" ) func RequestID() gin.HandlerFunc { return func(c *gin.Context) { requestID := c.GetHeader("X-Request-ID") if requestID == "" { requestID = uuid.New().String() } c.Set("request_id", requestID) c.Header("X-Request-ID", requestID) c.Next() } } ``` ## Phase 3: Handler Refactoring Example (Week 2) ### Before (Current) **File**: Example from auth handlers ```go func (h *Handler) GetAuth(c *gin.Context) { id := c.Param("id") auth, err := h.store.GetAuth(id) if err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } if auth == nil { c.JSON(404, gin.H{"error": "not found"}) return } c.JSON(200, auth) } ``` ### After (Refactored) ```go func (h *Handler) GetAuth(c *gin.Context) { id := c.Param("id") auth, err := h.usecase.GetAuth(c.Request.Context(), id) if err != nil { _ = c.Error(err) // Pass to error middleware return } c.JSON(200, dto.SuccessResponse{Data: auth}) } ``` ### Use Case Implementation ```go func (uc *AuthUseCase) GetAuth(ctx context.Context, id string) (*dto.AuthDTO, error) { if id == "" { return nil, domainerrors.NewValidationError("id", "cannot be empty") } auth, err := uc.repo.FindByID(ctx, id) if err != nil { return nil, domainerrors.NewInternalError(err) } if auth == nil { return nil, domainerrors.NewConfigNotFound(id) } return uc.mapper.ToDTO(auth), nil } ``` ## Phase 4: Migration Strategy ### Step-by-Step Migration 1. **Week 1**: Create error types and middleware (new code only) 2. **Week 2-3**: Migrate handlers one package at a time: - [ ] `internal/api/handlers/management/auth_files.go` - [ ] `internal/api/handlers/management/config_lists.go` - [ ] `internal/api/handlers/proxy/` - [ ] `internal/api/handlers/oauth/` 3. **Week 4**: Remove old error handling patterns 4. **Week 5**: Add error logging aggregation ### Compatibility Layer (Temporary) ```go // Deprecated: Use c.Error(err) instead func LegacyErrorResponse(c *gin.Context, status int, err error) { domainErr := &domainerrors.DomainError{ Code: domainerrors.ErrorCode(fmt.Sprintf("LEGACY_%d", status)), Message: err.Error(), HTTPStatus: status, } _ = c.Error(domainErr) } ``` ## Testing ### Unit Tests for Error Handling **File**: `internal/api/middleware/error_handler_test.go` ```go func TestErrorHandler_DomainError(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) // Simulate error c.Error(domainerrors.NewConfigNotFound("test-config")) // Run middleware middleware := ErrorHandler(logrus.New()) middleware(c) assert.Equal(t, 404, w.Code) var resp dto.ErrorResponse json.Unmarshal(w.Body.Bytes(), &resp) assert.Equal(t, "CONFIG_NOT_FOUND", resp.Error.Code) } ``` ## Success Metrics - [ ] All handlers use `c.Error()` instead of direct `c.JSON()` for errors - [ ] Zero `gin.H{"error": ...}` patterns in codebase - [ ] Consistent error response format across all APIs - [ ] Internal errors don't leak sensitive details - [ ] Request ID tracking for all error responses