| # 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 |
|
|