Skip to content

Commit

Permalink
AVM: report structure txn failure info (#5875)
Browse files Browse the repository at this point in the history
  • Loading branch information
jannotti committed Jan 17, 2024
1 parent 5e5c16a commit 434dca0
Show file tree
Hide file tree
Showing 18 changed files with 758 additions and 141 deletions.
24 changes: 6 additions & 18 deletions daemon/algod/api/client/restClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type HTTPError struct {
StatusCode int
Status string
ErrorString string
Data map[string]any
}

// Error formats an error string.
Expand Down Expand Up @@ -120,24 +121,11 @@ func extractError(resp *http.Response) error {
decodeErr := json.Unmarshal(errorBuf, &errorJSON)

var errorString string
var data map[string]any
if decodeErr == nil {
if errorJSON.Data == nil {
// There's no additional data, so let's just use the message
errorString = errorJSON.Message
} else {
// There's additional data, so let's re-encode the JSON response to show everything.
// We do this because the original response is likely encoded with escapeHTML=true, but
// since this isn't a webpage that extra encoding is not preferred.
var buffer strings.Builder
enc := json.NewEncoder(&buffer)
enc.SetEscapeHTML(false)
encErr := enc.Encode(errorJSON)
if encErr != nil {
// This really shouldn't happen, but if it does let's default to errorBuff
errorString = string(errorBuf)
} else {
errorString = buffer.String()
}
errorString = errorJSON.Message
if errorJSON.Data != nil {
data = *errorJSON.Data
}
} else {
errorString = string(errorBuf)
Expand All @@ -149,7 +137,7 @@ func extractError(resp *http.Response) error {
return unauthorizedRequestError{errorString, apiToken, resp.Request.URL.String()}
}

return HTTPError{StatusCode: resp.StatusCode, Status: resp.Status, ErrorString: errorString}
return HTTPError{StatusCode: resp.StatusCode, Status: resp.Status, ErrorString: errorString, Data: data}
}

// stripTransaction gets a transaction of the form "tx-XXXXXXXX" and truncates the "tx-" part, if it starts with "tx-"
Expand Down
8 changes: 7 additions & 1 deletion daemon/algod/api/server/v2/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package v2

import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
Expand All @@ -44,7 +45,12 @@ import (
// returnError logs an internal message while returning the encoded response.
func returnError(ctx echo.Context, code int, internal error, external string, logger logging.Logger) error {
logger.Info(internal)
return ctx.JSON(code, model.ErrorResponse{Message: external})
var data *map[string]any
var se *basics.SError
if errors.As(internal, &se) {
data = &se.Attrs
}
return ctx.JSON(code, model.ErrorResponse{Message: external, Data: data})
}

func badRequest(ctx echo.Context, internal error, external string, log logging.Logger) error {
Expand Down
132 changes: 132 additions & 0 deletions data/basics/serr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright (C) 2019-2024 Algorand, Inc.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// go-algorand is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with go-algorand. If not, see <https://www.gnu.org/licenses/>.

package basics

import (
"errors"
"strings"

"golang.org/x/exp/slog"
)

// SError is a structured error object. It contains a message and an arbitrary
// set of attributes. If the message contains "%A", it will be replaced by the
// attributes (in no guaranteed order), when SError() is called.
//
//msgp:ignore SError
type SError struct {
Msg string
Attrs map[string]any
Wrapped error
}

// New creates a new structured error object using the supplied message and
// attributes. If the message contains "%A", it will be replaced by the
// attributes when Error() is called.
func New(msg string, pairs ...any) *SError {
attrs := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
attrs[pairs[i].(string)] = pairs[i+1]
}
return &SError{Msg: msg, Attrs: attrs}
}

// Error returns either the exact supplied message, or the serialized attributes if
// the supplied message was blank, or substituted for %A.
func (e *SError) Error() string {
if e.Msg == "" {
return e.AttributesAsString()
}
// imperfect because we replace \%A as well
if strings.Contains(e.Msg, "%A") {
return strings.Replace(e.Msg, "%A", e.AttributesAsString(), -1)
}
return e.Msg
}

// AttributesAsString returns the attributes the same way that slog serializes
// attributes to text in a log message, in no guaranteed order.
func (e *SError) AttributesAsString() string {
var buf strings.Builder
args := make([]any, 0, 2*len(e.Attrs))
for key, val := range e.Attrs {
args = append(args, key)
args = append(args, val)
}
l := slog.New(slog.NewTextHandler(&buf, nil))
l.Info("", args...)
return strings.TrimSuffix(strings.SplitN(buf.String(), " ", 4)[3], "\n")
}

// Annotate adds additional attributes to an existing error, even if the error
// is deep in the error chain. If the supplied error is nil, nil is returned so
// that callers can annotate errors without checking if they are non-nil. If
// the error is not a structured error, it is wrapped in one using its existing
// message and the new attributes. Just like append() for slices, callers should
// re-assign, like this `err = serr.Annotate(err, "x", 100)`
func Annotate(err error, pairs ...any) error {
if err == nil {
return nil
}
var serr *SError
if ok := errors.As(err, &serr); ok {
for i := 0; i < len(pairs); i += 2 {
serr.Attrs[pairs[i].(string)] = pairs[i+1]
}
return err
}
// Since we don't have a structured error, we wrap the existing error in one.
serr = New(err.Error(), pairs...)
serr.Wrapped = err
return serr
}

// Wrap is used to "demote" an existing error to a field in a new structured
// error. The wrapped error message is added as $field-msg, and if the error is
// structured, the attributes are added under $field-attrs.
func Wrap(err error, msg string, field string, pairs ...any) error {
serr := New(msg, field+"-msg", err.Error())
for i := 0; i < len(pairs); i += 2 {
serr.Attrs[pairs[i].(string)] = pairs[i+1]
}
serr.Wrapped = err

var inner *SError
if ok := errors.As(err, &inner); ok {
attributes := make(map[string]any, len(inner.Attrs))
for key, val := range inner.Attrs {
attributes[key] = val
}
serr.Attrs[field+"-attrs"] = attributes
}

return serr
}

// Unwrap returns the inner error, if it exists.
func (e *SError) Unwrap() error {
return e.Wrapped
}

// Attributes returns the attributes of a structured error, or nil/empty.
func Attributes(err error) map[string]any {
var se *SError
if errors.As(err, &se) {
return se.Attrs
}
return nil
}
135 changes: 135 additions & 0 deletions data/basics/serr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (C) 2019-2024 Algorand, Inc.
// This file is part of go-algorand
//
// go-algorand is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// go-algorand is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with go-algorand. If not, see <https://www.gnu.org/licenses/>.

package basics

import (
"errors"
"fmt"
"testing"

"github.com/algorand/go-algorand/test/partitiontest"
"github.com/stretchr/testify/assert"
)

func TestNew(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

err := New("test")
assert.Equal(t, "test", err.Error())
}

func TestNewWithPairs(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

err := New("test", "a", 7, "b", []byte{3, 4})
assert.Equal(t, "test", err.Error())
assert.Equal(t, 7, err.Attrs["a"])
assert.Equal(t, []byte{3, 4}, err.Attrs["b"])

err.Msg = ""
assert.ErrorContains(t, err, `a=7`)
assert.ErrorContains(t, err, `b="\x03\x04"`)

err.Msg = "check it: %A"
assert.ErrorContains(t, err, ` a=7`)
assert.ErrorContains(t, err, ` b="\x03\x04"`)
assert.Equal(t, `check it: `, err.Error()[:10])

}

func TestAnnotate(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

err := New("test", "a", 7, "b", []byte{3, 4})
assert.Equal(t, 7, err.Attrs["a"])
assert.Equal(t, nil, err.Attrs["c"])
Annotate(err, "c", true, "a", false)
assert.Equal(t, true, err.Attrs["c"])
assert.Equal(t, false, err.Attrs["a"])
}

func attribute(err error, name string) any {
var serr *SError
if ok := errors.As(err, &serr); ok {
return serr.Attrs[name]
}
return nil
}

func TestAnnotateUnstructured(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

err := errors.New("hello")
err = Annotate(err, "c", true, "a", false)
assert.Equal(t, true, attribute(err, "c"))
}

func TestReannotateEmbedded(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

var err error
err = New("test", "a", 7, "b", []byte{3, 4})
err = fmt.Errorf("embed the above here %w", err)
assert.Equal(t, 7, attribute(err, "a"))
assert.Equal(t, nil, attribute(err, "c"))
Annotate(err, "c", true, "a", false)
assert.Equal(t, true, attribute(err, "c"))
assert.Equal(t, false, attribute(err, "a"))
// "b" is still visible. It would not be is we had _wrapped_ the fmt.Error
assert.Equal(t, []byte{3, 4}, attribute(err, "b"))
}

func TestWrapBare(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

var err error
err = errors.New("inner thingy")
err = Wrap(err, "outer stuff", "xxx")
assert.Equal(t, "inner thingy", attribute(err, "xxx-msg"))
assert.Equal(t, nil, attribute(err, "xxx-attrs"))
}

func TestWrapStructured(t *testing.T) {
partitiontest.PartitionTest(t)
t.Parallel()

var err error
err = New("test", "a", 7, "b", []byte{3, 4})
err = Wrap(err, "outer stuff", "yyy")
assert.Equal(t, "test", attribute(err, "yyy-msg"))
assert.NotNil(t, attribute(err, "yyy-attrs"))

// these are deeper now, not here
assert.Equal(t, nil, attribute(err, "a"))
assert.Equal(t, nil, attribute(err, "b"))

// here they are
attrs := attribute(err, "yyy-attrs").(map[string]any)
assert.Equal(t, 7, attrs["a"])
assert.Equal(t, []byte{3, 4}, attrs["b"])

// deeper, with a new attribute
err = Wrap(err, "further out", "again", "name", "jj")
assert.Nil(t, attribute(err, "yyy-msg"))
assert.Equal(t, "outer stuff", attribute(err, "again-msg"))
}

0 comments on commit 434dca0

Please sign in to comment.