Skip to content
Snippets Groups Projects
Commit 0d8d9cec authored by Lyuben Penkovski's avatar Lyuben Penkovski
Browse files

Merge branch 'ci-pipelines' into 'main'

Structured errors package with CI pipelines

See merge request !1
parents 37f66eab 0cb73a66
Branches
Tags
1 merge request!1Structured errors package with CI pipelines
Pipeline #49010 passed
vendor/**/* -diff
stages:
- test
before_script:
- ln -s /builds /go/src/code.vereign.com
- cd /go/src/code.vereign.com/${CI_PROJECT_PATH}
unit tests:
image: golang:1.17.7
stage: test
tags:
- amd64-docker
script:
- go version
- go test -race -coverprofile=coverage.out ./...
- go tool cover -func=coverage.out
lint:
image: golangci/golangci-lint:v1.44.2
stage: test
tags:
- amd64-docker
script:
- golangci-lint --version
- golangci-lint run
before_script:
- ln -s /builds /go/src/code.vereign.com
- cd /go/src/code.vereign.com/${CI_PROJECT_PATH}
run:
deadline: 5m
skip-dirs:
- vendor/
- .*fakes/
- .*generated/
skip-files:
- .*generated.go
linters:
disable-all: true
enable:
- megacheck
- govet
- deadcode
- errcheck
- goconst
- gocyclo
- goimports
- revive
- gosec
- ineffassign
- nakedret
- staticcheck
- structcheck
- unconvert
- varcheck
- vet
- vetshadow
- misspell
- staticcheck
// Package errors defines structured errors which can
// be used for nesting errors with propagation
// of error identifiers and their messages.
// It also supports JSON serialization, so service to
// service communication can preserve error Kind.
package errors
import (
"bytes"
"encoding/json"
"net/http"
)
var separator = ": "
type Kind int
const (
Unknown Kind = iota // Unknown error.
BadRequest // BadRequest specifies invalid arguments or operation.
Unauthorized // Unauthorized request.
Forbidden // Forbidden operation.
Exist // Exist already.
NotFound // NotFound specifies that a resource does not exist.
Timeout // Timeout of request.
Internal // Internal error or inconsistency.
)
type Error struct {
// ID is a unique error identifier.
ID string
// Kind of error returned to the caller.
Kind Kind
// Message is a description of the error.
Message string
// The underlying error that triggered this one, if any.
Err error
}
func (k Kind) String() string {
switch k {
case Unknown:
return "unknown error"
case BadRequest:
return "bad request"
case Unauthorized:
return "not authenticated"
case Forbidden:
return "permission denied"
case Exist:
return "already exist"
case NotFound:
return "not found"
case Timeout:
return "timeout"
case Internal:
return "internal error"
}
return "unknown error kind"
}
// New builds an error value from its arguments.
// There must be at least one argument or New panics.
// The type of each argument determines its meaning.
// If more than one argument of a given type is presented, only the last one is
// recorded.
//
// The supported types are:
// errors.Kind:
// The kind of the error.
// *errors.Error
// The underlying error that triggered this one. If the error has
// non-empty ID and Kind fields, they are promoted as values of the
// returned one.
// error:
// The underlying error that triggered this one.
// string:
// Treated as an error message and assigned to the Message field.
func New(args ...interface{}) error {
if len(args) == 0 {
panic("call to errors.New without arguments")
}
e := &Error{}
var innerKind = Unknown
for _, arg := range args {
switch arg := arg.(type) {
case Kind:
e.Kind = arg
case *Error:
copy := *arg
e.Err = &copy
e.ID = copy.ID
innerKind = copy.Kind
if e.Message == "" {
e.Message = copy.Message
}
case error:
e.Err = arg
case string:
e.Message = arg
}
}
if e.ID == "" {
e.ID = NewID()
}
if e.Kind == Unknown {
e.Kind = innerKind
}
return e
}
// Is reports whether err is an *Error of the given Kind.
func Is(kind Kind, err error) bool {
cerr, ok := err.(*Error)
return ok && cerr.Kind == kind
}
// Error returns description of the error.
func (e *Error) Error() string {
if e == nil {
return "nil"
}
if e.ID == "" {
e.ID = NewID()
}
b := new(bytes.Buffer)
b.WriteString(e.Message)
if e.Kind != 0 {
pad(b, separator)
b.WriteString(e.Kind.String())
}
if e.Err != nil {
pad(b, separator)
if cerr, ok := e.Err.(*Error); ok {
b.WriteString(cerr.errorSkipID())
} else {
b.WriteString(e.Err.Error())
}
}
b.WriteRune(' ')
b.WriteRune('(')
b.WriteString(e.ID)
b.WriteRune(')')
return b.String()
}
func (e *Error) errorSkipID() string {
if e == nil {
return "nil"
}
b := new(bytes.Buffer)
b.WriteString(e.Message)
if e.Kind != 0 {
pad(b, separator)
b.WriteString(e.Kind.String())
}
if e.Err != nil {
pad(b, separator)
if cerr, ok := e.Err.(*Error); ok {
b.WriteString(cerr.errorSkipID())
} else {
b.WriteString(e.Err.Error())
}
}
return b.String()
}
// StatusCode returns the HTTP status code corresponding to the error.
func (e *Error) StatusCode() int {
switch e.Kind {
case BadRequest:
return http.StatusBadRequest
case Unauthorized:
return http.StatusUnauthorized
case Forbidden:
return http.StatusForbidden
case Exist:
return http.StatusConflict
case NotFound:
return http.StatusNotFound
case Timeout:
return http.StatusRequestTimeout
case Internal:
return http.StatusInternalServerError
default:
return http.StatusInternalServerError
}
}
// MarshalJSON returns the JSON representation of an Error.
func (e *Error) MarshalJSON() (data []byte, err error) {
var d = struct {
ID string `json:"id,omitempty"`
Kind Kind `json:"kind"`
Message string `json:"message,omitempty"`
}{
ID: e.ID,
Kind: e.Kind,
Message: e.Message,
}
return json.Marshal(d)
}
// UnmarshalJSON decodes a JSON encoded Error.
func (e *Error) UnmarshalJSON(data []byte) error {
var d struct {
ID string `json:"id,omitempty"`
Kind Kind `json:"kind"`
Message string `json:"message,omitempty"`
}
if err := json.Unmarshal(data, &d); err != nil {
return err
}
*e = Error{
ID: d.ID,
Kind: d.Kind,
Message: d.Message,
}
return nil
}
func JSON(w http.ResponseWriter, err error, statusCode ...int) {
var e error
var ok bool
if e, ok = err.(*Error); !ok {
e = New(err)
}
// check if the error can report its own status code
code := http.StatusInternalServerError
if sc, ok := e.(interface {
StatusCode() int
}); ok {
code = sc.StatusCode()
}
// overwrite the status code if it's explicitly passed as argument
if len(statusCode) > 0 {
code = statusCode[0]
}
w.WriteHeader(code)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(e)
}
// Temporary reports if an Error is temporary and
// whether the request can be retried.
func (e *Error) Temporary() bool {
return e != nil && (e.Kind == Internal || e.Kind == Timeout)
}
// GetKind returns error kind determined
// by the specified HTTP status code.
func GetKind(statusCode int) Kind {
switch statusCode {
case http.StatusBadRequest:
return BadRequest
case http.StatusUnauthorized:
return Unauthorized
case http.StatusForbidden:
return Forbidden
case http.StatusConflict:
return Exist
case http.StatusNotFound:
return NotFound
case http.StatusRequestTimeout:
return Timeout
case http.StatusInternalServerError:
return Internal
default:
return Unknown
}
}
// pad appends str to the buffer if the buffer already has some data.
func pad(b *bytes.Buffer, str string) {
if b.Len() == 0 {
return
}
b.WriteString(str)
}
package errors_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"code.vereign.com/gaiax/tsa/golib/errors"
)
func TestNew(t *testing.T) {
e := errors.New("something went wrong")
assert.Implements(t, (*error)(nil), e)
// create error with from a Kind
e = errors.New(errors.Forbidden)
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Forbidden, e))
assert.Contains(t, e.Error(), "permission denied")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Empty(t, ec.Message)
assert.Equal(t, errors.Forbidden, ec.Kind)
assert.Nil(t, ec.Err)
}
// create error with a string message only
e = errors.New("something went wrong")
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Unknown, e))
assert.Contains(t, e.Error(), "something went wrong")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, "something went wrong", ec.Message)
assert.Equal(t, errors.Unknown, ec.Kind)
assert.Nil(t, ec.Err)
}
// create error with Kind and Message
e = errors.New(errors.Internal, "something went wrong")
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Internal, e))
assert.Contains(t, e.Error(), "something went wrong: internal error")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, "something went wrong", ec.Message)
assert.Equal(t, errors.Internal, ec.Kind)
assert.Nil(t, ec.Err)
}
// create error from a previous error
e = errors.New(fmt.Errorf("oops it did it again"))
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Unknown, e))
assert.Contains(t, e.Error(), "oops it did it again")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, errors.Unknown, ec.Kind)
assert.NotNil(t, ec.Err)
assert.Equal(t, ec.Err.Error(), "oops it did it again")
}
// create error from a previous structured error
e = errors.New(errors.New(errors.Unauthorized, "no way out"))
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Unauthorized, e))
assert.Contains(t, e.Error(), "no way out: not authenticated")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, errors.Unauthorized, ec.Kind)
assert.Equal(t, ec.Message, "no way out")
assert.NotNil(t, ec.Err)
assert.Contains(t, ec.Err.Error(), "no way out: not authenticated")
}
// create error from a previous structured error
e = errors.New(errors.BadRequest, "bad request", errors.New(errors.Unauthorized, "no way out"))
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.BadRequest, e))
assert.Contains(t, e.Error(), "bad request: no way out: not authenticated")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, errors.BadRequest, ec.Kind)
assert.Equal(t, "bad request", ec.Message)
assert.NotNil(t, ec.Err)
assert.Contains(t, ec.Err.Error(), "no way out: not authenticated")
}
// create error from a previous structured error and a message
e = errors.New("bad request", errors.New(errors.Unauthorized, "no way out"))
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Unauthorized, e))
assert.Contains(t, e.Error(), "bad request: not authenticated: no way out: not authenticated")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, errors.Unauthorized, ec.Kind)
assert.Equal(t, "bad request", ec.Message)
assert.NotNil(t, ec.Err)
assert.Contains(t, ec.Err.Error(), "no way out: not authenticated")
}
// create error from a previous structured error and a message
e = errors.New(errors.BadRequest, errors.New(errors.Unauthorized, "no way out"))
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.BadRequest, e))
assert.Contains(t, e.Error(), "bad request: no way out: not authenticated")
if ec, ok := e.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, errors.BadRequest, ec.Kind)
assert.Equal(t, "no way out", ec.Message)
assert.NotNil(t, ec.Err)
assert.Contains(t, ec.Err.Error(), "no way out: not authenticated")
}
// create three nested errors
e1 := fmt.Errorf("cannot insert record")
e2 := errors.New("account already exists", e1)
e3 := errors.New("failed to create account", e2)
assert.Contains(t, e3.Error(), "failed to create account: account already exists: cannot insert record")
if ec, ok := e3.(*errors.Error); ok {
assert.NotEmpty(t, ec.ID)
assert.Equal(t, errors.Unknown, ec.Kind)
assert.Equal(t, "failed to create account", ec.Message)
assert.NotNil(t, ec.Err)
assert.Contains(t, ec.Err.Error(), "account already exists")
}
}
func TestIs(t *testing.T) {
e := errors.New(errors.Timeout)
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Timeout, e))
// create error from a previous structured error and a message
e = errors.New(errors.Timeout, errors.New(errors.Unauthorized))
assert.IsType(t, &errors.Error{}, e)
assert.True(t, errors.Is(errors.Timeout, e))
}
func TestError_StatusCode(t *testing.T) {
tests := []struct {
name string
kind errors.Kind
code int
}{
{
name: "unknown error",
kind: errors.Unknown,
code: http.StatusInternalServerError,
},
{
name: "bad request",
kind: errors.BadRequest,
code: http.StatusBadRequest,
},
{
name: "unauthorized",
kind: errors.Unauthorized,
code: http.StatusUnauthorized,
},
{
name: "forbidden",
kind: errors.Forbidden,
code: http.StatusForbidden,
},
{
name: "exists",
kind: errors.Exist,
code: http.StatusConflict,
},
{
name: "not found",
kind: errors.NotFound,
code: http.StatusNotFound,
},
{
name: "timeout",
kind: errors.Timeout,
code: http.StatusRequestTimeout,
},
{
name: "internal",
kind: errors.Internal,
code: http.StatusInternalServerError,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
e := errors.New(test.kind)
assert.IsType(t, &errors.Error{}, e)
if ec, ok := e.(*errors.Error); ok {
assert.Equal(t, test.code, ec.StatusCode())
}
})
}
}
func TestError_MarshalJSON(t *testing.T) {
e := errors.New(errors.NotFound, "item does not exist")
ec, ok := e.(*errors.Error)
assert.True(t, ok)
id := ec.ID
data, err := ec.MarshalJSON()
assert.NoError(t, err)
assert.NotEmpty(t, data)
ee := &errors.Error{}
err = ee.UnmarshalJSON(data)
assert.NoError(t, err)
assert.Equal(t, id, ee.ID)
assert.Equal(t, ec.Kind, ee.Kind)
assert.Equal(t, ec.Message, ee.Message)
assert.Equal(t, ec.Error(), ee.Error())
assert.Equal(t, errors.NotFound, ee.Kind)
}
func TestError_Temporary(t *testing.T) {
e := errors.New(errors.Forbidden)
ec, ok := e.(*errors.Error)
assert.True(t, ok)
assert.False(t, ec.Temporary())
e = errors.New(errors.Internal)
ec, ok = e.(*errors.Error)
assert.True(t, ok)
assert.True(t, ec.Temporary())
}
func TestGetKind(t *testing.T) {
tests := []struct {
name string
code int
kind errors.Kind
}{
{
name: "undefined HTTP status code",
code: 9999,
kind: errors.Unknown,
},
{
name: "bad request",
code: http.StatusBadRequest,
kind: errors.BadRequest,
},
{
name: "unauthorized",
code: http.StatusUnauthorized,
kind: errors.Unauthorized,
},
{
name: "forbidden",
code: http.StatusForbidden,
kind: errors.Forbidden,
},
{
name: "exists",
code: http.StatusConflict,
kind: errors.Exist,
},
{
name: "not found",
code: http.StatusNotFound,
kind: errors.NotFound,
},
{
name: "timeout",
code: http.StatusRequestTimeout,
kind: errors.Timeout,
},
{
name: "internal",
code: http.StatusInternalServerError,
kind: errors.Internal,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
kind := errors.GetKind(test.code)
assert.Equal(t, test.kind, kind)
})
}
}
func TestJSON(t *testing.T) {
tests := []struct {
// input
name string
err error
statusCode int
// output
responseCode int
responseError *errors.Error
}{
{
name: "error is nil",
err: nil,
responseCode: http.StatusInternalServerError,
responseError: &errors.Error{
Kind: errors.Unknown,
},
},
{
name: "simple text error",
err: fmt.Errorf("simple text error"),
responseCode: http.StatusInternalServerError,
responseError: &errors.Error{
Kind: errors.Unknown,
Message: "",
},
},
{
name: "structured error",
err: errors.New("structured error"),
responseCode: http.StatusInternalServerError,
responseError: &errors.Error{
Kind: errors.Unknown,
Message: "structured error",
},
},
{
name: "structured error with kind",
err: errors.New(errors.Forbidden, "structured error with kind"),
responseCode: http.StatusForbidden,
responseError: &errors.Error{
Kind: errors.Forbidden,
Message: "structured error with kind",
},
},
{
name: "structured error with kind and embedded error",
err: errors.New(errors.NotFound, "structured error with kind and embedded error", fmt.Errorf("embedded error")),
responseCode: http.StatusNotFound,
responseError: &errors.Error{
Kind: errors.NotFound,
Message: "structured error with kind and embedded error",
Err: fmt.Errorf("embedded error"),
},
},
{
name: "structured error with kind only",
err: errors.New(errors.Timeout),
responseCode: http.StatusRequestTimeout,
responseError: &errors.Error{
Kind: errors.Timeout,
Message: "",
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rr := httptest.NewRecorder()
if test.statusCode > 0 {
errors.JSON(rr, test.err, test.statusCode)
} else {
errors.JSON(rr, test.err)
}
assert.Equal(t, test.responseCode, rr.Code)
var responseError *errors.Error
err := json.NewDecoder(rr.Body).Decode(&responseError)
assert.NoError(t, err)
assert.Equal(t, test.responseError.Kind, responseError.Kind)
assert.Equal(t, test.responseError.Message, responseError.Message)
})
}
}
package errors
import (
"crypto/rand"
"fmt"
)
const Alphabet string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"
func NewID() string {
length := 16
ll := len(Alphabet)
b := make([]byte, length)
_, err := rand.Read(b) // generates len(b) random bytes
if err != nil {
panic(fmt.Errorf("failed to read random bytes: %v", err))
}
for i := 0; i < length; i++ {
b[i] = Alphabet[int(b[i])%ll]
}
return string(b)
}
package errors_test
import (
"testing"
"github.com/stretchr/testify/assert"
"code.vereign.com/gaiax/tsa/golib/errors"
)
func TestNewID(t *testing.T) {
for i := 0; i < 100; i++ {
id := errors.NewID()
assert.Len(t, id, 16)
for _, r := range id {
assert.Contains(t, errors.Alphabet, string(r))
}
}
}
go.mod 0 → 100644
module code.vereign.com/gaiax/tsa/golib
go 1.17
require github.com/stretchr/testify v1.7.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2 // indirect
)
go.sum 0 → 100644
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2 h1:VEmvx0P+GVTgkNu2EdTN988YCZPcD3lo9AoczZpucwc=
gopkg.in/yaml.v3 v3.0.0-20200601152816-913338de1bd2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment