refactor: standardize API error handling (#1635)

This commit is contained in:
Elias Schneider
2026-08-02 23:36:06 +02:00
committed by GitHub
parent 281bea54d3
commit 7a4d0dd275
108 changed files with 3163 additions and 1629 deletions
+289
View File
@@ -0,0 +1,289 @@
package apperror
import (
"fmt"
"net/http"
)
// The constructors in this file keep public messages, statuses, and details in one place
func AlreadyInUse(property string) *Error {
return New(CodeAlreadyInUse, http.StatusConflict, property+" is already in use").WithDetail("property", property)
}
func NotFound(resource string) *Error {
return New(CodeNotFound, http.StatusNotFound, resource+" not found").WithDetail("resource", resource)
}
func InvalidRequestBody(cause error) *Error {
return Wrap(cause, CodeInvalidRequestBody, http.StatusBadRequest, "Request body is invalid")
}
func MissingField(field string) *Error {
return InvalidField(field, "required", "is required")
}
func InvalidField(field, code, message string) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, fmt.Sprintf("%s %s", field, message)).WithFields([]FieldError{{
Field: field,
Code: code,
Message: message,
}})
}
func SetupNotAvailable() *Error {
return New(CodeSetupNotAvailable, http.StatusNotFound, "Not found")
}
func SetupAlreadyCompleted() *Error {
return New(CodeSetupAlreadyCompleted, http.StatusConflict, "Initial setup has already been completed")
}
func TokenInvalidOrExpired() *Error {
return New(CodeTokenInvalidOrExpired, http.StatusUnauthorized, "Token is invalid or expired")
}
func DeviceCodeInvalid() *Error {
return New(CodeDeviceCodeInvalid, http.StatusUnauthorized, "One-time access code must be used on the device it was generated for")
}
func TokenInvalid() *Error {
return New(CodeInvalidToken, http.StatusUnauthorized, "Token is invalid")
}
func OidcMissingAuthorization() *Error {
return New(CodeOidcMissingAuthorization, http.StatusForbidden, "Authorization is missing")
}
func OidcInvalidCallbackURL() *Error {
return New(CodeOidcInvalidCallbackURL, http.StatusBadRequest, "Callback URL is invalid and may need to be corrected by an administrator")
}
func InvalidCIMDURLPattern(pattern string) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, "Metadata document URL pattern is invalid").
WithDetail("pattern", pattern).
WithFields([]FieldError{{
Field: "cimdUrlAllowlist",
Code: "invalid_value",
Message: "contains an invalid URL pattern",
}})
}
func UnsupportedFileType(expected string) *Error {
if expected == "" {
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File type is not supported")
}
return New(CodeFileTypeNotSupported, http.StatusUnsupportedMediaType, "File must be of type "+expected).
WithDetail("expected_file_type", expected)
}
func FileTooLarge(maxSize string) *Error {
return New(CodeFileTooLarge, http.StatusRequestEntityTooLarge, fmt.Sprintf("File must not exceed %s", maxSize)).WithDetail("max_size", maxSize)
}
func NotSignedIn() *Error {
return New(CodeNotSignedIn, http.StatusUnauthorized, "You are not signed in")
}
func MissingPermission() *Error {
return New(CodeForbidden, http.StatusForbidden, "You don't have permission to perform this action")
}
func TooManyRequests() *Error {
return New(CodeRateLimited, http.StatusTooManyRequests, "Too many requests")
}
func UserNotFound() *Error {
return New(CodeUserNotFound, http.StatusNotFound, "User not found")
}
func InvalidImage(cause error) *Error {
return Wrap(cause, CodeInvalidImage, http.StatusBadRequest, "File is not a valid image")
}
func MissingSessionID() *Error {
return New(CodeMissingSessionID, http.StatusBadRequest, "Session ID is missing")
}
func InvalidWebAuthnSession() *Error {
return New(CodeInvalidWebAuthnSession, http.StatusBadRequest, "Your passkey request has expired")
}
func InvalidWebAuthnResponse(cause error) *Error {
return Wrap(cause, CodeInvalidWebAuthnResponse, http.StatusBadRequest, "We couldn't process the response from your passkey")
}
func WebAuthnAuthenticationFailed(cause error) *Error {
return Wrap(cause, CodeWebAuthnAuthenticationFailed, http.StatusUnauthorized, "We couldn't verify your passkey")
}
func PasskeyUserVerificationRequired(cause error) *Error {
return Wrap(cause, CodePasskeyUserVerificationRequired, http.StatusBadRequest, "Your passkey couldn't verify you. If you're using a security key, configure a FIDO2 PIN and try again")
}
func ReservedClaim(key string) *Error {
return New(CodeReservedClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is reserved and can't be used", key)).
WithDetail("key", key).
WithFields([]FieldError{{
Field: "key",
Code: "reserved",
Message: "is reserved",
}})
}
func DuplicateClaim(key string) *Error {
return New(CodeDuplicateClaim, http.StatusBadRequest, fmt.Sprintf("Claim %s is already defined", key)).
WithDetail("key", key).
WithFields([]FieldError{{
Field: "key",
Code: "duplicate",
Message: "is listed more than once",
}})
}
func LdapDisabled() *Error {
return New(CodeLdapDisabled, http.StatusConflict, "LDAP is not enabled")
}
func LdapUserUpdate() *Error {
return New(CodeLdapUserUpdate, http.StatusForbidden, "LDAP users can't be updated")
}
func LdapUserGroupUpdate() *Error {
return New(CodeLdapUserGroupUpdate, http.StatusForbidden, "LDAP user groups can't be updated")
}
func OidcAccessDenied() *Error {
return New(CodeOidcAccessDenied, http.StatusForbidden, "You're not allowed to access this service")
}
func OidcInteractionNotFound() *Error {
return New(CodeNotFound, http.StatusNotFound, "OIDC interaction not found or expired").
WithDetail("resource", "OIDC interaction")
}
func OidcClientIDNotMatching() *Error {
return New(CodeOidcClientIDNotMatching, http.StatusBadRequest, "Client ID in request doesn't match client ID in token")
}
func UIConfigDisabled() *Error {
return New(CodeUIConfigDisabled, http.StatusForbidden, "The configuration can't be changed since the UI configuration is disabled")
}
func InvalidUserID() *Error {
return Validation([]FieldError{{
Field: "userId",
Code: "invalid_format",
Message: "must be a valid UUID",
}})
}
func OneTimeAccessDisabled() *Error {
return New(CodeOneTimeAccessDisabled, http.StatusForbidden, "One-time access is disabled")
}
func DeviceLoginRequestInvalidOrExpired() *Error {
return New(CodeDeviceLoginExpired, http.StatusUnauthorized, "Device login request is invalid or expired")
}
func DeviceLoginDenied() *Error {
return New(CodeDeviceLoginDenied, http.StatusForbidden, "Device login request was denied")
}
func InvalidAPIKey() *Error {
return New(CodeInvalidAPIKey, http.StatusUnauthorized, "API key is invalid")
}
func NoAPIKeyProvided() *Error {
return New(CodeNoAPIKeyProvided, http.StatusUnauthorized, "API key is missing")
}
func APIKeyNotFound() *Error {
return New(CodeAPIKeyNotFound, http.StatusNotFound, "API key not found")
}
func APIKeyNotExpired() *Error {
return New(CodeAPIKeyNotExpired, http.StatusConflict, "API key is not expired yet")
}
func InvalidAPIKeyExpiration() *Error {
return New(CodeInvalidAPIKeyExpiration, http.StatusBadRequest, "API key expiration time must be in the future").WithFields([]FieldError{{
Field: "expiresAt",
Code: "invalid_value",
Message: "must be in the future",
}})
}
func APIKeyAuthNotAllowed() *Error {
return New(CodeAPIKeyAuthNotAllowed, http.StatusForbidden, "API key authentication is not allowed for this endpoint")
}
func UserDisabled() *Error {
return New(CodeUserDisabled, http.StatusForbidden, "User account is disabled")
}
func ValidationMessage(message string) *Error {
return New(CodeValidationFailed, http.StatusBadRequest, message)
}
func OidcDeviceCodeExpired() *Error {
return New(CodeOidcDeviceCodeExpired, http.StatusBadRequest, "Device code has expired")
}
func OidcInvalidDeviceCode() *Error {
return New(CodeOidcInvalidDeviceCode, http.StatusBadRequest, "Device code is invalid")
}
func ReauthenticationRequired() *Error {
return New(CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
}
func ReauthenticationRequiredWithCause(cause error) *Error {
return Wrap(cause, CodeReauthenticationRequired, http.StatusUnauthorized, "Reauthentication is required")
}
func OpenSignupDisabled() *Error {
return New(CodeOpenSignupDisabled, http.StatusForbidden, "Open user signup is not enabled")
}
func ClientIDAlreadyExists() *Error {
return New(CodeClientIDAlreadyExists, http.StatusConflict, "Client ID is already in use")
}
func UserEmailNotSet() *Error {
return New(CodeUserEmailNotSet, http.StatusConflict, "The user does not have an email address set")
}
func ImageNotFound() *Error {
return New(CodeImageNotFound, http.StatusNotFound, "Image not found")
}
func LogoDownloadFailed(cause error) *Error {
return Wrap(cause, CodeLogoDownloadFailed, http.StatusUnprocessableEntity, "Logo could not be downloaded")
}
func LogoTypeNotSupported() *Error {
return New(CodeLogoTypeNotSupported, http.StatusUnprocessableEntity, "Downloaded logo has an unsupported file type")
}
func LogoTooLarge(maxSize string) *Error {
return New(CodeLogoTooLarge, http.StatusUnprocessableEntity, fmt.Sprintf("Downloaded logo must not exceed %s", maxSize)).
WithDetail("max_size", maxSize)
}
func InvalidLogoURL(cause error) *Error {
return Wrap(cause, CodeValidationFailed, http.StatusBadRequest, "Logo URL is not allowed").WithFields([]FieldError{{
Field: "logoUrl",
Code: "invalid_value",
Message: "must be a public HTTP or HTTPS URL",
}})
}
func OidcPARRequired() *Error {
return New(CodeOidcPARRequired, http.StatusBadRequest, "This client requires pushed authorization requests")
}
func InvalidEmailVerificationToken() *Error {
return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid")
}
+234
View File
@@ -0,0 +1,234 @@
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"
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
}
+45
View File
@@ -0,0 +1,45 @@
package apperror
import (
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestWrapPreservesCauseWithoutChangingClientMessage(t *testing.T) {
cause := errors.New("database connection details")
err := Wrap(cause, CodeNotFound, http.StatusNotFound, "User not found")
require.ErrorIs(t, err, cause)
require.Equal(t, "User not found", err.ClientMessage())
require.Contains(t, err.Error(), "database connection details")
}
func TestErrorsMatchByCode(t *testing.T) {
err := Wrap(errors.New("database connection details"), CodeAlreadyInUse, http.StatusBadRequest, "email is already in use")
wrapped := Wrap(err, CodeInternal, http.StatusInternalServerError, "request failed")
require.True(t, IsCode(err, CodeAlreadyInUse))
require.True(t, IsCode(wrapped, CodeAlreadyInUse))
require.ErrorIs(t, err, New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use"))
require.NotErrorIs(t, err, errors.Join(New(CodeAlreadyInUse, http.StatusBadRequest, "username is already in use")))
require.False(t, IsCode(wrapped, CodeNotFound))
}
func TestErrorCopiesFields(t *testing.T) {
fields := []FieldError{{Field: "email", Code: "required", Message: "is required"}}
err := New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
fields[0].Message = "changed"
require.Equal(t, "is required", err.Fields()[0].Message)
}
func TestErrorCopiesDetails(t *testing.T) {
err := New(CodeAlreadyInUse, http.StatusBadRequest, "Already in use").WithDetail("property", "email")
details := err.Details()
details["property"] = "password"
require.Equal(t, map[string]string{"property": "email"}, err.Details())
}