API / plans /error-handling-refactor.md
sshinmen's picture
Update HF Spaces deployment with latest changes
2e6b65c
|
Raw
History Blame Contribute Delete
12.8 kB

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

  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)

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