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
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
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
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
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
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)
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
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
- Week 1: Create error types and middleware (new code only)
- 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/
-
- Week 4: Remove old error handling patterns
- Week 5: Add error logging aggregation
Compatibility Layer (Temporary)
// 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
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 directc.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