You've already forked agentic-coding-workflow
feat(errs): AppError type with HTTP status mapping
This commit is contained in:
@@ -0,0 +1,111 @@
|
|||||||
|
// Package errs defines the application's error type (AppError) and helpers
|
||||||
|
// to map domain error codes to HTTP status codes. Service layers wrap
|
||||||
|
// underlying errors with errs.Wrap / errs.New; transport layers use errs.As
|
||||||
|
// + errs.HTTPStatus to translate them into Google API style responses
|
||||||
|
// ({code, message, fields, request_id}).
|
||||||
|
package errs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Code is a stable, machine-readable identifier for a class of error.
|
||||||
|
// Codes are part of the public API contract surfaced by HTTP responses.
|
||||||
|
type Code string
|
||||||
|
|
||||||
|
// Canonical error codes. Keep this list in sync with HTTPStatus and any
|
||||||
|
// downstream consumers (e.g., HTTP transport, OpenAPI schemas).
|
||||||
|
const (
|
||||||
|
CodeInvalidInput Code = "invalid_input"
|
||||||
|
CodeUnauthorized Code = "unauthorized"
|
||||||
|
CodeForbidden Code = "forbidden"
|
||||||
|
CodeNotFound Code = "not_found"
|
||||||
|
CodeConflict Code = "conflict"
|
||||||
|
CodeRateLimited Code = "rate_limited"
|
||||||
|
CodeUpstream Code = "upstream_error"
|
||||||
|
CodeUnavailable Code = "unavailable"
|
||||||
|
CodeInternal Code = "internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppError is the application's structured error type. It carries a stable
|
||||||
|
// Code, a human-readable Message, an optional underlying Cause, and an
|
||||||
|
// optional Fields map for per-field validation details.
|
||||||
|
type AppError struct {
|
||||||
|
Code Code
|
||||||
|
Message string
|
||||||
|
Cause error
|
||||||
|
Fields map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface. When a Cause is present the format
|
||||||
|
// is "<message>: <cause>"; otherwise just the message is returned.
|
||||||
|
func (e *AppError) Error() string {
|
||||||
|
if e.Cause != nil {
|
||||||
|
return fmt.Sprintf("%s: %s", e.Message, e.Cause.Error())
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap exposes the underlying cause so errors.Is / errors.As can traverse
|
||||||
|
// the chain.
|
||||||
|
func (e *AppError) Unwrap() error { return e.Cause }
|
||||||
|
|
||||||
|
// WithFields attaches per-field validation details and returns the receiver
|
||||||
|
// for chaining.
|
||||||
|
func (e *AppError) WithFields(f map[string]any) *AppError {
|
||||||
|
e.Fields = f
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs a new AppError with the given code and message.
|
||||||
|
func New(code Code, msg string) *AppError {
|
||||||
|
return &AppError{Code: code, Message: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap wraps an existing error with an AppError carrying the given code and
|
||||||
|
// message. The original error is preserved as Cause and remains discoverable
|
||||||
|
// via errors.Is / errors.As.
|
||||||
|
func Wrap(cause error, code Code, msg string) *AppError {
|
||||||
|
return &AppError{Code: code, Message: msg, Cause: cause}
|
||||||
|
}
|
||||||
|
|
||||||
|
// As is a convenience wrapper around errors.As that extracts the first
|
||||||
|
// *AppError in err's chain. It returns (nil, false) when err is nil or no
|
||||||
|
// AppError is found.
|
||||||
|
func As(err error) (*AppError, bool) {
|
||||||
|
if err == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var ae *AppError
|
||||||
|
if errors.As(err, &ae) {
|
||||||
|
return ae, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPStatus maps an error Code to its canonical HTTP status code. Unknown
|
||||||
|
// codes default to 500 Internal Server Error.
|
||||||
|
func HTTPStatus(code Code) int {
|
||||||
|
switch code {
|
||||||
|
case CodeInvalidInput:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case CodeUnauthorized:
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
case CodeForbidden:
|
||||||
|
return http.StatusForbidden
|
||||||
|
case CodeNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
case CodeConflict:
|
||||||
|
return http.StatusConflict
|
||||||
|
case CodeRateLimited:
|
||||||
|
return http.StatusTooManyRequests
|
||||||
|
case CodeUpstream:
|
||||||
|
return http.StatusBadGateway
|
||||||
|
case CodeUnavailable:
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package errs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew_BasicShape(t *testing.T) {
|
||||||
|
e := New(CodeInvalidInput, "字段缺失")
|
||||||
|
require.Equal(t, CodeInvalidInput, e.Code)
|
||||||
|
require.Equal(t, "字段缺失", e.Message)
|
||||||
|
require.Nil(t, e.Cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithFields(t *testing.T) {
|
||||||
|
e := New(CodeInvalidInput, "x").WithFields(map[string]any{"name": "required"})
|
||||||
|
require.Equal(t, "required", e.Fields["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrap_PreservesCause(t *testing.T) {
|
||||||
|
cause := errors.New("boom")
|
||||||
|
e := Wrap(cause, CodeInternal, "wrapped")
|
||||||
|
require.ErrorIs(t, e, cause)
|
||||||
|
require.Equal(t, "wrapped: boom", e.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPStatus(t *testing.T) {
|
||||||
|
cases := map[Code]int{
|
||||||
|
CodeInvalidInput: http.StatusBadRequest,
|
||||||
|
CodeUnauthorized: http.StatusUnauthorized,
|
||||||
|
CodeForbidden: http.StatusForbidden,
|
||||||
|
CodeNotFound: http.StatusNotFound,
|
||||||
|
CodeConflict: http.StatusConflict,
|
||||||
|
CodeRateLimited: http.StatusTooManyRequests,
|
||||||
|
CodeUpstream: http.StatusBadGateway,
|
||||||
|
CodeUnavailable: http.StatusServiceUnavailable,
|
||||||
|
CodeInternal: http.StatusInternalServerError,
|
||||||
|
}
|
||||||
|
for code, want := range cases {
|
||||||
|
require.Equal(t, want, HTTPStatus(code), "code=%s", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user