Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Access] Add draft of access api error framework #7106

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
154 changes: 154 additions & 0 deletions access/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package access

import (
"context"
"errors"
"fmt"

"github.com/onflow/flow-go/module/irrecoverable"
)

// RequireNoError returns nil if error is nil, otherwise throws an irrecoverable exception
func RequireNoError(ctx context.Context, err error) error {
if err == nil {
return nil
}

irrecoverable.Throw(ctx, err)
return irrecoverable.NewException(err)
}

// RequireErrorIs returns the error if it unwraps to any of the provided target error types
// Otherwise, it throws an irrecoverable exception
func RequireErrorIs(ctx context.Context, err error, targetErrs ...error) error {
if err == nil {
return nil
}

for _, targetErr := range targetErrs {
if errors.Is(err, targetErr) {
return err
}
}

irrecoverable.Throw(ctx, err)
return irrecoverable.NewException(err)
}

// InvalidRequest indicates that the client's request was malformed or invalid
type InvalidRequest struct {
err error
}

func NewInvalidRequest(err error) InvalidRequest {
return InvalidRequest{err: err}
}

func (e InvalidRequest) Error() string {
return fmt.Sprintf("invalid argument: %v", e.err)
}

func (e InvalidRequest) Unwrap() error {
return e.err
}

func IsInvalidRequest(err error) bool {
var errInvalidRequest InvalidRequest
return errors.As(err, &errInvalidRequest)
}

// DataNotFound indicates that the requested data was not found on the system
type DataNotFound struct {
dataType string
err error
}

func NewDataNotFound(dataType string, err error) DataNotFound {
return DataNotFound{dataType: dataType, err: err}
}

func (e DataNotFound) Error() string {
return fmt.Sprintf("data not found for %s: %v", e.dataType, e.err)
}

func (e DataNotFound) Unwrap() error {
return e.err
}

func IsDataNotFound(err error) bool {
var errDataNotFound DataNotFound
return errors.As(err, &errDataNotFound)
}

// InternalError indicates that a non-fatal internal error occurred
// IMPORTANT: this should only be used for benign internal errors. Fatal or irrecoverable system
// errors must be handled explicitly.
type InternalError struct {
err error
}

func NewInternalError(err error) InternalError {
return InternalError{err: err}
}

func (e InternalError) Error() string {
return fmt.Sprintf("internal error: %v", e.err)
}

func (e InternalError) Unwrap() error {
return e.err
}

func IsInternalError(err error) bool {
var errInternalError InternalError
return errors.As(err, &errInternalError)
}

// OutOfRangeError indicates that the request was for data that is outside of the available range.
// This is a more specific version of DataNotFound, where the data is known to eventually exist, but
// currently is not known.
// For example, querying data for a height above the current finalized height.
type OutOfRangeError struct {
err error
}

func NewOutOfRangeError(err error) OutOfRangeError {
return OutOfRangeError{err: err}
}
Comment on lines +146 to +148
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[optional] since you are mentioning it:

flow-go/access/errors.go

Lines 139 to 140 in df07206

// This is a more specific version of DataNotFoundError, where the data is known to eventually exist, but
// currently is not known.

we could also implement it accordingly

Suggested change
func NewOutOfRangeError(err error) OutOfRangeError {
return OutOfRangeError{err: err}
}
func NewOutOfRangeError(err error) error {
return NewDataNotFoundError("OutOfRange", OutOfRangeError{err: err})
}

similarly for the PreconditionFailedError below, which you are identifying as special sub-cases of InvalidRequestError


func (e OutOfRangeError) Error() string {
return fmt.Sprintf("out of range: %v", e.err)
}

func (e OutOfRangeError) Unwrap() error {
return e.err
}

func IsOutOfRangeError(err error) bool {
var errOutOfRangeError OutOfRangeError
return errors.As(err, &errOutOfRangeError)
}

// FailedPrecondition indicates that a precondition for the operation was not met
// This is a more specific version of InvalidRequest, where the request is valid, but the system
// is not currently in a state to fulfill the request (but may be in the future).
type FailedPrecondition struct {
err error
}

func NewFailedPrecondition(err error) FailedPrecondition {
return FailedPrecondition{err: err}
}

func (e FailedPrecondition) Error() string {
return fmt.Sprintf("precondition failed: %v", e.err)
}

func (e FailedPrecondition) Unwrap() error {
return e.err
}

func IsPreconditionFailed(err error) bool {
var errPreconditionFailed FailedPrecondition
return errors.As(err, &errPreconditionFailed)
}
92 changes: 92 additions & 0 deletions access/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package access_test

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/require"

"github.com/onflow/flow-go/access"
"github.com/onflow/flow-go/module/irrecoverable"
)

func TestRequireNoError(t *testing.T) {
t.Parallel()

t.Run("no error", func(t *testing.T) {
t.Parallel()

signalerCtx := irrecoverable.NewMockSignalerContext(t, context.Background())
ctx := irrecoverable.WithSignalerContext(context.Background(), signalerCtx)

err := access.RequireNoError(ctx, nil)
require.NoError(t, err)
})

t.Run("with error", func(t *testing.T) {
t.Parallel()

expectedErr := fmt.Errorf("expected error")

signalerCtx := irrecoverable.NewMockSignalerContextExpectError(t, context.Background(), expectedErr)
ctx := irrecoverable.WithSignalerContext(context.Background(), signalerCtx)

err := access.RequireNoError(ctx, expectedErr)
require.NotErrorIs(t, err, expectedErr, "expected error should be overridden and explicitly not wrapped")
require.Containsf(t, err.Error(), expectedErr.Error(), "expected returned error message to contain original error message")
})
}

func TestRequireErrorIs(t *testing.T) {
t.Parallel()

targetErr := fmt.Errorf("target error")

t.Run("no error", func(t *testing.T) {
t.Parallel()

signalerCtx := irrecoverable.NewMockSignalerContext(t, context.Background())
ctx := irrecoverable.WithSignalerContext(context.Background(), signalerCtx)

err := access.RequireErrorIs(ctx, nil, targetErr)
require.NoError(t, err)
})

t.Run("with expected error", func(t *testing.T) {
t.Parallel()

expectedErr := fmt.Errorf("got err: %w", targetErr)

signalerCtx := irrecoverable.NewMockSignalerContext(t, context.Background())
ctx := irrecoverable.WithSignalerContext(context.Background(), signalerCtx)

err := access.RequireErrorIs(ctx, expectedErr, targetErr)
require.ErrorIs(t, err, expectedErr)
})

t.Run("with multiple expected error", func(t *testing.T) {
t.Parallel()

expectedErr := fmt.Errorf("got err: %w", targetErr)

signalerCtx := irrecoverable.NewMockSignalerContext(t, context.Background())
ctx := irrecoverable.WithSignalerContext(context.Background(), signalerCtx)

err := access.RequireErrorIs(ctx, expectedErr, fmt.Errorf("target error2"), targetErr)
require.ErrorIs(t, err, expectedErr)
})

t.Run("with unexpected error", func(t *testing.T) {
t.Parallel()

expectedErr := fmt.Errorf("expected error")

signalerCtx := irrecoverable.NewMockSignalerContextExpectError(t, context.Background(), expectedErr)
ctx := irrecoverable.WithSignalerContext(context.Background(), signalerCtx)

err := access.RequireErrorIs(ctx, expectedErr, targetErr)
require.NotErrorIs(t, err, expectedErr, "expected error should be overridden and explicitly not wrapped")
require.Containsf(t, err.Error(), expectedErr.Error(), "expected returned error message to contain original error message")
})
}
42 changes: 41 additions & 1 deletion engine/access/rest/common/error.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package common

import "net/http"
import (
"context"
"errors"
"net/http"

"github.com/onflow/flow-go/access"
)

// StatusError provides custom error with http status.
type StatusError interface {
Expand Down Expand Up @@ -56,3 +62,37 @@ func (e *Error) Status() int {
func (e *Error) Error() string {
return e.err.Error()
}

// ErrorToStatusError converts an Access API error into a grpc status error. The input may either
// be a status.Error already, or an access sentinel error.
func ErrorToStatusError(err error) StatusError {
Copy link
Contributor Author

@peterargue peterargue Feb 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

each API has unique error reporting protocols (grpc vs rest vs websockets). Create API specific converters that return the appropriate mapping from access sentinels.

if err == nil {
return nil
}

var converted StatusError
if errors.As(err, &converted) {
return converted
}

switch {
case access.IsInvalidRequest(err):
return NewBadRequestError(err)
case access.IsDataNotFound(err):
return NewNotFoundError(err.Error(), err)
case access.IsPreconditionFailed(err):
return NewRestError(http.StatusPreconditionFailed, err.Error(), err)
case access.IsOutOfRangeError(err):
return NewNotFoundError(err.Error(), err)
case access.IsInternalError(err):
return NewRestError(http.StatusInternalServerError, err.Error(), err)
case errors.Is(err, context.Canceled):
return NewRestError(http.StatusRequestTimeout, "Request canceled", err)
case errors.Is(err, context.DeadlineExceeded):
return NewRestError(http.StatusRequestTimeout, "Request deadline exceeded", err)
default:
// TODO: ideally we would throw an exception in this case. For now, report it as Unknown so we
// can more easily identify any missed code paths and fix them while transitioning to this pattern.
return NewRestError(http.StatusInternalServerError, err.Error(), err)
}
}
4 changes: 2 additions & 2 deletions engine/access/rest/http/routes/execution_result.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func GetExecutionResultsByBlockIDs(r *common.Request, backend access.API, link c
for i, id := range req.BlockIDs {
res, err := backend.GetExecutionResultForBlockID(r.Context(), id)
if err != nil {
return nil, err
return nil, common.ErrorToStatusError(err)
}

var response commonmodels.ExecutionResult
Expand All @@ -44,7 +44,7 @@ func GetExecutionResultByID(r *common.Request, backend access.API, link commonmo

res, err := backend.GetExecutionResultByID(r.Context(), req.ID)
if err != nil {
return nil, err
return nil, common.ErrorToStatusError(err)
}

if res == nil {
Expand Down
2 changes: 1 addition & 1 deletion engine/access/rest/http/routes/node_version_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func GetNodeVersionInfo(r *common.Request, backend access.API, _ commonmodels.LinkGenerator) (interface{}, error) {
params, err := backend.GetNodeVersionInfo(r.Context())
if err != nil {
return nil, err
return nil, common.ErrorToStatusError(err)
}

var response models.NodeVersionInfo
Expand Down
Loading