Skip to content

Commit b817284

Browse files
chore: improved validation and error responses (#719)
* pr 1: improve validation Signed-off-by: Mathew Wicks <[email protected]> Signed-off-by: Andy Stoneberg <[email protected]> * chore: improved validation and error responses - Updated StatusCausesFromAPIStatus to also return causes from StatusReasonConflict - Enhanced PauseActionWorkspaceHandler and CreateWorkspaceHandler to utilize IsEOFError for better error responses. - Enhanced PauseActionWorkspaceHandler to ensure conflict_causes[] is present when Patch operation returns Invalid - Introduced new test files for response error handling and validation helper functions. Signed-off-by: Andy Stoneberg <[email protected]> --------- Signed-off-by: Andy Stoneberg <[email protected]> Co-authored-by: Mathew Wicks <[email protected]>
1 parent 0142392 commit b817284

16 files changed

+1729
-211
lines changed

workspaces/backend/api/helpers.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"encoding/json"
2121
"errors"
2222
"fmt"
23+
"io"
2324
"mime"
2425
"net/http"
2526
"strings"
@@ -66,6 +67,13 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
6667
if a.IsMaxBytesError(err) {
6768
return err
6869
}
70+
71+
// provide better error message for the case where the body is empty
72+
// NOTE: io.EOF is only returned when the body is completely empty or contains only whitespace.
73+
// If there's any actual JSON content (even malformed), json.Decoder returns different errors.
74+
if a.IsEOFError(err) {
75+
return fmt.Errorf("request body was empty: %w", err)
76+
}
6977
return fmt.Errorf("error decoding JSON: %w", err)
7078
}
7179
return nil
@@ -77,6 +85,14 @@ func (a *App) IsMaxBytesError(err error) bool {
7785
return errors.As(err, &maxBytesError)
7886
}
7987

88+
// IsEOFError checks if the error is an EOF error (empty request body).
89+
// This returns true when the request body is completely empty, which happens when:
90+
// - Content-Length is 0, or
91+
// - The body stream ends immediately without any data (io.EOF)
92+
func (a *App) IsEOFError(err error) bool {
93+
return errors.Is(err, io.EOF)
94+
}
95+
8096
// ValidateContentType validates the Content-Type header of the request.
8197
// If this method returns false, the request has been handled and the caller should return immediately.
8298
// If this method returns true, the request has the correct Content-Type.

workspaces/backend/api/response_errors.go

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const (
2929
errMsgPathParamsInvalid = "path parameters were invalid"
3030
errMsgRequestBodyInvalid = "request body was invalid"
3131
errMsgKubernetesValidation = "kubernetes validation error (note: .cause.validation_errors[] correspond to the internal k8s object, not the request body)"
32+
errMsgKubernetesConflict = "kubernetes conflict error (see .cause.conflict_cause[] for details)"
3233
)
3334

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

4445
type ErrorResponse struct {
45-
Code string `json:"code"`
46-
Message string `json:"message"`
47-
Cause *ErrorCause `json:"cause,omitempty"`
46+
// Code is a string representation of the HTTP status code.
47+
Code string `json:"code"`
48+
49+
// Message is a human-readable description of the error.
50+
Message string `json:"message"`
51+
52+
// Cause contains detailed information about the cause of the error.
53+
Cause *ErrorCause `json:"cause,omitempty"`
4854
}
4955

5056
type ErrorCause struct {
57+
// ConflictCauses contains details about conflict errors that caused the request to fail.
58+
ConflictCauses []ConflictError `json:"conflict_cause,omitempty"`
59+
60+
// ValidationErrors contains details about validation errors that caused the request to fail.
5161
ValidationErrors []ValidationError `json:"validation_errors,omitempty"`
5262
}
5363

64+
type ErrorCauseOrigin string
65+
66+
const (
67+
// OriginInternal indicates the error originated from the internal application logic.
68+
OriginInternal ErrorCauseOrigin = "INTERNAL"
69+
70+
// OriginKubernetes indicates the error originated from the Kubernetes API server.
71+
OriginKubernetes ErrorCauseOrigin = "KUBERNETES"
72+
)
73+
74+
type ConflictError struct {
75+
// Origin indicates where the conflict error originated.
76+
// If value is empty, the origin is unknown.
77+
Origin ErrorCauseOrigin `json:"origin,omitempty"`
78+
79+
// A human-readable description of the cause of the error.
80+
// This field may be presented as-is to a reader.
81+
Message string `json:"message,omitempty"`
82+
}
83+
5484
type ValidationError struct {
55-
Type field.ErrorType `json:"type"`
56-
Field string `json:"field"`
57-
Message string `json:"message"`
85+
// Origin indicates where the validation error originated.
86+
// If value is empty, the origin is unknown.
87+
Origin ErrorCauseOrigin `json:"origin,omitempty"`
88+
89+
// A machine-readable description of the cause of the error.
90+
// If value is empty, there is no information available.
91+
Type field.ErrorType `json:"type,omitempty"`
92+
93+
// The field of the resource that has caused this error, as named by its JSON serialization.
94+
// May include dot and postfix notation for nested attributes.
95+
// Arrays are zero-indexed.
96+
// Fields may appear more than once in an array of causes due to fields having multiple errors.
97+
//
98+
// Examples:
99+
// "name" - the field "name" on the current resource
100+
// "items[0].name" - the field "name" on the first array entry in "items"
101+
Field string `json:"field,omitempty"`
102+
103+
// A human-readable description of the cause of the error.
104+
// This field may be presented as-is to a reader.
105+
Message string `json:"message,omitempty"`
58106
}
59107

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

147195
// HTTP: 409
148-
func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error) {
196+
func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error, k8sCauses []metav1.StatusCause) {
197+
conflictErrs := make([]ConflictError, len(k8sCauses))
198+
199+
// convert k8s causes to conflict errors
200+
for i, cause := range k8sCauses {
201+
conflictErrs[i] = ConflictError{
202+
Origin: OriginKubernetes,
203+
Message: cause.Message,
204+
}
205+
}
206+
207+
// if we have k8s causes, use a generic message
208+
// otherwise, use the error message
209+
var msg string
210+
if len(conflictErrs) > 0 {
211+
msg = errMsgKubernetesConflict
212+
} else {
213+
msg = err.Error()
214+
}
215+
149216
httpError := &HTTPError{
150217
StatusCode: http.StatusConflict,
151218
ErrorResponse: ErrorResponse{
152219
Code: strconv.Itoa(http.StatusConflict),
153-
Message: err.Error(),
220+
Message: msg,
221+
Cause: &ErrorCause{
222+
ConflictCauses: conflictErrs,
223+
},
154224
},
155225
}
156226
a.errorResponse(w, r, httpError)
@@ -187,6 +257,7 @@ func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, m
187257
// convert field errors to validation errors
188258
for i, err := range errs {
189259
valErrs[i] = ValidationError{
260+
Origin: OriginInternal,
190261
Type: err.Type,
191262
Field: err.Field,
192263
Message: err.ErrorBody(),
@@ -196,6 +267,7 @@ func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, m
196267
// convert k8s causes to validation errors
197268
for i, cause := range k8sCauses {
198269
valErrs[i+len(errs)] = ValidationError{
270+
Origin: OriginKubernetes,
199271
Type: field.ErrorType(cause.Type),
200272
Field: cause.Field,
201273
Message: cause.Message,

0 commit comments

Comments
 (0)