Initial commit
This commit is contained in:
226
pkg/errors/errors.go
Normal file
226
pkg/errors/errors.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// Package errors provides structured error types for the Prefect API client.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// APIError represents an error returned by the Prefect API.
|
||||
type APIError struct {
|
||||
// StatusCode is the HTTP status code
|
||||
StatusCode int
|
||||
|
||||
// Message is the error message
|
||||
Message string
|
||||
|
||||
// Details contains additional error details from the API
|
||||
Details map[string]interface{}
|
||||
|
||||
// RequestID is the request ID if available
|
||||
RequestID string
|
||||
|
||||
// Response is the raw HTTP response
|
||||
Response *http.Response
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *APIError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("prefect api error (status %d): %s", e.StatusCode, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("prefect api error (status %d)", e.StatusCode)
|
||||
}
|
||||
|
||||
// IsNotFound returns true if the error is a 404 Not Found error.
|
||||
func (e *APIError) IsNotFound() bool {
|
||||
return e.StatusCode == http.StatusNotFound
|
||||
}
|
||||
|
||||
// IsUnauthorized returns true if the error is a 401 Unauthorized error.
|
||||
func (e *APIError) IsUnauthorized() bool {
|
||||
return e.StatusCode == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// IsForbidden returns true if the error is a 403 Forbidden error.
|
||||
func (e *APIError) IsForbidden() bool {
|
||||
return e.StatusCode == http.StatusForbidden
|
||||
}
|
||||
|
||||
// IsRateLimited returns true if the error is a 429 Too Many Requests error.
|
||||
func (e *APIError) IsRateLimited() bool {
|
||||
return e.StatusCode == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
// IsServerError returns true if the error is a 5xx server error.
|
||||
func (e *APIError) IsServerError() bool {
|
||||
return e.StatusCode >= 500 && e.StatusCode < 600
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error from the API.
|
||||
type ValidationError struct {
|
||||
*APIError
|
||||
ValidationErrors []ValidationDetail
|
||||
}
|
||||
|
||||
// ValidationDetail represents a single validation error detail.
|
||||
type ValidationDetail struct {
|
||||
Loc []string `json:"loc"`
|
||||
Msg string `json:"msg"`
|
||||
Type string `json:"type"`
|
||||
Ctx interface{} `json:"ctx,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *ValidationError) Error() string {
|
||||
if len(e.ValidationErrors) == 0 {
|
||||
return e.APIError.Error()
|
||||
}
|
||||
return fmt.Sprintf("validation error: %s", e.ValidationErrors[0].Msg)
|
||||
}
|
||||
|
||||
// NewAPIError creates a new APIError from an HTTP response.
|
||||
func NewAPIError(resp *http.Response) error {
|
||||
if resp == nil {
|
||||
return &APIError{
|
||||
StatusCode: 0,
|
||||
Message: "nil response",
|
||||
}
|
||||
}
|
||||
|
||||
apiErr := &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: resp,
|
||||
RequestID: resp.Header.Get("X-Request-ID"),
|
||||
}
|
||||
|
||||
// Try to read and parse the response body
|
||||
if resp.Body != nil {
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
// Try to parse as JSON
|
||||
var errorResponse struct {
|
||||
Detail interface{} `json:"detail"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResponse) == nil {
|
||||
// Check if detail is a validation error (422)
|
||||
if resp.StatusCode == http.StatusUnprocessableEntity {
|
||||
return parseValidationError(apiErr, errorResponse.Detail)
|
||||
}
|
||||
|
||||
// Handle string detail
|
||||
if msg, ok := errorResponse.Detail.(string); ok {
|
||||
apiErr.Message = msg
|
||||
} else if details, ok := errorResponse.Detail.(map[string]interface{}); ok {
|
||||
apiErr.Details = details
|
||||
if msg, ok := details["message"].(string); ok {
|
||||
apiErr.Message = msg
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not JSON, use raw body as message
|
||||
apiErr.Message = string(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to status text if no message
|
||||
if apiErr.Message == "" {
|
||||
apiErr.Message = http.StatusText(resp.StatusCode)
|
||||
}
|
||||
|
||||
return apiErr
|
||||
}
|
||||
|
||||
// parseValidationError parses validation errors from the API response.
|
||||
func parseValidationError(apiErr *APIError, detail interface{}) error {
|
||||
valErr := &ValidationError{
|
||||
APIError: apiErr,
|
||||
}
|
||||
|
||||
// Detail can be a list of validation errors
|
||||
if details, ok := detail.([]interface{}); ok {
|
||||
for _, d := range details {
|
||||
if detailMap, ok := d.(map[string]interface{}); ok {
|
||||
var vd ValidationDetail
|
||||
|
||||
// Parse loc
|
||||
if loc, ok := detailMap["loc"].([]interface{}); ok {
|
||||
for _, l := range loc {
|
||||
if s, ok := l.(string); ok {
|
||||
vd.Loc = append(vd.Loc, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse msg
|
||||
if msg, ok := detailMap["msg"].(string); ok {
|
||||
vd.Msg = msg
|
||||
}
|
||||
|
||||
// Parse type
|
||||
if typ, ok := detailMap["type"].(string); ok {
|
||||
vd.Type = typ
|
||||
}
|
||||
|
||||
// Parse ctx
|
||||
if ctx, ok := detailMap["ctx"]; ok {
|
||||
vd.Ctx = ctx
|
||||
}
|
||||
|
||||
valErr.ValidationErrors = append(valErr.ValidationErrors, vd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return valErr
|
||||
}
|
||||
|
||||
// IsNotFound checks if an error is a 404 Not Found error.
|
||||
func IsNotFound(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.IsNotFound()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUnauthorized checks if an error is a 401 Unauthorized error.
|
||||
func IsUnauthorized(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.IsUnauthorized()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsForbidden checks if an error is a 403 Forbidden error.
|
||||
func IsForbidden(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.IsForbidden()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsRateLimited checks if an error is a 429 Too Many Requests error.
|
||||
func IsRateLimited(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.IsRateLimited()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsServerError checks if an error is a 5xx server error.
|
||||
func IsServerError(err error) bool {
|
||||
if apiErr, ok := err.(*APIError); ok {
|
||||
return apiErr.IsServerError()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidationError checks if an error is a validation error.
|
||||
func IsValidationError(err error) bool {
|
||||
_, ok := err.(*ValidationError)
|
||||
return ok
|
||||
}
|
||||
240
pkg/errors/errors_test.go
Normal file
240
pkg/errors/errors_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPIError_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *APIError
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with message",
|
||||
err: &APIError{
|
||||
StatusCode: 404,
|
||||
Message: "Flow not found",
|
||||
},
|
||||
want: "prefect api error (status 404): Flow not found",
|
||||
},
|
||||
{
|
||||
name: "without message",
|
||||
err: &APIError{
|
||||
StatusCode: 500,
|
||||
},
|
||||
want: "prefect api error (status 500)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.err.Error(); got != tt.want {
|
||||
t.Errorf("Error() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_Checks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
checkFuncs map[string]func(*APIError) bool
|
||||
want map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "404 not found",
|
||||
statusCode: 404,
|
||||
checkFuncs: map[string]func(*APIError) bool{
|
||||
"IsNotFound": (*APIError).IsNotFound,
|
||||
"IsUnauthorized": (*APIError).IsUnauthorized,
|
||||
"IsForbidden": (*APIError).IsForbidden,
|
||||
"IsRateLimited": (*APIError).IsRateLimited,
|
||||
"IsServerError": (*APIError).IsServerError,
|
||||
},
|
||||
want: map[string]bool{
|
||||
"IsNotFound": true,
|
||||
"IsUnauthorized": false,
|
||||
"IsForbidden": false,
|
||||
"IsRateLimited": false,
|
||||
"IsServerError": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "401 unauthorized",
|
||||
statusCode: 401,
|
||||
checkFuncs: map[string]func(*APIError) bool{
|
||||
"IsNotFound": (*APIError).IsNotFound,
|
||||
"IsUnauthorized": (*APIError).IsUnauthorized,
|
||||
"IsForbidden": (*APIError).IsForbidden,
|
||||
"IsRateLimited": (*APIError).IsRateLimited,
|
||||
"IsServerError": (*APIError).IsServerError,
|
||||
},
|
||||
want: map[string]bool{
|
||||
"IsNotFound": false,
|
||||
"IsUnauthorized": true,
|
||||
"IsForbidden": false,
|
||||
"IsRateLimited": false,
|
||||
"IsServerError": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "500 server error",
|
||||
statusCode: 500,
|
||||
checkFuncs: map[string]func(*APIError) bool{
|
||||
"IsNotFound": (*APIError).IsNotFound,
|
||||
"IsUnauthorized": (*APIError).IsUnauthorized,
|
||||
"IsForbidden": (*APIError).IsForbidden,
|
||||
"IsRateLimited": (*APIError).IsRateLimited,
|
||||
"IsServerError": (*APIError).IsServerError,
|
||||
},
|
||||
want: map[string]bool{
|
||||
"IsNotFound": false,
|
||||
"IsUnauthorized": false,
|
||||
"IsForbidden": false,
|
||||
"IsRateLimited": false,
|
||||
"IsServerError": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := &APIError{StatusCode: tt.statusCode}
|
||||
for checkName, checkFunc := range tt.checkFuncs {
|
||||
if got := checkFunc(err); got != tt.want[checkName] {
|
||||
t.Errorf("%s() = %v, want %v", checkName, got, tt.want[checkName])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPIError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resp *http.Response
|
||||
wantStatus int
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "nil response",
|
||||
resp: nil,
|
||||
wantStatus: 0,
|
||||
wantMsg: "nil response",
|
||||
},
|
||||
{
|
||||
name: "404 with JSON body",
|
||||
resp: &http.Response{
|
||||
StatusCode: 404,
|
||||
Body: io.NopCloser(strings.NewReader(`{"detail": "Flow not found"}`)),
|
||||
Header: http.Header{},
|
||||
},
|
||||
wantStatus: 404,
|
||||
wantMsg: "Flow not found",
|
||||
},
|
||||
{
|
||||
name: "500 without body",
|
||||
resp: &http.Response{
|
||||
StatusCode: 500,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: http.Header{},
|
||||
},
|
||||
wantStatus: 500,
|
||||
wantMsg: "Internal Server Error",
|
||||
},
|
||||
{
|
||||
name: "with request ID",
|
||||
resp: &http.Response{
|
||||
StatusCode: 400,
|
||||
Body: io.NopCloser(strings.NewReader(`{"detail": "Bad request"}`)),
|
||||
Header: http.Header{
|
||||
"X-Request-ID": []string{"req-123"},
|
||||
},
|
||||
},
|
||||
wantStatus: 400,
|
||||
wantMsg: "Bad request",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewAPIError(tt.resp)
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *APIError, got %T", err)
|
||||
}
|
||||
if apiErr.StatusCode != tt.wantStatus {
|
||||
t.Errorf("StatusCode = %v, want %v", apiErr.StatusCode, tt.wantStatus)
|
||||
}
|
||||
if apiErr.Message != tt.wantMsg {
|
||||
t.Errorf("Message = %v, want %v", apiErr.Message, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAPIError_Validation(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
StatusCode: 422,
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"detail": [
|
||||
{
|
||||
"loc": ["body", "name"],
|
||||
"msg": "field required",
|
||||
"type": "value_error.missing"
|
||||
}
|
||||
]
|
||||
}`)),
|
||||
Header: http.Header{},
|
||||
}
|
||||
|
||||
err := NewAPIError(resp)
|
||||
valErr, ok := err.(*ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ValidationError, got %T", err)
|
||||
}
|
||||
|
||||
if valErr.StatusCode != 422 {
|
||||
t.Errorf("StatusCode = %v, want 422", valErr.StatusCode)
|
||||
}
|
||||
|
||||
if len(valErr.ValidationErrors) != 1 {
|
||||
t.Fatalf("expected 1 validation error, got %d", len(valErr.ValidationErrors))
|
||||
}
|
||||
|
||||
vd := valErr.ValidationErrors[0]
|
||||
if len(vd.Loc) != 2 || vd.Loc[0] != "body" || vd.Loc[1] != "name" {
|
||||
t.Errorf("Loc = %v, want [body name]", vd.Loc)
|
||||
}
|
||||
if vd.Msg != "field required" {
|
||||
t.Errorf("Msg = %v, want 'field required'", vd.Msg)
|
||||
}
|
||||
if vd.Type != "value_error.missing" {
|
||||
t.Errorf("Type = %v, want 'value_error.missing'", vd.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperFunctions(t *testing.T) {
|
||||
notFoundErr := &APIError{StatusCode: 404}
|
||||
unauthorizedErr := &APIError{StatusCode: 401}
|
||||
serverErr := &APIError{StatusCode: 500}
|
||||
valErr := &ValidationError{APIError: &APIError{StatusCode: 422}}
|
||||
|
||||
if !IsNotFound(notFoundErr) {
|
||||
t.Error("IsNotFound should return true for 404 error")
|
||||
}
|
||||
if !IsUnauthorized(unauthorizedErr) {
|
||||
t.Error("IsUnauthorized should return true for 401 error")
|
||||
}
|
||||
if !IsServerError(serverErr) {
|
||||
t.Error("IsServerError should return true for 500 error")
|
||||
}
|
||||
if !IsValidationError(valErr) {
|
||||
t.Error("IsValidationError should return true for validation error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user