slate-fixed-1770508646/pkg/httperror/error.go
jordan 69d72044b3
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-07 23:57:27 +00:00

332 lines
9.9 KiB
Go

// Package httperror provides typed HTTP errors with sentinel error matching.
//
// NOTE: This file mirrors github.com/orchard9/rdev/pkg/api/error.go
// When updating patterns here, consider updating the rdev source as well.
//
// HTTPError implements the error interface and provides errors.Is() matching
// for idiomatic Go error handling in HTTP handlers.
//
// Usage:
//
// func GetUser(w http.ResponseWriter, r *http.Request) error {
// user, err := svc.Get(ctx, id)
// if err != nil {
// if errors.Is(err, ErrUserNotFound) {
// return httperror.NotFoundf("user %s not found", id)
// }
// return err
// }
// httpresponse.OK(w, r, user)
// return nil
// }
//
// // In error handling middleware, check error types:
// if errors.Is(err, httperror.ErrNotFound) {
// // handle not found
// }
package httperror
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, httperror.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
}