package api import ( "errors" "fmt" "net/http" ) // HTTPError is a typed error that maps to an HTTP response. // It implements the error interface and provides sentinel error matching via errors.Is(). type HTTPError struct { Status int // HTTP status code Code string // Machine-readable error code Message string // Human-readable message Details any // Optional additional details cause error // Underlying error for wrapping } // Error implements the error interface. func (e *HTTPError) Error() string { if e.cause != nil { return fmt.Sprintf("%s: %v", e.Message, e.cause) } return e.Message } // Unwrap returns the underlying error for errors.Unwrap(). func (e *HTTPError) Unwrap() error { return e.cause } // Is implements errors.Is() matching for sentinel errors. // Matches if both errors are HTTPErrors with the same status code. func (e *HTTPError) Is(target error) bool { var t *HTTPError if errors.As(target, &t) { return e.Status == t.Status } return false } // WithCause returns a copy of the error with the given underlying cause. func (e *HTTPError) WithCause(cause error) *HTTPError { return &HTTPError{ Status: e.Status, Code: e.Code, Message: e.Message, Details: e.Details, cause: cause, } } // ----------------------------------------------------------------------------- // Sentinel Errors // ----------------------------------------------------------------------------- // Sentinel errors for errors.Is() matching. // Use these with errors.Is() to check error types: // // if errors.Is(err, api.ErrNotFound) { // // handle not found // } var ( ErrBadRequest = &HTTPError{Status: http.StatusBadRequest, Code: "BAD_REQUEST", Message: "bad request"} ErrUnauthorized = &HTTPError{Status: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: "unauthorized"} ErrForbidden = &HTTPError{Status: http.StatusForbidden, Code: "FORBIDDEN", Message: "forbidden"} ErrNotFound = &HTTPError{Status: http.StatusNotFound, Code: "NOT_FOUND", Message: "not found"} ErrConflict = &HTTPError{Status: http.StatusConflict, Code: "CONFLICT", Message: "conflict"} ErrUnprocessableEntity = &HTTPError{Status: http.StatusUnprocessableEntity, Code: "UNPROCESSABLE_ENTITY", Message: "unprocessable entity"} ErrTooManyRequests = &HTTPError{Status: http.StatusTooManyRequests, Code: "TOO_MANY_REQUESTS", Message: "too many requests"} ErrInternal = &HTTPError{Status: http.StatusInternalServerError, Code: "INTERNAL_ERROR", Message: "internal error"} ErrServiceUnavailable = &HTTPError{Status: http.StatusServiceUnavailable, Code: "SERVICE_UNAVAILABLE", Message: "service unavailable"} ErrValidation = &HTTPError{Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: "validation failed"} ) // ----------------------------------------------------------------------------- // Factory Functions // ----------------------------------------------------------------------------- // BadRequest creates a 400 Bad Request error. func BadRequest(msg string) error { return &HTTPError{ Status: http.StatusBadRequest, Code: "BAD_REQUEST", Message: msg, } } // BadRequestf creates a 400 Bad Request error with formatted message. func BadRequestf(format string, args ...any) error { return BadRequest(fmt.Sprintf(format, args...)) } // Unauthorized creates a 401 Unauthorized error. func Unauthorized(msg string) error { return &HTTPError{ Status: http.StatusUnauthorized, Code: "UNAUTHORIZED", Message: msg, } } // Unauthorizedf creates a 401 Unauthorized error with formatted message. func Unauthorizedf(format string, args ...any) error { return Unauthorized(fmt.Sprintf(format, args...)) } // Forbidden creates a 403 Forbidden error. func Forbidden(msg string) error { return &HTTPError{ Status: http.StatusForbidden, Code: "FORBIDDEN", Message: msg, } } // Forbiddenf creates a 403 Forbidden error with formatted message. func Forbiddenf(format string, args ...any) error { return Forbidden(fmt.Sprintf(format, args...)) } // NotFound creates a 404 Not Found error. func NotFound(msg string) error { return &HTTPError{ Status: http.StatusNotFound, Code: "NOT_FOUND", Message: msg, } } // NotFoundf creates a 404 Not Found error with formatted message. func NotFoundf(format string, args ...any) error { return NotFound(fmt.Sprintf(format, args...)) } // Conflict creates a 409 Conflict error. func Conflict(msg string) error { return &HTTPError{ Status: http.StatusConflict, Code: "CONFLICT", Message: msg, } } // Conflictf creates a 409 Conflict error with formatted message. func Conflictf(format string, args ...any) error { return Conflict(fmt.Sprintf(format, args...)) } // Internal creates a 500 Internal Server Error. func Internal(msg string) error { return &HTTPError{ Status: http.StatusInternalServerError, Code: "INTERNAL_ERROR", Message: msg, } } // Internalf creates a 500 Internal Server Error with formatted message. func Internalf(format string, args ...any) error { return Internal(fmt.Sprintf(format, args...)) } // Validation creates a 400 validation error. func Validation(msg string) error { return &HTTPError{ Status: http.StatusBadRequest, Code: "VALIDATION_ERROR", Message: msg, } } // Validationf creates a 400 validation error with formatted message. func Validationf(format string, args ...any) error { return Validation(fmt.Sprintf(format, args...)) } // UnprocessableEntity creates a 422 Unprocessable Entity error. // Use for validation failures when the request is syntactically correct // but semantically invalid. func UnprocessableEntity(msg string) error { return &HTTPError{ Status: http.StatusUnprocessableEntity, Code: "UNPROCESSABLE_ENTITY", Message: msg, } } // UnprocessableEntityf creates a 422 Unprocessable Entity error with formatted message. func UnprocessableEntityf(format string, args ...any) error { return UnprocessableEntity(fmt.Sprintf(format, args...)) } // TooManyRequests creates a 429 Too Many Requests error. func TooManyRequests(msg string) error { return &HTTPError{ Status: http.StatusTooManyRequests, Code: "TOO_MANY_REQUESTS", Message: msg, } } // TooManyRequestsf creates a 429 Too Many Requests error with formatted message. func TooManyRequestsf(format string, args ...any) error { return TooManyRequests(fmt.Sprintf(format, args...)) } // ServiceUnavailable creates a 503 Service Unavailable error. func ServiceUnavailable(msg string) error { return &HTTPError{ Status: http.StatusServiceUnavailable, Code: "SERVICE_UNAVAILABLE", Message: msg, } } // ServiceUnavailablef creates a 503 Service Unavailable error with formatted message. func ServiceUnavailablef(format string, args ...any) error { return ServiceUnavailable(fmt.Sprintf(format, args...)) } // ----------------------------------------------------------------------------- // Error Wrapping // ----------------------------------------------------------------------------- // WithDetails returns an error with additional details attached. // If the error is not an HTTPError, it wraps it as an internal error. func WithDetails(err error, details any) error { var httpErr *HTTPError if errors.As(err, &httpErr) { return &HTTPError{ Status: httpErr.Status, Code: httpErr.Code, Message: httpErr.Message, Details: details, cause: httpErr.cause, } } // Not an HTTPError, wrap as internal error with details return &HTTPError{ Status: http.StatusInternalServerError, Code: "INTERNAL_ERROR", Message: err.Error(), Details: details, } } // WithCode returns an error with a custom error code. // Useful for domain-specific error codes like "KEY_REVOKED". func WithCode(err error, code string) error { var httpErr *HTTPError if errors.As(err, &httpErr) { return &HTTPError{ Status: httpErr.Status, Code: code, Message: httpErr.Message, Details: httpErr.Details, cause: httpErr.cause, } } return &HTTPError{ Status: http.StatusInternalServerError, Code: code, Message: err.Error(), } } // WrapError wraps an underlying error with an HTTPError. // The underlying error is accessible via errors.Unwrap(). func WrapError(httpErr *HTTPError, cause error) error { return httpErr.WithCause(cause) } // ----------------------------------------------------------------------------- // Error Checking // ----------------------------------------------------------------------------- // IsHTTPError checks if an error is an HTTPError. func IsHTTPError(err error) bool { var httpErr *HTTPError return errors.As(err, &httpErr) } // AsHTTPError extracts an HTTPError from an error chain. // Returns nil if the error is not an HTTPError. func AsHTTPError(err error) *HTTPError { var httpErr *HTTPError if errors.As(err, &httpErr) { return httpErr } return nil } // StatusCode returns the HTTP status code for an error. // Returns 200 for nil errors (success case). // Returns 500 for non-HTTPErrors. func StatusCode(err error) int { if err == nil { return http.StatusOK } if httpErr := AsHTTPError(err); httpErr != nil { return httpErr.Status } return http.StatusInternalServerError }