Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions workspaces/backend/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"strings"
Expand Down Expand Up @@ -62,8 +63,10 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(v); err != nil {
// NOTE: we don't wrap this error so we can unpack it in the caller
if a.IsMaxBytesError(err) {
// NOTE: we don't wrap these errors so we can unpack them in the caller
// io.EOF is only returned when the body is completely empty or contains only whitespace.
// If there's any actual JSON content (even malformed), json.Decoder returns different errors.
if a.IsMaxBytesError(err) || a.IsEOFError(err) {
return err
}
return fmt.Errorf("error decoding JSON: %w", err)
Expand All @@ -77,6 +80,14 @@ func (a *App) IsMaxBytesError(err error) bool {
return errors.As(err, &maxBytesError)
}

// IsEOFError checks if the error is an EOF error (empty request body).
// This returns true when the request body is completely empty, which happens when:
// - Content-Length is 0, or
// - The body stream ends immediately without any data (io.EOF)
func (a *App) IsEOFError(err error) bool {
return errors.Is(err, io.EOF)
}

// ValidateContentType validates the Content-Type header of the request.
// If this method returns false, the request has been handled and the caller should return immediately.
// If this method returns true, the request has the correct Content-Type.
Expand Down
88 changes: 80 additions & 8 deletions workspaces/backend/api/response_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const (
errMsgPathParamsInvalid = "path parameters were invalid"
errMsgRequestBodyInvalid = "request body was invalid"
errMsgKubernetesValidation = "kubernetes validation error (note: .cause.validation_errors[] correspond to the internal k8s object, not the request body)"
errMsgKubernetesConflict = "kubernetes conflict error (see .cause.conflict_cause[] for details)"
)

// ErrorEnvelope is the body of all error responses.
Expand All @@ -42,19 +43,66 @@ type HTTPError struct {
}

type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Cause *ErrorCause `json:"cause,omitempty"`
// Code is a string representation of the HTTP status code.
Code string `json:"code"`

// Message is a human-readable description of the error.
Message string `json:"message"`

// Cause contains detailed information about the cause of the error.
Cause *ErrorCause `json:"cause,omitempty"`
}

type ErrorCause struct {
// ConflictCauses contains details about conflict errors that caused the request to fail.
ConflictCauses []ConflictError `json:"conflict_cause,omitempty"`

// ValidationErrors contains details about validation errors that caused the request to fail.
ValidationErrors []ValidationError `json:"validation_errors,omitempty"`
}

type ErrorCauseOrigin string

const (
// OriginInternal indicates the error originated from the internal application logic.
OriginInternal ErrorCauseOrigin = "INTERNAL"

// OriginKubernetes indicates the error originated from the Kubernetes API server.
OriginKubernetes ErrorCauseOrigin = "KUBERNETES"
)

type ConflictError struct {
// Origin indicates where the conflict error originated.
// If value is empty, the origin is unknown.
Origin ErrorCauseOrigin `json:"origin,omitempty"`

// A human-readable description of the cause of the error.
// This field may be presented as-is to a reader.
Message string `json:"message,omitempty"`
}

type ValidationError struct {
Type field.ErrorType `json:"type"`
Field string `json:"field"`
Message string `json:"message"`
// Origin indicates where the validation error originated.
// If value is empty, the origin is unknown.
Origin ErrorCauseOrigin `json:"origin,omitempty"`

// A machine-readable description of the cause of the error.
// If value is empty, there is no information available.
Type field.ErrorType `json:"type,omitempty"`

// The field of the resource that has caused this error, as named by its JSON serialization.
// May include dot and postfix notation for nested attributes.
// Arrays are zero-indexed.
// Fields may appear more than once in an array of causes due to fields having multiple errors.
//
// Examples:
// "name" - the field "name" on the current resource
// "items[0].name" - the field "name" on the first array entry in "items"
Field string `json:"field,omitempty"`

// A human-readable description of the cause of the error.
// This field may be presented as-is to a reader.
Message string `json:"message,omitempty"`
}

// errorResponse writes an error response to the client.
Expand Down Expand Up @@ -145,12 +193,34 @@ func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
}

// HTTP: 409
func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error) {
func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error, k8sCauses []metav1.StatusCause) {
conflictErrs := make([]ConflictError, len(k8sCauses))

// convert k8s causes to conflict errors
for i, cause := range k8sCauses {
conflictErrs[i] = ConflictError{
Origin: OriginKubernetes,
Message: cause.Message,
}
}

// if we have k8s causes, use a generic message
// otherwise, use the error message
var msg string
if len(conflictErrs) > 0 {
msg = errMsgKubernetesConflict
} else {
msg = err.Error()
}

httpError := &HTTPError{
StatusCode: http.StatusConflict,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusConflict),
Message: err.Error(),
Message: msg,
Cause: &ErrorCause{
ConflictCauses: conflictErrs,
},
},
}
a.errorResponse(w, r, httpError)
Expand Down Expand Up @@ -187,6 +257,7 @@ func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, m
// convert field errors to validation errors
for i, err := range errs {
valErrs[i] = ValidationError{
Origin: OriginInternal,
Type: err.Type,
Field: err.Field,
Message: err.ErrorBody(),
Expand All @@ -196,6 +267,7 @@ func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, m
// convert k8s causes to validation errors
for i, cause := range k8sCauses {
valErrs[i+len(errs)] = ValidationError{
Origin: OriginKubernetes,
Type: field.ErrorType(cause.Type),
Field: cause.Field,
Message: cause.Message,
Expand Down
Loading
Loading