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