mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-23 02:11:42 +00:00
fixes
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
package errors
|
||||
|
||||
// Error codes following consistent naming convention
|
||||
const (
|
||||
// Client errors (4xx)
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeRateLimited = "RATE_LIMITED"
|
||||
ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE"
|
||||
ErrCodeInvalidAPIKey = "INVALID_API_KEY"
|
||||
ErrCodeQuotaExceeded = "QUOTA_EXCEEDED"
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeInvalidConfig = "INVALID_CONFIG"
|
||||
|
||||
// Package-specific errors
|
||||
ErrCodePackageNotFound = "PACKAGE_NOT_FOUND"
|
||||
ErrCodeVersionNotFound = "VERSION_NOT_FOUND"
|
||||
ErrCodeChecksumMismatch = "CHECKSUM_MISMATCH"
|
||||
ErrCodeCorruptPackage = "CORRUPT_PACKAGE"
|
||||
ErrCodeSecurityBlocked = "SECURITY_BLOCKED"
|
||||
ErrCodeSecurityViolation = "SECURITY_VIOLATION" // Package has vulnerabilities exceeding thresholds
|
||||
ErrCodeUpstreamError = "UPSTREAM_ERROR"
|
||||
|
||||
// Server errors (5xx)
|
||||
ErrCodeInternalServer = "INTERNAL_SERVER_ERROR"
|
||||
ErrCodeStorageFailure = "STORAGE_FAILURE"
|
||||
ErrCodeUpstreamFailure = "UPSTREAM_FAILURE"
|
||||
ErrCodeDatabaseFailure = "DATABASE_FAILURE"
|
||||
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||
ErrCodeCircuitOpen = "CIRCUIT_OPEN"
|
||||
)
|
||||
|
||||
// HTTPStatusCode maps error codes to HTTP status codes
|
||||
var HTTPStatusCode = map[string]int{
|
||||
ErrCodeBadRequest: 400,
|
||||
ErrCodeUnauthorized: 401,
|
||||
ErrCodeForbidden: 403,
|
||||
ErrCodeNotFound: 404,
|
||||
ErrCodeConflict: 409,
|
||||
ErrCodeRateLimited: 429,
|
||||
ErrCodePayloadTooLarge: 413,
|
||||
ErrCodeInvalidAPIKey: 401,
|
||||
ErrCodeQuotaExceeded: 429,
|
||||
ErrCodeInvalidConfig: 400,
|
||||
ErrCodePackageNotFound: 404,
|
||||
ErrCodeVersionNotFound: 404,
|
||||
ErrCodeChecksumMismatch: 422,
|
||||
ErrCodeCorruptPackage: 422,
|
||||
ErrCodeSecurityBlocked: 403,
|
||||
ErrCodeSecurityViolation: 426, // Upgrade Required
|
||||
ErrCodeUpstreamError: 502,
|
||||
ErrCodeInternalServer: 500,
|
||||
ErrCodeStorageFailure: 500,
|
||||
ErrCodeUpstreamFailure: 502,
|
||||
ErrCodeDatabaseFailure: 500,
|
||||
ErrCodeServiceUnavailable: 503,
|
||||
ErrCodeCircuitOpen: 503,
|
||||
}
|
||||
|
||||
// GetHTTPStatus returns the HTTP status code for an error code
|
||||
func GetHTTPStatus(code string) int {
|
||||
if status, ok := HTTPStatusCode[code]; ok {
|
||||
return status
|
||||
}
|
||||
return 500 // Default to internal server error
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Error represents a structured error with code and details
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
Trace []string `json:"trace,omitempty"`
|
||||
Cause error `json:"-"` // Internal cause, not serialized
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *Error) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s (caused by: %v)", e.Code, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the cause for errors.Is/As support
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// New creates a new error with the given code and message
|
||||
func New(code, message string) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// Newf creates a new error with formatted message
|
||||
func Newf(code, format string, args ...interface{}) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
}
|
||||
}
|
||||
|
||||
// WithDetails adds details to the error
|
||||
func (e *Error) WithDetails(details interface{}) *Error {
|
||||
e.Details = details
|
||||
return e
|
||||
}
|
||||
|
||||
// WithTrace adds stack trace to the error
|
||||
func (e *Error) WithTrace(trace []string) *Error {
|
||||
e.Trace = trace
|
||||
return e
|
||||
}
|
||||
|
||||
// WithCause adds an underlying cause to the error
|
||||
func (e *Error) WithCause(cause error) *Error {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// Wrap wraps an existing error with a new code and message
|
||||
func Wrap(err error, code, message string) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapf wraps an existing error with formatted message
|
||||
func Wrapf(err error, code, format string, args ...interface{}) *Error {
|
||||
return &Error{
|
||||
Code: code,
|
||||
Message: fmt.Sprintf(format, args...),
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Common error constructors
|
||||
func BadRequest(message string) *Error {
|
||||
return New(ErrCodeBadRequest, message)
|
||||
}
|
||||
|
||||
func Unauthorized(message string) *Error {
|
||||
return New(ErrCodeUnauthorized, message)
|
||||
}
|
||||
|
||||
func Forbidden(message string) *Error {
|
||||
return New(ErrCodeForbidden, message)
|
||||
}
|
||||
|
||||
func NotFound(message string) *Error {
|
||||
return New(ErrCodeNotFound, message)
|
||||
}
|
||||
|
||||
func InternalServer(message string) *Error {
|
||||
return New(ErrCodeInternalServer, message)
|
||||
}
|
||||
|
||||
func PackageNotFound(name, version string) *Error {
|
||||
return New(ErrCodePackageNotFound, fmt.Sprintf("Package %s@%s not found", name, version)).
|
||||
WithDetails(map[string]string{
|
||||
"package": name,
|
||||
"version": version,
|
||||
})
|
||||
}
|
||||
|
||||
func QuotaExceeded(limit int64) *Error {
|
||||
return New(ErrCodeQuotaExceeded, "Storage quota exceeded").
|
||||
WithDetails(map[string]interface{}{
|
||||
"limit_bytes": limit,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type ErrorsTestSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func TestErrorsTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(ErrorsTestSuite))
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestNew() {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "simple_error",
|
||||
code: ErrCodeNotFound,
|
||||
message: "Resource not found",
|
||||
},
|
||||
{
|
||||
name: "empty_message",
|
||||
code: ErrCodeBadRequest,
|
||||
message: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
err := New(tt.code, tt.message)
|
||||
s.Equal(tt.code, err.Code)
|
||||
s.Equal(tt.message, err.Message)
|
||||
s.Nil(err.Details)
|
||||
s.Nil(err.Trace)
|
||||
s.Nil(err.Cause)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestNewf() {
|
||||
tests := []struct {
|
||||
name string
|
||||
code string
|
||||
format string
|
||||
args []interface{}
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "formatted_message",
|
||||
code: ErrCodePackageNotFound,
|
||||
format: "Package %s@%s not found",
|
||||
args: []interface{}{"react", "18.2.0"},
|
||||
expected: "Package react@18.2.0 not found",
|
||||
},
|
||||
{
|
||||
name: "no_args",
|
||||
code: ErrCodeInternalServer,
|
||||
format: "Internal error",
|
||||
args: []interface{}{},
|
||||
expected: "Internal error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
err := Newf(tt.code, tt.format, tt.args...)
|
||||
s.Equal(tt.code, err.Code)
|
||||
s.Equal(tt.expected, err.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestWithDetails() {
|
||||
tests := []struct {
|
||||
name string
|
||||
details interface{}
|
||||
}{
|
||||
{
|
||||
name: "map_details",
|
||||
details: map[string]string{"key": "value"},
|
||||
},
|
||||
{
|
||||
name: "string_details",
|
||||
details: "some details",
|
||||
},
|
||||
{
|
||||
name: "nil_details",
|
||||
details: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
err := New(ErrCodeBadRequest, "test").WithDetails(tt.details)
|
||||
s.Equal(tt.details, err.Details)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestWithTrace() {
|
||||
trace := []string{"file1.go:10", "file2.go:20"}
|
||||
err := New(ErrCodeInternalServer, "test").WithTrace(trace)
|
||||
s.Equal(trace, err.Trace)
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestWithCause() {
|
||||
cause := errors.New("underlying error")
|
||||
err := New(ErrCodeStorageFailure, "test").WithCause(cause)
|
||||
s.Equal(cause, err.Cause)
|
||||
s.Contains(err.Error(), "underlying error")
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestWrap() {
|
||||
cause := errors.New("original error")
|
||||
wrapped := Wrap(cause, ErrCodeDatabaseFailure, "database connection failed")
|
||||
|
||||
s.Equal(ErrCodeDatabaseFailure, wrapped.Code)
|
||||
s.Equal("database connection failed", wrapped.Message)
|
||||
s.Equal(cause, wrapped.Cause)
|
||||
s.True(errors.Is(wrapped, cause))
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestWrapf() {
|
||||
cause := errors.New("connection refused")
|
||||
wrapped := Wrapf(cause, ErrCodeUpstreamFailure, "failed to connect to %s", "registry.npmjs.org")
|
||||
|
||||
s.Equal(ErrCodeUpstreamFailure, wrapped.Code)
|
||||
s.Equal("failed to connect to registry.npmjs.org", wrapped.Message)
|
||||
s.Equal(cause, wrapped.Cause)
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestErrorString() {
|
||||
tests := []struct {
|
||||
name string
|
||||
err *Error
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "error_without_cause",
|
||||
err: New(ErrCodeNotFound, "not found"),
|
||||
expected: "NOT_FOUND: not found",
|
||||
},
|
||||
{
|
||||
name: "error_with_cause",
|
||||
err: Wrap(errors.New("io error"), ErrCodeStorageFailure, "storage failed"),
|
||||
expected: "STORAGE_FAILURE: storage failed (caused by: io error)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
s.Equal(tt.expected, tt.err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestCommonConstructors() {
|
||||
tests := []struct {
|
||||
name string
|
||||
fn func() *Error
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "bad_request",
|
||||
fn: func() *Error { return BadRequest("invalid input") },
|
||||
wantCode: ErrCodeBadRequest,
|
||||
},
|
||||
{
|
||||
name: "unauthorized",
|
||||
fn: func() *Error { return Unauthorized("invalid token") },
|
||||
wantCode: ErrCodeUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "forbidden",
|
||||
fn: func() *Error { return Forbidden("access denied") },
|
||||
wantCode: ErrCodeForbidden,
|
||||
},
|
||||
{
|
||||
name: "not_found",
|
||||
fn: func() *Error { return NotFound("resource missing") },
|
||||
wantCode: ErrCodeNotFound,
|
||||
},
|
||||
{
|
||||
name: "internal_server",
|
||||
fn: func() *Error { return InternalServer("server error") },
|
||||
wantCode: ErrCodeInternalServer,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
err := tt.fn()
|
||||
s.Equal(tt.wantCode, err.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestPackageNotFound() {
|
||||
err := PackageNotFound("lodash", "4.17.21")
|
||||
s.Equal(ErrCodePackageNotFound, err.Code)
|
||||
s.Equal("Package lodash@4.17.21 not found", err.Message)
|
||||
s.NotNil(err.Details)
|
||||
|
||||
details, ok := err.Details.(map[string]string)
|
||||
s.True(ok)
|
||||
s.Equal("lodash", details["package"])
|
||||
s.Equal("4.17.21", details["version"])
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestQuotaExceeded() {
|
||||
limit := int64(1000000)
|
||||
err := QuotaExceeded(limit)
|
||||
s.Equal(ErrCodeQuotaExceeded, err.Code)
|
||||
s.NotNil(err.Details)
|
||||
|
||||
details, ok := err.Details.(map[string]interface{})
|
||||
s.True(ok)
|
||||
s.Equal(limit, details["limit_bytes"])
|
||||
}
|
||||
|
||||
func (s *ErrorsTestSuite) TestUnwrap() {
|
||||
cause := errors.New("root cause")
|
||||
wrapped := Wrap(cause, ErrCodeDatabaseFailure, "db error")
|
||||
|
||||
unwrapped := wrapped.Unwrap()
|
||||
s.Equal(cause, unwrapped)
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkNewError(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = New(ErrCodeNotFound, "test error")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNewErrorWithDetails(b *testing.B) {
|
||||
details := map[string]string{"key": "value"}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = New(ErrCodeNotFound, "test error").WithDetails(details)
|
||||
}
|
||||
}
|
||||
|
||||
// Test edge cases
|
||||
func (s *ErrorsTestSuite) TestEdgeCases() {
|
||||
s.Run("nil_error_wrap", func() {
|
||||
wrapped := Wrap(nil, ErrCodeInternalServer, "test")
|
||||
s.Nil(wrapped.Cause)
|
||||
})
|
||||
|
||||
s.Run("chained_wrapping", func() {
|
||||
err1 := errors.New("base")
|
||||
err2 := Wrap(err1, ErrCodeStorageFailure, "storage")
|
||||
err3 := Wrap(err2, ErrCodeInternalServer, "internal")
|
||||
|
||||
s.True(errors.Is(err3, err2))
|
||||
s.True(errors.Is(err3, err1))
|
||||
})
|
||||
|
||||
s.Run("large_details", func() {
|
||||
largeDetails := make(map[string]string)
|
||||
for i := 0; i < 1000; i++ {
|
||||
largeDetails[string(rune(i))] = "value"
|
||||
}
|
||||
err := New(ErrCodeBadRequest, "test").WithDetails(largeDetails)
|
||||
s.Equal(largeDetails, err.Details)
|
||||
})
|
||||
}
|
||||
|
||||
// Table-driven test for error codes
|
||||
func TestGetHTTPStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
code string
|
||||
expectedStatus int
|
||||
}{
|
||||
{ErrCodeBadRequest, 400},
|
||||
{ErrCodeUnauthorized, 401},
|
||||
{ErrCodeForbidden, 403},
|
||||
{ErrCodeNotFound, 404},
|
||||
{ErrCodeConflict, 409},
|
||||
{ErrCodePayloadTooLarge, 413},
|
||||
{ErrCodeChecksumMismatch, 422},
|
||||
{ErrCodeRateLimited, 429},
|
||||
{ErrCodeInternalServer, 500},
|
||||
{ErrCodeDatabaseFailure, 500},
|
||||
{ErrCodeUpstreamFailure, 502},
|
||||
{ErrCodeServiceUnavailable, 503},
|
||||
{"UNKNOWN_CODE", 500}, // Default
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.code, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expectedStatus, GetHTTPStatus(tt.code))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// Response is the standard API response envelope
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error *ErrorResponse `json:"error,omitempty"`
|
||||
Metadata *ResponseMeta `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponse contains error details
|
||||
type ErrorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details interface{} `json:"details,omitempty"`
|
||||
Trace []string `json:"trace,omitempty"`
|
||||
}
|
||||
|
||||
// ResponseMeta contains request metadata
|
||||
type ResponseMeta struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// WriteJSON writes a success response as JSON
|
||||
func WriteJSON(w http.ResponseWriter, statusCode int, data interface{}, meta *ResponseMeta) {
|
||||
response := Response{
|
||||
Success: statusCode < 400,
|
||||
Data: data,
|
||||
Metadata: meta,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
// Fallback to simple error response
|
||||
http.Error(w, `{"success":false,"error":{"code":"ENCODING_ERROR","message":"Failed to encode response"}}`, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteError writes an error response as JSON
|
||||
func WriteError(w http.ResponseWriter, statusCode int, err *Error, meta *ResponseMeta) {
|
||||
errResp := &ErrorResponse{
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
Details: err.Details,
|
||||
Trace: err.Trace,
|
||||
}
|
||||
|
||||
response := Response{
|
||||
Success: false,
|
||||
Error: errResp,
|
||||
Metadata: meta,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
if encErr := json.NewEncoder(w).Encode(response); encErr != nil {
|
||||
// Fallback to simple error response
|
||||
http.Error(w, `{"success":false,"error":{"code":"ENCODING_ERROR","message":"Failed to encode error response"}}`, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteErrorSimple writes an error without metadata
|
||||
func WriteErrorSimple(w http.ResponseWriter, err *Error) {
|
||||
statusCode := GetHTTPStatus(err.Code)
|
||||
meta := &ResponseMeta{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
WriteError(w, statusCode, err, meta)
|
||||
}
|
||||
|
||||
// WriteJSONSimple writes a success response without metadata
|
||||
func WriteJSONSimple(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
meta := &ResponseMeta{
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
WriteJSON(w, statusCode, data, meta)
|
||||
}
|
||||
Reference in New Issue
Block a user