-
Notifications
You must be signed in to change notification settings - Fork 115
Errors
To avoid burden of mapping errors returned from 3-rd party libraries you can gather all error mappings in one place and put an interceptor provided by atlas-app-toolkit package in a middleware chain as following:
interceptor := errros.UnaryServerInterceptor(
// List of mappings
// Base case: simply map error to an error container.
errors.NewMapping(fmt.Errorf("Some Error"), errors.NewContainer(/* ... */).WithDetail(/* ... */)),
)
This document is a brief overview of facilities provided by error handling package. The rationale for implementing it are four noble reasons:
- Provide ability to add specific details and field information to an error.
- Provide ability to handle multiple errors without returning control to a callee.
- Ability to map errors from 3-rd party libraries (gorm, to name one).
- Mapping from error to container should be performed automatically in gRPC interceptor.
Error mapper performs conditional mapping from one error message to another. Error mapping functions are passed to a gRPC Error interceptor and called against error returned from handler.
Currently there are two mappers available:
- ValidationErrors: error mapper and interceptor for protoc-gen-validate validation errors
- PQErrors: error mapper for go postgres driver
Error container is a data structure that implements Error interface and GRPCStatus method, enabling passing it around as a conventional error from one side and as a protobuf Status to gRPC gateway from the other side.
There are several approaches exist to work with it:
- Single error mode
- Multiple errors mode
This code snippet demonstrates the usage of error container as conventional error:
func validateNameLength(name string) error {
if len(name) > 255 {
return errors.NewContainer(
codes.InvalidArgument, "Name validation error."
).WithDetail(
codes.InvalidArgument, "object", "Invalid name length."
).WithField(
"name", "Specify name with length less than 255.")
}
return nil
}
func (svc *Service) validateName(name string) error {
err := errors.InitContainer()
if len(name) > 255 {
err = err.WithDetail(codes.InvalidArgument, "object", "Invalid name length.")
err = err.WithField("name", "Specify name with length less than 255.")
}
if strings.HasPrefix(name, "_") {
err = err.WithDetail(codes.InvalidArgument, "object", "Invalid name.").WithField(
"name", "Name cannot start with an underscore")
}
return err.IfSet(codes.InvalidArgument, "Invalid name.")
}
To gather multiple errors across several procedures use context functions:
func (svc *Service) globalValidate(ctx context.Context, input *pb.Input) error {
svc.validateName(ctx, input.Name)
svc.validateIP(ctx, input.IP)
// in some particular cases we expect that something really bad
// should happen, so we can analyse it and throw instead of validation errors.
if err := validateNamePermExternal(svc.AuthInfo); err != nil {
return errors.New(ctx, codes.Unauthorized, "Client is not authorized.").
WithDetails(/* ... */)
}
return errors.IfSet(ctx, codes.InvalidArgument, "Overall validation failed.")
// Alternatively if we want to return the latest errCode/errMessage set instead
// of overwriting it:
// return errors.Error(ctx)
}
func (svc *Service) validateName(ctx context.Context, name string) {
if len(name) > 255 {
errors.Detail(ctx, codes.InvalidArgument, "object", "Invalid name length.")
errors.Field(ctx, "name", "Specify name with length less than 255.")
}
}
func (svc *Service) validateIP(ctx context.Context, ip string) { /* ip validation */ }
Error mapper performs conditional mapping from one error message to another. Error mapping functions are passed to a gRPC Error interceptor and called against error returned from handler.
Below we demonstrate a cases and customization techniques for mapping functions:
interceptor := errors.UnaryServerInterceptor(
// List of mappings
// Base case: simply map error to an error container.
errors.NewMapping(fmt.Errorf("Some Error"), errors.NewContainer(/* ... */).WithDetail(/* ... */)),
// Extended Condition, mapped if error message contains "fk_contraint" or starts with "pg_sql:"
// MapFunc calls logger and returns Internal Error, depending on debug mode it could add details.
errors.NewMapping(
errors.CondOr(
errors.CondReMatch("fk_constraint"),
errors.CondHasPrefix("pg_sql:"),
),
errors.MapFunc(func (ctx context.Context, err error) (error, bool) {
logger.FromContext(ctx).Debug(fmt.Sprintf("DB Error: %v", err))
err := errors.NewContainer(codes.Internal, "database error")
if InDebugMode(ctx) {
err.WithDetail(/* ... */)
}
// boolean flag indicates whether the mapping succeeded
// it can be used to emulate fallthrough behavior (setting false)
return err, true
})
),
// Skip error
errors.NewMapping(fmt.Errorf("Error to Skip"), nil)
)
Such model allows us to define our own error classes and map them appropriately as in example below:
// service validation code.
type RequiredFieldErr string
(e RequiredFieldErr) Error() { return string(e) }
func RequiredFieldCond() errors.MapCond {
return errors.MapCond(func(err error) bool {
_, ok := err.(RequiredFieldErr)
return ok
})
}
func validateReqArgs(in *pb.Input) error {
if in.Name == "" {
return RequiredFieldErr("name")
}
return nil
}
// interceptor init code
interceptor := errors.UnaryServerInterceptor(
errors.NewMapping(
RequiredFieldCond(),
errors.MapFunc(func(ctx context.Context, err error) (error, bool) {
return errors.NewContainer(
codes.InvalidArgument, "Required field missing: %v", err
).WithField(string(err), "%q argument is required.", string(err)
), true
}),
)
)
import "github.com/infobloxopen/atlas-app-toolkit/errors/mappers/validationerrors"
validationerrors
is a request contents validator server-side middleware for
gRPC.
This middleware checks for the existence of a Validate
method on each of the
messages of a gRPC request. In case of a
validation failure, an InvalidArgument
gRPC status is returned, along with
the error that caused the validation failure.
It is intended to be used with plugins like https://github.com/lyft/protoc-gen-validate, Go protocol buffers codegen
plugins that create the Validate
methods (including nested messages) based on declarative options in the .proto
files themselves.
func UnaryServerInterceptor() grpc.UnaryServerInterceptor
UnaryServerInterceptor returns a new unary server interceptor that validates incoming messages and returns a ValidationError.
Invalid messages will be rejected with InvalidArgument
and the error before reaching any userspace handlers.
func DefaultMapping() errors.MapFunc
DefaultMapping returns a mapper that parses through the lyft protoc-gen-validate errors and only returns a user friendly error.
Example Usage:
-
Add validationerrors and errors interceptors to your application:
errors.UnaryServerInterceptor(ErrorMappings...), validationerrors.UnaryServerInterceptor(),
-
Create an ErrorMapping variable with all your mappings.
-
Add DefaultMapping as part of your ErrorMapping variable
var ErrorMappings = []errors.MapFunc{ // Adding Default Validations Mapping validationerrors.DefaultMapping(), }
Example return after DefaultMapping on a invalid email:
{ "error": { "status": 400, "code": "INVALID_ARGUMENT", "message": "Invalid primary_email: value must be a valid email address" }, "fields": { "primary_email": [ "value must be a valid email address" ] } }
-
You can also add custom validation mappings:
var ErrorMappings = []errors.MapFunc{ // Adding custom Validation Mapping based on the known field and reason from lyft errors.NewMapping( errors.CondAnd( validationerrors.CondValidation(), validationerrors.CondFieldEq("primary_email"), validationerrors.CondReasonEq("value must be a valid email address"), ), errors.MapFunc(func(ctx context.Context, err error) (error, bool) { vErr, _ := err.(validationerrors.ValidationError) return errors.NewContainer(codes.InvalidArgument, "Custom error message for field: %v reason: %v", vErr.Field, vErr.Reason), true }), ), }
For actual example usage look at: https://github.com/infobloxopen/atlas-contacts-app
import "github.com/infobloxopen/atlas-app-toolkit/errors/mappers/pqerrors"
pqerrors
is a error mapper for postgres.
Dedicated error mapper for go postgres driver (lib/pq.Error) package is included under the path of github.com/atlas-app-toolkit/errors/mappers/pqerrors. This package includes following components:
-
Condition function CondPQ, CondConstraintEq, CondCodeEq for conditions involved in *pq.Error detection, specific constraint name and specific status code of postgres error respectively.
-
ToMapFunc function that converts mapping function that deals with pq.Error to avoid burden of casting errors back and forth.
-
Default mapping function that can be included into errors interceptor for FK contraints (NewForeignKeyMapping), RESTRICT (NewRestrictMapping), NOT NULL (NewNotNullMapping), PK/UNIQUE (NewUniqueMapping)
Example Usage:
import (
...
"github.com/atlas-app-toolkit/errors/mappers/pqerrors"
)
interceptor := errros.UnaryServerInterceptor(
...
pqerrors.NewUniqueMapping("emails_address_key", "Contacts", "Primary Email Address"),
...
)
Any violation of UNIQUE constraint "email_address_key" will result in following error:
{
"error": {
"status": 409,
"code": "ALREADY_EXISTS",
"message": "There is already an existing 'Contacts' object with the same 'Primary Email Address'."
}
}