332 lines
9.9 KiB
Go
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
|
|
}
|