Files

236 lines
7.7 KiB
Go

package apperror
import (
"errors"
"fmt"
"maps"
"net/http"
"time"
)
type Code string
// #nosec G101 -- these are client-visible error codes, not credentials
const (
CodeInternal Code = "internal_error"
CodeValidationFailed Code = "validation_failed"
CodeInvalidRequestBody Code = "invalid_request_body"
CodeRequestTimeout Code = "request_timeout"
CodeNotFound Code = "not_found"
CodeAlreadyInUse Code = "already_in_use"
CodeForbidden Code = "forbidden"
CodeInvalidToken Code = "invalid_token"
CodeRateLimited Code = "rate_limited"
CodeFileTooLarge Code = "file_too_large"
CodeInvalidImage Code = "invalid_image"
CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response"
CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed"
CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required"
CodeSyncedPasskeyNotAllowed Code = "synced_passkey_not_allowed"
CodeInvalidWebAuthnSession Code = "invalid_webauthn_session"
CodeUserNotFound Code = "user_not_found"
CodeUserDisabled Code = "user_disabled"
CodeAPIKeyNotFound Code = "api_key_not_found"
CodeInvalidAPIKey Code = "invalid_api_key"
CodeDeviceLoginExpired Code = "device_login_expired"
CodeReauthenticationRequired Code = "reauthentication_required"
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
CodeSetupNotAvailable Code = "setup_not_available"
CodeSetupAlreadyCompleted Code = "setup_already_completed"
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
CodeDeviceCodeInvalid Code = "device_code_invalid"
CodeOidcMissingAuthorization Code = "oidc_missing_authorization"
CodeOidcInvalidCallbackURL Code = "oidc_invalid_callback_url"
CodeFileTypeNotSupported Code = "file_type_not_supported"
CodeNotSignedIn Code = "not_signed_in"
CodeMissingSessionID Code = "missing_session_id"
CodeReservedClaim Code = "reserved_claim"
CodeDuplicateClaim Code = "duplicate_claim"
CodeLdapDisabled Code = "ldap_disabled"
CodeLdapUserUpdate Code = "ldap_user_update"
CodeLdapUserGroupUpdate Code = "ldap_user_group_update"
CodeOidcAccessDenied Code = "oidc_access_denied"
CodeOidcClientIDNotMatching Code = "oidc_client_id_not_matching"
CodeUIConfigDisabled Code = "ui_config_disabled"
CodeOneTimeAccessDisabled Code = "one_time_access_disabled"
CodeDeviceLoginDenied Code = "device_login_denied"
CodeNoAPIKeyProvided Code = "no_api_key_provided"
CodeAPIKeyNotExpired Code = "api_key_not_expired"
CodeInvalidAPIKeyExpiration Code = "invalid_api_key_expiration"
CodeAPIKeyAuthNotAllowed Code = "api_key_auth_not_allowed"
CodeOidcDeviceCodeExpired Code = "oidc_device_code_expired"
CodeOidcInvalidDeviceCode Code = "oidc_invalid_device_code"
CodeOpenSignupDisabled Code = "open_signup_disabled"
CodeClientIDAlreadyExists Code = "client_id_already_exists"
CodeUserEmailNotSet Code = "user_email_not_set"
CodeImageNotFound Code = "image_not_found"
CodeLogoDownloadFailed Code = "logo_download_failed"
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
CodeLogoTooLarge Code = "logo_too_large"
CodeOidcPARRequired Code = "oidc_par_required"
)
// FieldError describes one safe, client-actionable validation failure
type FieldError struct {
Field string `json:"field"`
Code string `json:"code"`
Message string `json:"message"`
}
// Error is an application error with a stable code, an HTTP status, and an optional internal cause
type Error struct {
code Code
status int
message string
details map[string]string
fields []FieldError
retryAfter time.Duration
cause error
}
// New creates a client-safe application error
func New(code Code, status int, message string) *Error {
return &Error{code: code, status: status, message: message}
}
// Wrap creates a client-safe application error that retains an internal cause
func Wrap(cause error, code Code, status int, message string) *Error {
if cause == nil {
return New(code, status, message)
}
return &Error{code: code, status: status, message: message, cause: cause}
}
// Internal creates a generic server error that retains a diagnostic cause without exposing it to clients
func Internal(cause error) *Error {
return Wrap(cause, CodeInternal, http.StatusInternalServerError, "Something went wrong")
}
// Validation creates a structured validation error
func Validation(fields []FieldError) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
}
// Error returns diagnostic text including the internal cause when one exists
func (e *Error) Error() string {
if e == nil {
return "<nil>"
}
if e.cause == nil {
return e.message
}
return fmt.Sprintf("%s: %v", e.message, e.cause)
}
// Unwrap exposes the internal cause to errors.Is and errors.As
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
// Is matches application errors by stable code while ignoring message and detail differences
func (e *Error) Is(target error) bool {
targetError, ok := target.(*Error)
return ok && e != nil && targetError != nil && e.code == targetError.code
}
// IsCode reports whether an error or one of its wrapped causes has the given application code
func IsCode(err error, code Code) bool {
return errors.Is(err, &Error{code: code})
}
// Code returns the stable application error code
func (e *Error) Code() Code {
if e == nil {
return ""
}
return e.code
}
// HTTPStatus returns the HTTP status associated with the error
func (e *Error) HTTPStatus() int {
if e == nil {
return http.StatusInternalServerError
}
return e.status
}
// ClientMessage returns the message that is safe to include in an HTTP response
func (e *Error) ClientMessage() string {
if e == nil {
return ""
}
return e.message
}
// Details returns a copy of the additional client-safe details
func (e *Error) Details() map[string]string {
if e == nil || len(e.details) == 0 {
return nil
}
details := make(map[string]string, len(e.details))
maps.Copy(details, e.details)
return details
}
// Fields returns a copy of the structured validation details
func (e *Error) Fields() []FieldError {
if e == nil || len(e.fields) == 0 {
return nil
}
return append([]FieldError(nil), e.fields...)
}
// RetryAfter returns the duration a client should wait before retrying
func (e *Error) RetryAfter() time.Duration {
if e == nil {
return 0
}
return e.retryAfter
}
// WithFields attaches structured validation details without exposing the cause
func (e *Error) WithFields(fields []FieldError) *Error {
if e == nil {
return nil
}
e.fields = append([]FieldError(nil), fields...)
return e
}
// WithDetail attaches one client-safe string detail
func (e *Error) WithDetail(key, value string) *Error {
if e == nil {
return nil
}
if e.details == nil {
e.details = make(map[string]string)
}
e.details[key] = value
return e
}
// WithRetryAfter attaches a retry delay to the error
func (e *Error) WithRetryAfter(retryAfter time.Duration) *Error {
if e == nil {
return nil
}
e.retryAfter = retryAfter
return e
}