refactor: improve error handling and response formatting in API

This commit is contained in:
yusing
2025-05-03 17:41:10 +08:00
parent 82c829de18
commit 98e90d7a0b
31 changed files with 657 additions and 185 deletions

View File

@@ -37,11 +37,11 @@ func (err *baseError) Subjectf(format string, args ...any) Error {
}
func (err baseError) With(extra error) Error {
return &nestedError{&err, []error{extra}}
return &nestedError{err.Err, []error{extra}}
}
func (err baseError) Withf(format string, args ...any) Error {
return &nestedError{&err, []error{fmt.Errorf(format, args...)}}
return &nestedError{err.Err, []error{fmt.Errorf(format, args...)}}
}
func (err *baseError) Error() string {
@@ -62,3 +62,11 @@ func (err *baseError) MarshalJSON() ([]byte, error) {
return json.Marshal(err.Error())
}
}
func (err *baseError) Plain() []byte {
return Plain(err.Err)
}
func (err *baseError) Markdown() []byte {
return Markdown(err.Err)
}

View File

@@ -50,6 +50,7 @@ func TestBuilderNested(t *testing.T) {
• Inner: 1
• Inner: 2
• Action 2
• Inner: 3`
• Inner: 3
`
ExpectEqual(t, got, expected)
}

View File

@@ -20,6 +20,16 @@ type Error interface {
Subject(subject string) Error
// Subjectf is a wrapper for Subject(fmt.Sprintf(format, args...)).
Subjectf(format string, args ...any) Error
PlainError
MarkdownError
}
type PlainError interface {
Plain() []byte
}
type MarkdownError interface {
Markdown() []byte
}
// this makes JSON marshaling work,

View File

@@ -153,6 +153,7 @@ func TestErrorStringNested(t *testing.T) {
• 2
• action 3 > inner3: generic failure
• 3
• 3`
• 3
`
expect.Equal(t, ansi.StripANSI(ne.Error()), want)
}

43
internal/gperr/hint.go Normal file
View File

@@ -0,0 +1,43 @@
package gperr
import "github.com/yusing/go-proxy/internal/utils/strutils/ansi"
type Hint struct {
Prefix string
Message string
Suffix string
}
var _ PlainError = (*Hint)(nil)
var _ MarkdownError = (*Hint)(nil)
func (h *Hint) Error() string {
return h.Prefix + ansi.Info(h.Message) + h.Suffix
}
func (h *Hint) Plain() []byte {
return []byte(h.Prefix + h.Message + h.Suffix)
}
func (h *Hint) Markdown() []byte {
return []byte(h.Prefix + "**" + h.Message + "**" + h.Suffix)
}
func (h *Hint) MarshalText() ([]byte, error) {
return h.Plain(), nil
}
func (h *Hint) String() string {
return h.Error()
}
func DoYouMean(s string) *Hint {
if s == "" {
return nil
}
return &Hint{
Prefix: "Do you mean ",
Message: s,
Suffix: "?",
}
}

View File

@@ -15,7 +15,7 @@ func log(msg string, err error, level zerolog.Level, logger ...*zerolog.Logger)
} else {
l = logging.GetLogger()
}
l.WithLevel(level).Msg(New(highlight(msg)).With(err).Error())
l.WithLevel(level).Msg(New(highlightANSI(msg)).With(err).Error())
switch level {
case zerolog.FatalLevel:
os.Exit(1)

View File

@@ -3,8 +3,6 @@ package gperr
import (
"errors"
"fmt"
"github.com/yusing/go-proxy/internal/utils/strutils"
)
//nolint:recvcheck
@@ -67,48 +65,98 @@ func (err *nestedError) Is(other error) bool {
return false
}
var nilError = newError("<nil>")
var bulletPrefix = []byte("• ")
var markdownBulletPrefix = []byte("- ")
var spaces = []byte(" ")
type appendLineFunc func(buf []byte, err error, level int) []byte
func (err *nestedError) Error() string {
if err == nil {
return makeLine("<nil>", 0)
return nilError.Error()
}
if err.Err != nil {
lines := make([]string, 0, 1+len(err.Extras))
lines = append(lines, makeLine(err.Err.Error(), 0))
lines = append(lines, makeLines(err.Extras, 1)...)
return strutils.JoinLines(lines)
buf := appendLineNormal(nil, err.Err, 0)
if len(err.Extras) > 0 {
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, 1, appendLineNormal)
}
return strutils.JoinLines(makeLines(err.Extras, 0))
return string(buf)
}
//go:inline
func makeLine(err string, level int) string {
const bulletPrefix = "• "
const spaces = " "
func (err *nestedError) Plain() []byte {
if err == nil {
return appendLinePlain(nil, nilError, 0)
}
buf := appendLinePlain(nil, err.Err, 0)
if len(err.Extras) > 0 {
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, 1, appendLinePlain)
}
return buf
}
func (err *nestedError) Markdown() []byte {
if err == nil {
return appendLineMd(nil, nilError, 0)
}
buf := appendLineMd(nil, err.Err, 0)
if len(err.Extras) > 0 {
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, 1, appendLineMd)
}
return buf
}
func appendLineNormal(buf []byte, err error, level int) []byte {
if level == 0 {
return err
return append(buf, err.Error()...)
}
return spaces[:2*level] + bulletPrefix + err
buf = append(buf, spaces[:2*level]...)
buf = append(buf, bulletPrefix...)
buf = append(buf, err.Error()...)
return buf
}
func makeLines(errs []error, level int) []string {
if len(errs) == 0 {
return nil
func appendLinePlain(buf []byte, err error, level int) []byte {
if level == 0 {
return append(buf, Plain(err)...)
}
buf = append(buf, spaces[:2*level]...)
buf = append(buf, bulletPrefix...)
buf = append(buf, Plain(err)...)
return buf
}
func appendLineMd(buf []byte, err error, level int) []byte {
if level == 0 {
return append(buf, Markdown(err)...)
}
buf = append(buf, spaces[:2*level]...)
buf = append(buf, markdownBulletPrefix...)
buf = append(buf, Markdown(err)...)
return buf
}
func appendLines(buf []byte, errs []error, level int, appendLine appendLineFunc) []byte {
if len(errs) == 0 {
return buf
}
lines := make([]string, 0, len(errs))
for _, err := range errs {
switch err := wrap(err).(type) {
case *nestedError:
if err.Err != nil {
lines = append(lines, makeLine(err.Err.Error(), level))
lines = append(lines, makeLines(err.Extras, level+1)...)
buf = appendLine(buf, err.Err, level)
buf = append(buf, '\n')
buf = appendLines(buf, err.Extras, level+1, appendLine)
} else {
lines = append(lines, makeLines(err.Extras, level)...)
buf = appendLines(buf, err.Extras, level, appendLine)
}
default:
lines = append(lines, makeLine(err.Error(), level))
buf = appendLine(buf, err, level)
buf = append(buf, '\n')
}
}
return lines
return buf
}

View File

@@ -1,10 +1,10 @@
package gperr
import (
"bytes"
"encoding/json"
"errors"
"slices"
"strings"
"github.com/yusing/go-proxy/internal/utils/strutils/ansi"
)
@@ -19,10 +19,23 @@ type withSubject struct {
const subjectSep = " > "
func highlight(subject string) string {
type highlightFunc func(subject string) string
var _ PlainError = (*withSubject)(nil)
var _ MarkdownError = (*withSubject)(nil)
func highlightANSI(subject string) string {
return ansi.HighlightRed + subject + ansi.Reset
}
func highlightMarkdown(subject string) string {
return "**" + subject + "**"
}
func noHighlight(subject string) string {
return subject
}
func PrependSubject(subject string, err error) error {
if err == nil {
return nil
@@ -69,24 +82,38 @@ func (err *withSubject) Unwrap() error {
}
func (err *withSubject) Error() string {
return string(err.fmtError(highlightANSI))
}
func (err *withSubject) Plain() []byte {
return err.fmtError(noHighlight)
}
func (err *withSubject) Markdown() []byte {
return err.fmtError(highlightMarkdown)
}
func (err *withSubject) fmtError(highlight highlightFunc) []byte {
// subject is in reversed order
n := len(err.Subjects)
size := 0
errStr := err.Err.Error()
var sb strings.Builder
var buf bytes.Buffer
for _, s := range err.Subjects {
size += len(s)
}
sb.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight("")))
buf.Grow(size + 2 + n*len(subjectSep) + len(errStr) + len(highlight("")))
for i := n - 1; i > 0; i-- {
sb.WriteString(err.Subjects[i])
sb.WriteString(subjectSep)
buf.WriteString(err.Subjects[i])
buf.WriteString(subjectSep)
}
sb.WriteString(highlight(err.Subjects[0]))
sb.WriteString(": ")
sb.WriteString(errStr)
return sb.String()
buf.WriteString(highlight(err.Subjects[0]))
if errStr != "" {
buf.WriteString(": ")
buf.WriteString(errStr)
}
return buf.Bytes()
}
// MarshalJSON implements the json.Marshaler interface.

View File

@@ -1,8 +1,6 @@
package gperr
import (
"encoding/json"
"errors"
"fmt"
)
@@ -29,16 +27,17 @@ func Wrap(err error, message ...string) Error {
if len(message) == 0 || message[0] == "" {
return wrap(err)
}
wrapped := &wrappedError{err, message[0]}
//nolint:errorlint
switch err := err.(type) {
case *baseError:
err.Err = fmt.Errorf("%s: %w", message[0], err.Err)
err.Err = wrapped
return err
case *nestedError:
err.Err = fmt.Errorf("%s: %w", message[0], err.Err)
err.Err = wrapped
return err
}
return &baseError{fmt.Errorf("%s: %w", message[0], err)}
return &baseError{wrapped}
}
func Unwrap(err error) Error {
@@ -65,18 +64,6 @@ func wrap(err error) Error {
return &baseError{err}
}
func IsJSONMarshallable(err error) bool {
switch err := err.(type) {
case *nestedError, *withSubject:
return true
case *baseError:
return IsJSONMarshallable(err.Err)
default:
var v json.Marshaler
return errors.As(err, &v)
}
}
func Join(errors ...error) Error {
n := 0
for _, err := range errors {
@@ -103,3 +90,27 @@ func Collect[T any, Err error, Arg any, Func func(Arg) (T, Err)](eb *Builder, fn
eb.Add(err)
return result
}
func Plain(err error) []byte {
if err == nil {
return nil
}
if p, ok := err.(PlainError); ok {
return p.Plain()
}
return []byte(err.Error())
}
func Markdown(err error) []byte {
if err == nil {
return nil
}
switch err := err.(type) {
case MarkdownError:
return err.Markdown()
case interface{ Unwrap() []error }:
return appendLines(nil, err.Unwrap(), 0, appendLineMd)
default:
return []byte(err.Error())
}
}

View File

@@ -1,63 +1,55 @@
package gperr
import (
"errors"
"testing"
)
type testErr struct{}
func (e *testErr) Error() string {
func (e testErr) Error() string {
return "test error"
}
func (e *testErr) MarshalJSON() ([]byte, error) {
return nil, nil
func (e testErr) Plain() []byte {
return []byte("test error")
}
func TestIsJSONMarshallable(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "testErr",
err: &testErr{},
want: true,
},
{
name: "baseError",
err: &baseError{},
want: false,
},
{
name: "baseError with json marshallable error",
err: &baseError{&testErr{}},
want: true,
},
{
name: "nestedError",
err: &nestedError{},
want: true,
},
{
name: "withSubject",
err: &withSubject{},
want: true,
},
{
name: "standard error",
err: errors.New("test error"),
want: false,
},
}
func (e testErr) Markdown() []byte {
return []byte("**test error**")
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := IsJSONMarshallable(test.err); got != test.want {
t.Errorf("IsJSONMarshallable(%v) = %v, want %v", test.err, got, test.want)
}
})
type testMultiErr struct {
errors []error
}
func (e testMultiErr) Error() string {
return Join(e.errors...).Error()
}
func (e testMultiErr) Unwrap() []error {
return e.errors
}
func TestFormatting(t *testing.T) {
err := testErr{}
plain := Plain(err)
if string(plain) != "test error" {
t.Errorf("expected test error, got %s", string(plain))
}
md := Markdown(err)
if string(md) != "**test error**" {
t.Errorf("expected test error, got %s", string(md))
}
}
func TestMultiError(t *testing.T) {
err := testMultiErr{[]error{testErr{}, testErr{}}}
plain := Plain(err)
if string(plain) != "test error\ntest error" {
t.Errorf("expected test error, got %s", string(plain))
}
md := Markdown(err)
if string(md) != "**test error**\n**test error**" {
t.Errorf("expected test error, got %s", string(md))
}
}

34
internal/gperr/wrapped.go Normal file
View File

@@ -0,0 +1,34 @@
package gperr
import (
"errors"
"fmt"
)
type wrappedError struct {
Err error
Message string
}
var _ PlainError = (*wrappedError)(nil)
var _ MarkdownError = (*wrappedError)(nil)
func (e *wrappedError) Error() string {
return fmt.Sprintf("%s: %s", e.Message, e.Err.Error())
}
func (e *wrappedError) Plain() []byte {
return fmt.Appendf(nil, "%s: %s", e.Message, e.Err.Error())
}
func (e *wrappedError) Markdown() []byte {
return fmt.Appendf(nil, "**%s**: %s", e.Message, e.Err.Error())
}
func (e *wrappedError) Unwrap() error {
return e.Err
}
func (e *wrappedError) Is(target error) bool {
return errors.Is(e.Err, target)
}