Reorganize errors

This commit is contained in:
2025-02-26 10:32:38 +02:00
parent 9c7502b2a8
commit 8350e106d6
7 changed files with 419 additions and 131 deletions

View File

@@ -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))
}

41
common/errors/errors.go Normal file
View File

@@ -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")
)

View File

@@ -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")
}
}

View File

@@ -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")
}
}

104
errors/errors.go Normal file
View File

@@ -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
}

184
errors/errors_test.go Normal file
View File

@@ -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)
}
}
})
}
}

52
gemini/errors.go Normal file
View File

@@ -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)
}