fleet/server/platform/http/errors.go
Ian Littman cd439f6125
Fix data race in ErrorWithUUID.UUID() causing CI test failures (#40961)
Resolves #40857.

The scheduled CI runs (with -race enabled) were failing due to a data
race in ErrorWithUUID.UUID(). The race occurred between:
- HTTP response encoding calling UUID() to lazily initialize the uuid
field
- Error store background goroutine calling Error() via value-receiver
methods, which copies the struct (including the uuid field) concurrently
- Logging calls

Fix:
1. Use sync.Once for thread-safe lazy UUID initialization
2. Change all value-receiver methods on types embedding ErrorWithUUID to
pointer receivers to prevent struct copying that triggers the race
3. Add isNotFoundErr() helper to replace broken errors.Is/errors.As
patterns that relied on value-type error comparisons

From Claude Code Web (ported from my personal fork due to repo access
level required). I've read through the code prior to submitting this PR.
Prompt:

> The scheduled run of .github/workflows/test-go.yaml has had a bunch of
errors in integration tests, starting recently. set up and run the tests
(including race detection) as if you were running in GotHub Actions,
then figure out when the issue was introduced, and what needs to happen
to fix the test errors.

I expect that smoketests and continued during-dev validation of `main`
leading up to 4.83.0 will be sufficient manual testing here.

## Testing

- [x] Added/updated automated tests

- [ ] QA'd all new/changed functionality manually

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-05 09:17:51 -06:00

420 lines
12 KiB
Go

package http
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"github.com/docker/go-units"
platform_errors "github.com/fleetdm/fleet/v4/server/platform/errors"
"github.com/google/uuid"
)
// ErrWithInternal defines an interface for errors that have an internal message
// that should only be logged, not returned to the client.
type ErrWithInternal interface {
error
// Internal returns the error string that must only be logged internally,
// not returned to the client.
Internal() string
}
// ErrWithLogFields defines an interface for errors that have additional log fields.
type ErrWithLogFields interface {
error
// LogFields returns the additional log fields to add, which should come in
// key, value pairs (as used in go-kit log).
LogFields() []any
}
// ErrorUUIDer defines an interface for errors that have a UUID for tracking.
type ErrorUUIDer interface {
// UUID returns the error's UUID.
UUID() string
}
// ErrorWithUUID can be embedded in error types to implement ErrorUUIDer.
// The UUID is lazily generated on first access and is safe for concurrent use.
type ErrorWithUUID struct {
uuidOnce sync.Once
uuid string
}
var _ ErrorUUIDer = (*ErrorWithUUID)(nil)
// UUID implements the ErrorUUIDer interface.
func (e *ErrorWithUUID) UUID() string {
e.uuidOnce.Do(func() {
u, err := uuid.NewRandom()
if err != nil {
panic(err)
}
e.uuid = u.String()
})
return e.uuid
}
// BadRequestError is the error returned when the request is invalid.
type BadRequestError struct {
Message string
InternalErr error
ErrorWithUUID
}
// Error returns the error message.
func (e *BadRequestError) Error() string {
return e.Message
}
// BadRequestError implements the interface required by the server/service package logic
// to determine the status code to return to the client.
func (e *BadRequestError) BadRequestError() []map[string]string {
return nil
}
// Internal implements the ErrWithInternal interface.
func (e *BadRequestError) Internal() string {
if e.InternalErr != nil {
return e.InternalErr.Error()
}
return ""
}
// We implement the second type of Unwrap that returns an error array, which still works for errors.Is/As, but is not supported in errors.Unwrap
// This allows us to check the error chain, but not log the most inner error in the HTTP response.
func (e *BadRequestError) Unwrap() []error {
return []error{e.InternalErr}
}
// IsClientError implements ErrWithIsClientError.
func (e *BadRequestError) IsClientError() bool {
return true
}
type PayloadTooLargeError struct {
ContentLength string
MaxRequestSize int64
}
func (e PayloadTooLargeError) Error() string {
return fmt.Sprintf("Request exceeds the max size limit of %s. Configure the limit: https://fleetdm.com/docs/configuration/fleet-server-configuration#server-default-max-request-body-size", units.HumanSize(float64(e.MaxRequestSize)))
}
func (e PayloadTooLargeError) Internal() string {
// This is for us to have an indication of the size we get, might be spoofable, but better than nothing
msg := fmt.Sprintf("Request exceeds the max size limit of %s", units.HumanSize(float64(e.MaxRequestSize)))
if e.ContentLength != "" {
size := e.ContentLength
contentLengthAsNumber, err := strconv.ParseFloat(e.ContentLength, 64)
if err == nil {
// We don't care if we failed to parse the number, only if we were successful
size = units.HumanSize(contentLengthAsNumber)
}
msg += fmt.Sprintf(", Incoming Content-Length: %s", size)
}
return msg
}
func (e PayloadTooLargeError) StatusCode() int {
return http.StatusRequestEntityTooLarge
}
func (e PayloadTooLargeError) IsClientError() bool {
return true
}
// UserMessageError is an error that wraps another error with a user-friendly message.
type UserMessageError struct {
error
statusCode int
ErrorWithUUID
}
// NewUserMessageError creates a UserMessageError that will translate the
// error message of err to a user-friendly form. If statusCode is > 0, it
// will be used as the HTTP status code for the error, otherwise it defaults
// to http.StatusUnprocessableEntity (422).
func NewUserMessageError(err error, statusCode int) *UserMessageError {
if err == nil {
return nil
}
return &UserMessageError{
error: err,
statusCode: statusCode,
}
}
// StatusCode returns the HTTP status code for this error.
func (e *UserMessageError) StatusCode() int {
if e.statusCode > 0 {
return e.statusCode
}
return http.StatusUnprocessableEntity
}
// IsClientError implements ErrWithIsClientError.
// Returns true for 4xx status codes, false for 5xx.
func (e *UserMessageError) IsClientError() bool {
code := e.StatusCode()
return code >= 400 && code < 500
}
var rxJSONUnknownField = regexp.MustCompile(`^json: unknown field "(.+)"$`)
// IsJSONUnknownFieldError returns true if err is a JSON unknown field error.
// There is no exported type or value for this error, so we have to match the
// error message.
func IsJSONUnknownFieldError(err error) bool {
return rxJSONUnknownField.MatchString(err.Error())
}
// GetJSONUnknownField returns the unknown field name from a JSON unknown field error.
func GetJSONUnknownField(err error) *string {
errCause := platform_errors.Cause(err)
if IsJSONUnknownFieldError(errCause) {
substr := rxJSONUnknownField.FindStringSubmatch(errCause.Error())
return &substr[1]
}
return nil
}
// UserMessage implements the user-friendly translation of the error if its
// root cause is one of the supported types, otherwise it returns the error
// message.
func (e *UserMessageError) UserMessage() string {
cause := platform_errors.Cause(e.error)
switch cause := cause.(type) {
case *json.UnmarshalTypeError:
var sb strings.Builder
curType := cause.Type
for curType.Kind() == reflect.Slice || curType.Kind() == reflect.Array {
sb.WriteString("array of ")
curType = curType.Elem()
}
sb.WriteString(curType.Name())
if curType != cause.Type {
// it was an array
sb.WriteString("s")
}
return fmt.Sprintf("invalid value type at '%s': expected %s but got %s", cause.Field, sb.String(), cause.Value)
default:
// there's no specific error type for the strict json mode
// (DisallowUnknownFields), so resort to message-matching.
if matches := rxJSONUnknownField.FindStringSubmatch(cause.Error()); matches != nil {
return fmt.Sprintf("unsupported key provided: %q", matches[1])
}
return e.Error()
}
}
// ErrWithRetryAfter is an interface for errors that should set a specific HTTP
// Header Retry-After value (see
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
type ErrWithRetryAfter interface {
error
// RetryAfter returns the number of seconds to wait before retry.
RetryAfter() int
}
// ForeignKeyError is an interface for errors caused by foreign key constraint violations.
type ForeignKeyError interface {
error
IsForeignKey() bool
}
// IsForeignKey returns true if err is a foreign key constraint violation.
func IsForeignKey(err error) bool {
var fke ForeignKeyError
if errors.As(err, &fke) {
return fke.IsForeignKey()
}
return false
}
// AlreadyExistsError is an interface for errors when a resource already exists.
type AlreadyExistsError interface {
error
IsExists() bool
}
// Error is a generic error type with a code and message.
type Error struct {
Code int `json:"code,omitempty"`
Message string `json:"message,omitempty"`
ErrorWithUUID
}
// Error returns the error message.
func (e *Error) Error() string {
return e.Message
}
// AuthFailedError is returned when authentication fails.
type AuthFailedError struct {
// internal is the reason that should only be logged internally
internal string
ErrorWithUUID
}
// NewAuthFailedError creates a new AuthFailedError.
func NewAuthFailedError(internal string) *AuthFailedError {
return &AuthFailedError{internal: internal}
}
// Error implements the error interface.
func (e *AuthFailedError) Error() string {
return "Authentication failed"
}
// Internal implements ErrWithInternal.
func (e *AuthFailedError) Internal() string {
return e.internal
}
// StatusCode implements kithttp.StatusCoder.
func (e *AuthFailedError) StatusCode() int {
return http.StatusUnauthorized
}
// IsClientError implements ErrWithIsClientError.
func (e *AuthFailedError) IsClientError() bool {
return true
}
// AuthRequiredError is returned when authentication is required.
type AuthRequiredError struct {
// internal is the reason that should only be logged internally
internal string
ErrorWithUUID
}
// NewAuthRequiredError creates a new AuthRequiredError.
func NewAuthRequiredError(internal string) *AuthRequiredError {
return &AuthRequiredError{internal: internal}
}
// Error implements the error interface.
func (e *AuthRequiredError) Error() string {
return "Authentication required"
}
// Internal implements ErrWithInternal.
func (e *AuthRequiredError) Internal() string {
return e.internal
}
// StatusCode implements kithttp.StatusCoder.
func (e *AuthRequiredError) StatusCode() int {
return http.StatusUnauthorized
}
// IsClientError implements ErrWithIsClientError.
func (e *AuthRequiredError) IsClientError() bool {
return true
}
// AuthHeaderRequiredError is returned when an authorization header is required.
type AuthHeaderRequiredError struct {
// internal is the reason that should only be logged internally
internal string
ErrorWithUUID
}
// NewAuthHeaderRequiredError creates a new AuthHeaderRequiredError.
func NewAuthHeaderRequiredError(internal string) *AuthHeaderRequiredError {
return &AuthHeaderRequiredError{
internal: internal,
}
}
// Error implements the error interface.
func (e *AuthHeaderRequiredError) Error() string {
return "Authorization header required"
}
// Internal implements ErrWithInternal.
func (e *AuthHeaderRequiredError) Internal() string {
return e.internal
}
// StatusCode implements kithttp.StatusCoder.
func (e *AuthHeaderRequiredError) StatusCode() int {
return http.StatusUnauthorized
}
// IsClientError implements ErrWithIsClientError.
func (e *AuthHeaderRequiredError) IsClientError() bool {
return true
}
// ErrPasswordResetRequired is returned when a password reset is required.
var ErrPasswordResetRequired = &passwordResetRequiredError{}
type passwordResetRequiredError struct {
ErrorWithUUID
}
// Error implements the error interface.
func (e *passwordResetRequiredError) Error() string {
return "password reset required"
}
// StatusCode implements kithttp.StatusCoder.
func (e *passwordResetRequiredError) StatusCode() int {
return http.StatusUnauthorized
}
// IsClientError implements ErrWithIsClientError.
func (e *passwordResetRequiredError) IsClientError() bool {
return true
}
// ForbiddenErrorMessage is the error message that should be returned to
// clients when an action is forbidden. It is intentionally vague to prevent
// disclosing information that a client should not have access to.
const ForbiddenErrorMessage = "forbidden"
// CheckMissing is the error to return when no authorization check was performed
// by the service.
type CheckMissing struct {
response any
ErrorWithUUID
}
// CheckMissingWithResponse creates a new error indicating the authorization
// check was missed, and including the response for further analysis by the error
// encoder.
func CheckMissingWithResponse(response any) *CheckMissing {
return &CheckMissing{response: response}
}
// Error implements the error interface.
func (e *CheckMissing) Error() string {
return ForbiddenErrorMessage
}
// Internal implements the ErrWithInternal interface.
func (e *CheckMissing) Internal() string {
return "Missing authorization check"
}
// Response returns the response that was generated before the authorization
// check was found to be missing.
func (e *CheckMissing) Response() any {
return e.response
}