From a9983f35312155e3fb169ccb0f8914ced8b06e7d Mon Sep 17 00:00:00 2001 From: antanst Date: Wed, 26 Feb 2025 10:32:38 +0200 Subject: [PATCH] Reorganize errors --- common/errors.go | 106 -------------------- common/errors/errors.go | 41 ++++++++ common/errors/errors_test.go | 38 ++++++++ common/errors_test.go | 25 ----- errors/errors.go | 104 ++++++++++++++++++++ errors/errors_test.go | 184 +++++++++++++++++++++++++++++++++++ gemini/errors.go | 52 ++++++++++ 7 files changed, 419 insertions(+), 131 deletions(-) delete mode 100644 common/errors.go create mode 100644 common/errors/errors.go create mode 100644 common/errors/errors_test.go delete mode 100644 common/errors_test.go create mode 100644 errors/errors.go create mode 100644 errors/errors_test.go create mode 100644 gemini/errors.go diff --git a/common/errors.go b/common/errors.go deleted file mode 100644 index 641fe78..0000000 --- a/common/errors.go +++ /dev/null @@ -1,106 +0,0 @@ -package common - -import ( - "errors" - "fmt" -) - -type GeminiError struct { - Msg string - Code int - Header string -} - -func (e *GeminiError) Error() string { - return fmt.Sprintf("%s: %s", e.Msg, e.Header) -} - -func NewErrGeminiStatusCode(code int, header string) error { - var msg string - switch { - case code >= 10 && code < 20: - msg = "needs input" - case code >= 30 && code < 40: - msg = "redirect" - case code >= 40 && code < 50: - msg = "bad request" - case code >= 50 && code < 60: - msg = "server error" - case code >= 60 && code < 70: - msg = "TLS error" - default: - msg = "unexpected Status code" - } - return &GeminiError{ - Msg: msg, - Code: code, - Header: header, - } -} - -var ( - ErrGeminiRobotsParse = errors.New("gemini robots.txt parse error") - ErrGeminiRobotsDisallowed = errors.New("gemini robots.txt disallowed") - ErrGeminiResponseHeader = errors.New("gemini response header error") - ErrGeminiRedirect = errors.New("gemini redirection error") - ErrGeminiLinkLineParse = errors.New("gemini link line parse error") - - ErrURLParse = errors.New("URL parse error") - ErrURLNotGemini = errors.New("not a Gemini URL") - ErrURLDecode = errors.New("URL decode error") - ErrUTF8Parse = errors.New("UTF-8 parse error") - ErrTextParse = errors.New("text parse error") - - ErrBlacklistMatches = errors.New("url matches blacklist") - - ErrNetwork = errors.New("network error") - ErrNetworkDNS = errors.New("network DNS error") - ErrNetworkTLS = errors.New("network TLS error") - ErrNetworkSetConnectionDeadline = errors.New("network error - cannot set connection deadline") - ErrNetworkCannotWrite = errors.New("network error - cannot write") - ErrNetworkResponseSizeExceededMax = errors.New("network error - response size exceeded maximum size") - - ErrDatabase = errors.New("database error") - ErrDatabaseScan = errors.New("database scan error") -) - -// We could have used a map for speed, but -// we would lose ability to check wrapped -// errors via errors.Is(). - -var errGemini *GeminiError - -var knownErrors = []error{ //nolint:gochecknoglobals - errGemini, - ErrGeminiLinkLineParse, - ErrGeminiRobotsParse, - ErrGeminiRobotsDisallowed, - ErrGeminiResponseHeader, - ErrGeminiRedirect, - - ErrBlacklistMatches, - - ErrURLParse, - ErrURLDecode, - ErrUTF8Parse, - ErrTextParse, - - ErrNetwork, - ErrNetworkDNS, - ErrNetworkTLS, - ErrNetworkSetConnectionDeadline, - ErrNetworkCannotWrite, - ErrNetworkResponseSizeExceededMax, - - ErrDatabase, - ErrDatabaseScan, -} - -func IsKnownError(err error) bool { - for _, known := range knownErrors { - if errors.Is(err, known) { - return true - } - } - return errors.As(err, new(*GeminiError)) -} diff --git a/common/errors/errors.go b/common/errors/errors.go new file mode 100644 index 0000000..2c88f29 --- /dev/null +++ b/common/errors/errors.go @@ -0,0 +1,41 @@ +package errors + +import ( + "fmt" + + "gemini-grc/errors" +) + +// HostError is an error encountered while +// visiting a host, and should be recorded +// to the snapshot. +type HostError struct { + Err error +} + +func (e *HostError) Error() string { + return e.Err.Error() +} + +func (e *HostError) Unwrap() error { + return e.Err +} + +func NewHostError(err error) error { + return &HostError{Err: err} +} + +func IsHostError(err error) bool { + if err == nil { + return false + } + var asError *HostError + return errors.As(err, &asError) +} + +// Sentinel errors used for their string message primarily. +// Do not use them by themselves, to be embedded to HostError. +var ( + ErrBlacklistMatch = fmt.Errorf("black list match") + ErrRobotsMatch = fmt.Errorf("robots match") +) diff --git a/common/errors/errors_test.go b/common/errors/errors_test.go new file mode 100644 index 0000000..b5026ec --- /dev/null +++ b/common/errors/errors_test.go @@ -0,0 +1,38 @@ +package errors_test + +import ( + "errors" + "fmt" + "testing" + + "gemini-grc/gemini" +) + +func TestErrGemini(t *testing.T) { + t.Parallel() + err := gemini.NewGeminiError(50, "50 server error") + if !errors.As(err, new(*gemini.GeminiError)) { + t.Errorf("TestErrGemini fail") + } +} + +func TestErrGeminiWrapped(t *testing.T) { + t.Parallel() + err := gemini.NewGeminiError(50, "50 server error") + errWrapped := fmt.Errorf("%w wrapped", err) + if !errors.As(errWrapped, new(*gemini.GeminiError)) { + t.Errorf("TestErrGeminiWrapped fail") + } +} + +func TestIsGeminiError(t *testing.T) { + t.Parallel() + err1 := gemini.NewGeminiError(50, "50 server error") + if !gemini.IsGeminiError(err1) { + t.Errorf("TestGeminiError fail #1") + } + wrappedErr1 := fmt.Errorf("wrapped %w", err1) + if !gemini.IsGeminiError(wrappedErr1) { + t.Errorf("TestGeminiError fail #2") + } +} diff --git a/common/errors_test.go b/common/errors_test.go deleted file mode 100644 index ae9ac00..0000000 --- a/common/errors_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package common_test - -import ( - "errors" - "fmt" - "gemini-grc/common" - "testing" -) - -func TestErrGemini(t *testing.T) { - t.Parallel() - err := common.NewErrGeminiStatusCode(50, "50 server error") - if !errors.As(err, new(*common.GeminiError)) { - t.Errorf("TestErrGemini fail") - } -} - -func TestErrGeminiWrapped(t *testing.T) { - t.Parallel() - err := common.NewErrGeminiStatusCode(50, "50 server error") - errWrapped := fmt.Errorf("%w wrapped", err) - if !errors.As(errWrapped, new(*common.GeminiError)) { - t.Errorf("TestErrGeminiWrapped fail") - } -} diff --git a/errors/errors.go b/errors/errors.go new file mode 100644 index 0000000..dfe24ad --- /dev/null +++ b/errors/errors.go @@ -0,0 +1,104 @@ +package errors + +import ( + "errors" + "fmt" + "runtime" + "strings" +) + +type fatal interface { + Fatal() bool +} + +func IsFatal(err error) bool { + var e fatal + ok := As(err, &e) + return ok && e.Fatal() +} + +func As(err error, target any) bool { + return errors.As(err, target) +} + +func Is(err, target error) bool { + return errors.Is(err, target) +} + +func Unwrap(err error) error { + return errors.Unwrap(err) +} + +type Error struct { + Err error + Stack string + fatal bool +} + +func (e *Error) Error() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%v\n", e.Err)) + sb.WriteString(fmt.Sprintf("Stack Trace:\n%s", e.Stack)) + return sb.String() +} + +func (e *Error) Fatal() bool { + return e.fatal +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func NewError(err error) error { + if err == nil { + return nil + } + + // Check if it's already of our own + // Error type, so we don't add stack twice. + var asError *Error + if errors.As(err, &asError) { + return err + } + + // has the stack trace + var stack strings.Builder + buf := make([]uintptr, 50) + n := runtime.Callers(2, buf) + frames := runtime.CallersFrames(buf[:n]) + + // Format the stack trace + for { + frame, more := frames.Next() + // Skip runtime and standard library frames + if !strings.Contains(frame.File, "runtime/") { + stack.WriteString(fmt.Sprintf("\t%s:%d - %s\n", frame.File, frame.Line, frame.Function)) + } + if !more { + break + } + } + + return &Error{ + Err: err, + Stack: stack.String(), + } +} + +func NewFatalError(err error) error { + if err == nil { + return nil + } + + // Check if it's already of our own + // Error type. + var asError *Error + if errors.As(err, &asError) { + asError.fatal = true // Set fatal even for existing Error types + return err + } + err2 := NewError(err) + err2.(*Error).fatal = true + return err2 +} diff --git a/errors/errors_test.go b/errors/errors_test.go new file mode 100644 index 0000000..c3f2a71 --- /dev/null +++ b/errors/errors_test.go @@ -0,0 +1,184 @@ +package errors + +import ( + "errors" + "fmt" + "testing" +) + +type CustomError struct { + Err error +} + +func (e *CustomError) Error() string { return e.Err.Error() } + +func IsCustomError(err error) bool { + var asError *CustomError + return errors.As(err, &asError) +} + +func TestWrapping(t *testing.T) { + t.Parallel() + originalErr := errors.New("original error") + err1 := NewError(originalErr) + if !errors.Is(err1, originalErr) { + t.Errorf("original error is not wrapped") + } + if !Is(err1, originalErr) { + t.Errorf("original error is not wrapped") + } + unwrappedErr := errors.Unwrap(err1) + if !errors.Is(unwrappedErr, originalErr) { + t.Errorf("original error is not wrapped") + } + if !Is(unwrappedErr, originalErr) { + t.Errorf("original error is not wrapped") + } + unwrappedErr = Unwrap(err1) + if !errors.Is(unwrappedErr, originalErr) { + t.Errorf("original error is not wrapped") + } + if !Is(unwrappedErr, originalErr) { + t.Errorf("original error is not wrapped") + } + wrappedErr := fmt.Errorf("wrapped: %w", originalErr) + if !errors.Is(wrappedErr, originalErr) { + t.Errorf("original error is not wrapped") + } + if !Is(wrappedErr, originalErr) { + t.Errorf("original error is not wrapped") + } +} + +func TestNewError(t *testing.T) { + t.Parallel() + originalErr := &CustomError{errors.New("err1")} + if !IsCustomError(originalErr) { + t.Errorf("TestNewError fail #1") + } + err1 := NewError(originalErr) + if !IsCustomError(err1) { + t.Errorf("TestNewError fail #2") + } + wrappedErr1 := fmt.Errorf("wrapped %w", err1) + if !IsCustomError(wrappedErr1) { + t.Errorf("TestNewError fail #3") + } + unwrappedErr1 := Unwrap(wrappedErr1) + if !IsCustomError(unwrappedErr1) { + t.Errorf("TestNewError fail #4") + } +} + +func TestIsFatal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "simple non-fatal error", + err: fmt.Errorf("regular error"), + want: false, + }, + { + name: "direct fatal error", + err: NewFatalError(fmt.Errorf("fatal error")), + want: true, + }, + { + name: "non-fatal Error type", + err: NewError(fmt.Errorf("non-fatal error")), + want: false, + }, + { + name: "wrapped fatal error - one level", + err: fmt.Errorf("outer: %w", NewFatalError(fmt.Errorf("inner fatal"))), + want: true, + }, + { + name: "wrapped fatal error - two levels", + err: fmt.Errorf("outer: %w", + fmt.Errorf("middle: %w", + NewFatalError(fmt.Errorf("inner fatal")))), + want: true, + }, + { + name: "wrapped fatal error - three levels", + err: fmt.Errorf("outer: %w", + fmt.Errorf("middle1: %w", + fmt.Errorf("middle2: %w", + NewFatalError(fmt.Errorf("inner fatal"))))), + want: true, + }, + { + name: "multiple wrapped errors - non-fatal", + err: fmt.Errorf("outer: %w", + fmt.Errorf("middle: %w", + fmt.Errorf("inner: %w", + NewError(fmt.Errorf("base error"))))), + want: false, + }, + { + name: "wrapped non-fatal Error type", + err: fmt.Errorf("outer: %w", NewError(fmt.Errorf("inner"))), + want: false, + }, + { + name: "wrapped basic error", + err: fmt.Errorf("outer: %w", fmt.Errorf("inner")), + want: false, + }, + { + name: "fatal error wrapping fatal error", + err: NewFatalError(NewFatalError(fmt.Errorf("double fatal"))), + want: true, + }, + { + name: "fatal error wrapping non-fatal Error", + err: NewFatalError(NewError(fmt.Errorf("mixed"))), + want: true, + }, + { + name: "non-fatal Error wrapping fatal error", + err: NewError(NewFatalError(fmt.Errorf("mixed"))), + want: true, + }, + { + name: "Error wrapping Error", + err: NewError(NewError(fmt.Errorf("double wrapped"))), + want: false, + }, + { + name: "wrapped nil error", + err: fmt.Errorf("outer: %w", nil), + want: false, + }, + { + name: "fatal wrapping nil", + err: NewFatalError(nil), + want: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := IsFatal(tt.err) + if got != tt.want { + t.Errorf("IsFatal() = %v, want %v", got, tt.want) + if tt.err != nil { + t.Errorf("Error was: %v", tt.err) + } + } + }) + } +} diff --git a/gemini/errors.go b/gemini/errors.go new file mode 100644 index 0000000..2d60b3e --- /dev/null +++ b/gemini/errors.go @@ -0,0 +1,52 @@ +package gemini + +import ( + "fmt" + + "gemini-grc/errors" +) + +// GeminiError is used to represent +// Gemini network protocol errors only. +// Should be recorded to the snapshot. +// See https://geminiprotocol.net/docs/protocol-specification.gmi +type GeminiError struct { + Msg string + Code int + Header string +} + +func (e *GeminiError) Error() string { + return fmt.Sprintf("gemini error: code %d %s", e.Code, e.Msg) +} + +func NewGeminiError(code int, header string) error { + var msg string + switch { + case code >= 10 && code < 20: + msg = "needs input" + case code >= 30 && code < 40: + msg = "redirect" + case code >= 40 && code < 50: + msg = "bad request" + case code >= 50 && code < 60: + msg = "server error" + case code >= 60 && code < 70: + msg = "TLS error" + default: + msg = "unexpected Status code" + } + return &GeminiError{ + Msg: msg, + Code: code, + Header: header, + } +} + +func IsGeminiError(err error) bool { + if err == nil { + return false + } + var asError *GeminiError + return errors.As(err, &asError) +}