fetch_ml/internal/api/responses/errors.go
Jeremie Fraeys 23e5f3d1dc
refactor(api): internal refactoring for TUI and worker modules
- Refactor internal/worker and internal/queue packages
- Update cmd/tui for monitoring interface
- Update test configurations
2026-02-20 15:51:23 -05:00

173 lines
5 KiB
Go

// Package responses provides structured API response types with security-conscious error handling.
package responses
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/jfraeys/fetch_ml/internal/logging"
)
// ErrorResponse provides a sanitized error response to clients.
// It includes a trace ID for support lookup while preventing information leakage.
type ErrorResponse struct {
Error string `json:"error"` // Sanitized error message for clients
Code string `json:"code"` // Machine-readable error code
TraceID string `json:"trace_id"` // For support lookup (internal correlation)
}
// Error codes for machine-readable error identification
const (
ErrCodeBadRequest = "BAD_REQUEST"
ErrCodeUnauthorized = "UNAUTHORIZED"
ErrCodeForbidden = "FORBIDDEN"
ErrCodeNotFound = "NOT_FOUND"
ErrCodeConflict = "CONFLICT"
ErrCodeRateLimited = "RATE_LIMITED"
ErrCodeInternal = "INTERNAL_ERROR"
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
ErrCodeValidation = "VALIDATION_ERROR"
)
// HTTP status to error code mapping
var statusToCode = map[int]string{
http.StatusBadRequest: ErrCodeBadRequest,
http.StatusUnauthorized: ErrCodeUnauthorized,
http.StatusForbidden: ErrCodeForbidden,
http.StatusNotFound: ErrCodeNotFound,
http.StatusConflict: ErrCodeConflict,
http.StatusTooManyRequests: ErrCodeRateLimited,
http.StatusInternalServerError: ErrCodeInternal,
http.StatusServiceUnavailable: ErrCodeServiceUnavailable,
422: ErrCodeValidation, // Unprocessable Entity
}
// Patterns to sanitize from error messages (security: prevent information leakage)
var (
// Remove file paths
pathPattern = regexp.MustCompile(`/[^\s]*`)
// Remove sensitive keywords
sensitiveKeywords = []string{"password", "secret", "token", "key", "credential", "auth"}
)
// WriteError writes a sanitized error response to the client.
// It extracts the trace ID from the context, logs the full error internally,
// and returns a sanitized message to the client.
func WriteError(w http.ResponseWriter, r *http.Request, status int, err error, logger *logging.Logger) {
traceID := logging.TraceIDFromContext(r.Context())
if traceID == "" {
traceID = generateTraceID()
}
// Log the full error internally with all details
if logger != nil {
logger.Error("request failed",
"trace_id", traceID,
"method", r.Method,
"path", r.URL.Path,
"status", status,
"error", err.Error(),
"client_ip", getClientIP(r),
)
}
// Build sanitized response
resp := ErrorResponse{
Error: sanitizeError(err.Error()),
Code: errorCodeFromStatus(status),
TraceID: traceID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(resp)
}
// WriteErrorMessage writes a sanitized error response with a custom message.
func WriteErrorMessage(w http.ResponseWriter, r *http.Request, status int, message string, logger *logging.Logger) {
traceID := logging.TraceIDFromContext(r.Context())
if traceID == "" {
traceID = generateTraceID()
}
if logger != nil {
logger.Error("request failed",
"trace_id", traceID,
"method", r.Method,
"path", r.URL.Path,
"status", status,
"error", message,
)
}
resp := ErrorResponse{
Error: sanitizeError(message),
Code: errorCodeFromStatus(status),
TraceID: traceID,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(resp)
}
// sanitizeError removes potentially sensitive information from error messages.
// It prevents information leakage to clients while preserving useful context.
func sanitizeError(msg string) string {
if msg == "" {
return "An error occurred"
}
// Remove file paths
msg = pathPattern.ReplaceAllString(msg, "[path]")
// Remove sensitive keywords and their values
lowerMsg := strings.ToLower(msg)
for _, keyword := range sensitiveKeywords {
if strings.Contains(lowerMsg, keyword) {
return "An error occurred"
}
}
// Remove internal error details
msg = strings.ReplaceAll(msg, "internal error", "an error occurred")
msg = strings.ReplaceAll(msg, "Internal Error", "an error occurred")
// Truncate if too long
if len(msg) > 200 {
msg = msg[:200] + "..."
}
return msg
}
// errorCodeFromStatus returns the appropriate error code for an HTTP status.
func errorCodeFromStatus(status int) string {
if code, ok := statusToCode[status]; ok {
return code
}
return ErrCodeInternal
}
// getClientIP extracts the client IP from the request.
func getClientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return r.RemoteAddr
}
// generateTraceID generates a new trace ID when one isn't in context.
func generateTraceID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}