Skip to content

Commit

Permalink
refactor(api,pkg): improve mongo error detection
Browse files Browse the repository at this point in the history
  • Loading branch information
henrybarreto authored and gustavosbarreto committed Sep 6, 2023
1 parent 352dc1f commit 91dc129
Show file tree
Hide file tree
Showing 4 changed files with 80 additions and 192 deletions.
95 changes: 42 additions & 53 deletions api/pkg/echo/handlers/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,10 @@ import (
routes "github.com/shellhub-io/shellhub/api/routes/errors"
"github.com/shellhub-io/shellhub/api/services"
"github.com/shellhub-io/shellhub/api/store"
"github.com/shellhub-io/shellhub/api/store/mongo"
"github.com/shellhub-io/shellhub/pkg/errors"
)

func isErrorUnknown(err error) (bool, error) {
unknown, ok := err.(errors.Error)
if !ok {
return true, err
}

return (unknown.Layer != services.ErrLayer && unknown.Layer != store.ErrLayer), unknown
}

func isLastErrorUnknown(err error) (bool, error) {
converted, ok := err.(errors.Error)
if !ok {
return true, nil
}

last := errors.GetLastError(converted)
if last == nil {
return true, nil
}

return isErrorUnknown(last)
}

func report(reporter *sentry.Client, err error, request *http.Request) {
go func() {
if reporter != nil {
Expand All @@ -52,42 +30,53 @@ func report(reporter *sentry.Client, err error, request *http.Request) {
}()
}

func maybeReport(reporter *sentry.Client, err error, request *http.Request) {
if ok, last := isLastErrorUnknown(err); ok {
report(reporter, last, request)
}
}

// NewErrors returns a custom echo's error handler.
//
// When the error is from errors.Error type, it will check for the layer and response with the appropriated HTTP status
// code. However, if the error is not from errors.Error type, it will respond with HTTP status code 500. When this error
// occurs, it will also try to send the error to Sentry.
func NewErrors(reporter *sentry.Client) func(error, echo.Context) {
return func(err error, ctx echo.Context) {
var status int
// NOTE(r): The early return approach here, despite it being a bit verbose, is the best way to clarify what
// happens in each case, avoiding the use of else statements, which would make the code more confusing or a big
// switch statement, which would make the code less readable.

if converted, ok := err.(errors.Error); ok {
switch converted.Layer {
case guard.ErrLayer:
status = http.StatusForbidden
case routes.ErrLayer:
status = converter.FromErrRouteToHTTPStatus(converted.Code)
case services.ErrLayer:
status = converter.FromErrServiceToHTTPStatus(converted.Code)
case store.ErrLayer:
status = http.StatusInternalServerError
default:
status = http.StatusInternalServerError
}
// Every Mongo error that isn't mapped as a store error must be reported to Sentry and responded with HTTP
// status code 500.
if errors.Is(err, mongo.ErrMongo) {
report(reporter, err, ctx.Request())
ctx.NoContent(http.StatusInternalServerError) //nolint:errcheck

maybeReport(reporter, converted, ctx.Request())
} else if herr, ok := err.(*echo.HTTPError); ok {
status = herr.Code
} else {
status = http.StatusInternalServerError
return
}

report(reporter, err, ctx.Request())
// On HTTP errors, anything related to the HTTP protocol, we just return the error code, avoiding a 500 error.
var herr *echo.HTTPError
if ok := errors.As(err, &herr); ok {
ctx.NoContent(herr.Code) //nolint:errcheck

return
}

// When the error is a custom error, we need to check its layer to return the correct HTTP status code according
// to the error's layer. Whether the error is not a custom error, we return a 500 error, because itn't from
// Mongo, not even from HTTP, indicating that is something unknown by the API
var e errors.Error
if ok := errors.As(err, &e); !ok {
ctx.NoContent(http.StatusInternalServerError) //nolint:errcheck

return
}

var status int

switch e.Layer {
case guard.ErrLayer:
status = http.StatusForbidden
case routes.ErrLayer:
status = converter.FromErrRouteToHTTPStatus(e.Code)
case services.ErrLayer:
status = converter.FromErrServiceToHTTPStatus(e.Code)
case store.ErrLayer:
// What happens when an error is returned directly from the store's layer, which means it doesn't have a
// service error affecting it, which requires fixing.
status = http.StatusInternalServerError
}

ctx.NoContent(status) //nolint:errcheck
Expand Down
14 changes: 13 additions & 1 deletion api/store/mongo/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"io"

"github.com/shellhub-io/shellhub/api/store"
"github.com/shellhub-io/shellhub/pkg/errors"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
Expand Down Expand Up @@ -34,6 +35,13 @@ func AggregateCount(ctx context.Context, coll *mongo.Collection, pipeline []bson
return resp.Count, nil
}

// ErrLayer is an error level. Each error defined at this level, is container to it.
// ErrLayer is the errors' level for mongo's error.
const ErrLayer = "mongo"

// ErrMongo is the error for any unknown mongo error.
var ErrMongo = errors.New("mongo error", ErrLayer, 1)

func FromMongoError(err error) error {
switch {
case err == mongo.ErrNoDocuments, err == io.EOF:
Expand All @@ -43,6 +51,10 @@ func FromMongoError(err error) error {
case mongo.IsDuplicateKeyError(err):
return store.ErrDuplicate
default:
return err
if err == nil {
return nil
}

return errors.Wrap(ErrMongo, err)
}
}
105 changes: 25 additions & 80 deletions pkg/errors/errors.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package errors

import (
"fmt"
"strings"
"errors"
)

// Data is a custom type to carry error's metadata.
// It can be any data type, but a struct is recommended due its fine control on JSON marshalling for each field.
type Data interface{}

// Error is a custom error that carry attributes to specify error's message, resource, layer, code and data.
// Error implements error and unwrap interfaces.
type Error struct {
// message is the error message.
Message string `json:"message"`
Expand All @@ -18,110 +18,55 @@ type Error struct {
Code int `json:"code,omitempty"`
// Data is the error metadata.
Data Data `json:"data,omitempty"`
// Next is the next error in the error's chain. next is nil when has no more error in the error's chain.
Next error `json:"next,omitempty"`
}

// New creates a new Error.
//
// An Error contains a message, message that will be showed when Error() method is called, a layer, where the error
// happened and a code, that should be unique in the layer.
// New creates a new [Error].
func New(message, layer string, code int) error {
return Error{
Message: message,
Layer: layer,
Code: code,
Data: nil,
Next: nil,
}
}

// WithData creates a new Error from other with data. If parent is not from Error type, just return the parameter.
// WithData insiert [Data] into parent is from type [Error].
func WithData(parent error, data Data) error {
if parent == nil {
return nil
}

if err, ok := parent.(Error); ok {
return Error{
Message: err.Message,
Layer: err.Layer,
Code: err.Code,
Data: data,
Next: err.Next,
}
}

return parent
}
err.Data = data

// Error returns a message aggregating all errors' messages in the chain.
func (e Error) Error() string {
message := e.Message

if e.Next != nil {
// Recursively, get and join all messages in the chain.
message = strings.Join([]string{message, e.Next.Error()}, ": ")
return err
}

return message
return nil
}

// Unwrap returns the next error in the error's chain. If there is no next error, returns nil.
func (e Error) Unwrap() error {
return e.Next
func (e Error) Error() string {
return e.Message
}

// Wrap adds an Error to the error's chain. If err is nil, return nil. If next is nil, return err.
// Wrap wraps an error with another error.
//
// It is a interface for [errors.Join]. Check [errors.Join] for more information.
func Wrap(err error, next error) error {
if err == nil {
return nil
}

if next == nil {
return err
}

e, ok := err.(Error)
if !ok {
return fmt.Errorf("%s: %w", err.Error(), next)
}

err = nil //nolint:wastedassign
n, ok := next.(Error)
if !ok {
err = Error{Message: next.Error()}
} else {
err = n
}

return Error{
Message: e.Message,
Layer: e.Layer,
Code: e.Code,
Data: e.Data,
Next: err,
}
return errors.Join(err, next)
}

// GetLastError returns the last error in the error's chain. If there is no next error, returns nil.
func GetLastError(err error) error {
for {
if err == nil {
break
}

next, ok := err.(Error)
if !ok {
break
}

if next.Next == nil {
break
}
// Unwrap returns the next error from the error tree.
func Unwrap(err error) error {
return errors.Unwrap(err)
}

err = next.Next
}
// As wraps [errors.As]. Check [errors.As] for more information.
func As(err error, target interface{}) bool {
return errors.As(err, target)
}

return err
// Is wraps [errors.Is]. Check [errors.Is] for more information.
func Is(err, target error) bool {
return errors.Is(err, target)
}
58 changes: 0 additions & 58 deletions pkg/errors/errors_test.go

This file was deleted.

0 comments on commit 91dc129

Please sign in to comment.