fleet/server/platform/http/errors.go
Victor Lyuboslavsky 92bc1c650e
Move PostJSONWithTimeout to platform/http package and activity cleanup (#40561)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #38536

- Moved PostJSONWithTimeout to platform/http
- Created platform/errors package with only types needed by ctxerr. This
way, ctxerr did not need to import fleethttp.
- Made activity bounded context use PostJSONWithTimeout directly
- Removed some activity types from legacy code that were no longer
needed

# Checklist for submitter

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
- Changes file `38536-new-activity-bc` already present, and this is just
cleanup from that work.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Refactor**
* Reorganized error handling utilities for improved clarity and
decoupling.
* Consolidated HTTP utilities to centralize JSON posting functionality
with timeout support.
* Simplified activity service initialization by removing unused internal
parameters.
* Cleaned up test utilities and removed webhook-related test
scaffolding.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-26 17:39:10 -06:00

417 lines
12 KiB
Go

package http
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"regexp"
"strconv"
"strings"
"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.
type ErrorWithUUID struct {
uuid string
}
var _ ErrorUUIDer = (*ErrorWithUUID)(nil)
// UUID implements the ErrorUUIDer interface.
func (e *ErrorWithUUID) UUID() string {
if e.uuid == "" {
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
}