Realized we had a race condition: in an async runtime it's possible for an errs.Err() to be returned by multiple functions, even though that Err() was only sourced by one of them. The addition of a tracker contains the returned error into the scope of that func so that only the error produced in the current iteration is returned.
382 lines
11 KiB
Go
382 lines
11 KiB
Go
package fault_test
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/alcionai/corso/src/pkg/fault"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// mock helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var (
|
|
ctrl any
|
|
items = []string{}
|
|
)
|
|
|
|
type mockController struct {
|
|
errors any
|
|
}
|
|
|
|
func connectClient() error { return nil }
|
|
func dependencyCall() error { return nil }
|
|
func getIthItem(i int) error { return nil }
|
|
func getData() ([]string, error) { return nil, nil }
|
|
func storeData([]string, *fault.Errors) {}
|
|
|
|
type mockOper struct {
|
|
Errors *fault.Errors
|
|
}
|
|
|
|
func newOperation() mockOper { return mockOper{fault.New(true)} }
|
|
func (m mockOper) Run() *fault.Errors { return m.Errors }
|
|
|
|
type mockDepenedency struct{}
|
|
|
|
func (md mockDepenedency) do() error {
|
|
return errors.New("caught one")
|
|
}
|
|
|
|
var dependency = mockDepenedency{}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// examples
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ExampleNew highlights assumptions and best practices
|
|
// for generating fault.Errors structs.
|
|
func ExampleNew() {
|
|
// fault.Errors should only be generated during the construction of
|
|
// another controller, such as a new Backup or Restore Operations.
|
|
// Configurations like failFast are set during construction.
|
|
//
|
|
// Generating new fault.Errors structs outside of an operation
|
|
// controller is a smell, and should be avoided. If you need
|
|
// to aggregate errors, you should accept an interface and pass
|
|
// an fault.Errors instance into it.
|
|
ctrl = mockController{
|
|
errors: fault.New(false),
|
|
}
|
|
}
|
|
|
|
// ExampleErrors_Fail describes the assumptions and best practices
|
|
// for setting the Failure error.
|
|
func ExampleErrors_Fail() {
|
|
errs := fault.New(false)
|
|
|
|
// Fail() is used to record non-recoverable errors.
|
|
//
|
|
// Fail() should only get called in the last step before returning
|
|
// a fault.Errors from a controller. In all other cases, you
|
|
// should simply return an error and expect the upstream controller
|
|
// to call Fail() for you.
|
|
topLevelHandler := func(errs *fault.Errors) *fault.Errors {
|
|
if err := connectClient(); err != nil {
|
|
return errs.Fail(err)
|
|
}
|
|
|
|
return errs
|
|
}
|
|
if errs := topLevelHandler(errs); errs.Err() != nil {
|
|
fmt.Println(errs.Err())
|
|
}
|
|
|
|
// Only the topmost func in the stack should set the Fail() err.
|
|
// IE: Fail() is not Wrap(). In lower levels, errors should get
|
|
// wrapped and returned like normal, and only handled by fault
|
|
// at the end.
|
|
lowLevelCall := func() error {
|
|
if err := dependencyCall(); err != nil {
|
|
// wrap here, deeper into the stack
|
|
return errors.Wrap(err, "dependency")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
if err := lowLevelCall(); err != nil {
|
|
// fail here, at the top of the stack
|
|
errs.Fail(err)
|
|
}
|
|
}
|
|
|
|
// ExampleErrors_Add describes the assumptions and best practices
|
|
// for aggregating iterable or recoverable errors.
|
|
func ExampleErrors_Add() {
|
|
errs := fault.New(false)
|
|
|
|
// Add() is used to record any recoverable error.
|
|
//
|
|
// Add() should only get called as the last error handling step
|
|
// within a loop or stream. In all other cases, you can return
|
|
// an error like normal and expect the upstream point of iteration
|
|
// to call Add() for you.
|
|
for i := range items {
|
|
clientBasedGetter := func(i int) error {
|
|
if err := getIthItem(i); err != nil {
|
|
// lower level calls don't Add to the fault.Errors.
|
|
// they handl errors like normal.
|
|
return errors.Wrap(err, "dependency")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if err := clientBasedGetter(i); err != nil {
|
|
// Here at the top of the loop is the correct place
|
|
// to Add an error using fault.
|
|
errs.Add(err)
|
|
}
|
|
}
|
|
|
|
// Iteration should exit anytime the primary error in fault is
|
|
// non-nil. fault.Errors does not expose the failFast flag
|
|
// directly. Instead, errors from Add() will automatically
|
|
// promote to the Err() value. Therefore, loops only ned to
|
|
// check the errs.Err(). If it is non-nil, then the loop should break.
|
|
for i := range items {
|
|
if errs.Err() != nil {
|
|
// if failFast == true errs.Add() was called,
|
|
// we'll catch the error here.
|
|
break
|
|
}
|
|
|
|
if err := getIthItem(i); err != nil {
|
|
errs.Add(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ExampleErrors_Err describes retrieving the non-recoverable error.
|
|
func ExampleErrors_Err() {
|
|
errs := fault.New(false)
|
|
errs.Fail(errors.New("catastrophe"))
|
|
|
|
// Err() returns the primary failure.
|
|
err := errs.Err()
|
|
fmt.Println(err)
|
|
|
|
// if multiple Failures occur, each one after the first gets
|
|
// added to the Errs slice.
|
|
errs.Fail(errors.New("another catastrophe"))
|
|
errSl := errs.Errs()
|
|
|
|
for _, e := range errSl {
|
|
fmt.Println(e)
|
|
}
|
|
|
|
// If Err() is nil, then you can assume the operation completed.
|
|
// A complete operation is not necessarily an error-free operation.
|
|
// Recoverable errors may still have been added using Add(err).
|
|
//
|
|
// Even if Err() is nil, Errs() can be non-empty.
|
|
// Make sure you check both.
|
|
|
|
errs = fault.New(true)
|
|
|
|
// If failFast is set to true, then the first error Add()ed gets
|
|
// promoted to the Err() position.
|
|
|
|
errs.Add(errors.New("not catastrophic, but still becomes the Err()"))
|
|
err = errs.Err()
|
|
fmt.Println(err)
|
|
|
|
// Output: catastrophe
|
|
// another catastrophe
|
|
// not catastrophic, but still becomes the Err()
|
|
}
|
|
|
|
// ExampleErrors_Errs describes retrieving individual errors.
|
|
func ExampleErrors_Errs() {
|
|
errs := fault.New(false)
|
|
errs.Add(errors.New("not catastrophic"))
|
|
errs.Add(errors.New("something unwanted"))
|
|
|
|
// Errs() gets the slice of all recoverable errors Add()ed during
|
|
// the run, but which did not force the process to exit.
|
|
//
|
|
// Errs() only needs to be investigated by the end user at the
|
|
// conclusion of an operation. Checking Errs() within lower-
|
|
// layer code is a smell. Funcs should return a standard error,
|
|
// or errs.Err(), if they need upstream handlers to handle the errors.
|
|
errSl := errs.Errs()
|
|
for _, err := range errSl {
|
|
fmt.Println(err)
|
|
}
|
|
|
|
// One or more errors in errs.Errs() does not necessarily mean the
|
|
// process failed. You can have non-zero Errs() but a nil Err().
|
|
if errs.Err() == nil {
|
|
fmt.Println("Err() is nil")
|
|
}
|
|
|
|
// If Errs() is nil, then you can assume that no recoverable or
|
|
// iteration-based errors occurred. But that does not necessarily
|
|
// mean the operation was able to complete.
|
|
//
|
|
// Even if Errs() contains zero items, Err() can be non-nil.
|
|
// Make sure you check both.
|
|
|
|
// Output: not catastrophic
|
|
// something unwanted
|
|
// Err() is nil
|
|
}
|
|
|
|
func ExampleTracker() {
|
|
// It is common for Corso to run operations in parallel,
|
|
// and for iterations to be nested within iterations. To
|
|
// avoid mistakenly returning an error that was sourced
|
|
// from some other async iteration, recoverable instances
|
|
// are aggrgated into a Tracker.
|
|
errs := fault.New(false)
|
|
trkr := errs.Tracker()
|
|
|
|
err := func() error {
|
|
for i := range items {
|
|
// note that we check errs.Err() on every iteration,
|
|
// not trkr.Err(). The loop should break if any
|
|
// hard failure occurs, not just one within this loop.
|
|
if errs.Err() != nil {
|
|
break
|
|
}
|
|
|
|
if err := getIthItem(i); err != nil {
|
|
// instead of calling errs.Add(err), we call the
|
|
// trackers Add method. The error will still get
|
|
// added to the errs.Errs() set. But if this err
|
|
// causes the run to fail, only this tracker treats
|
|
// it as the causal failure.
|
|
trkr.Add(err)
|
|
}
|
|
}
|
|
|
|
return trkr.Err()
|
|
}()
|
|
if err != nil {
|
|
// handle the Err() that appeared in the tracker
|
|
fmt.Println("err occurred", errs.Err())
|
|
}
|
|
|
|
}
|
|
|
|
// ExampleErrorsE2e showcases a more complex integration.
|
|
func Example_errors_e2e() {
|
|
oper := newOperation()
|
|
|
|
// imagine that we're a user, calling into corso SDK.
|
|
// (fake funcs used here to minimize example bloat)
|
|
//
|
|
// The operation is our controller, we expect it to
|
|
// generate a new fault.Errors when constructed, and
|
|
// to return that struct when we call Run()
|
|
errs := oper.Run()
|
|
|
|
// Let's investigate what went on inside. Since we're at
|
|
// the top of our controller, and returning a fault.Errors,
|
|
// all the error handlers set the Fail() case.
|
|
/* Run() */
|
|
func() *fault.Errors {
|
|
if err := connectClient(); err != nil {
|
|
// Fail() here; we're top level in the controller
|
|
// and this is a non-recoverable issue
|
|
return oper.Errors.Fail(err)
|
|
}
|
|
|
|
data, err := getData()
|
|
if err != nil {
|
|
return oper.Errors.Fail(err)
|
|
}
|
|
|
|
// storeData will aggregate iterated errors into
|
|
// oper.Errors.
|
|
storeData(data, oper.Errors)
|
|
|
|
// return oper.Errors here, in part to ensure it's
|
|
// non-nil, and because we don't know if we've
|
|
// aggregated any iterated errors yet.
|
|
return oper.Errors
|
|
}()
|
|
|
|
// What about the lower level handling? storeData didn't
|
|
// return an error, so what's happening there?
|
|
/* storeData */
|
|
func(data []any, errs *fault.Errors) {
|
|
// this is downstream in our code somewhere
|
|
storer := func(a any) error {
|
|
if err := dependencyCall(); err != nil {
|
|
// we're not passing in or calling fault.Errors here,
|
|
// because this isn't the iteration handler, it's just
|
|
// a regular error.
|
|
return errors.Wrap(err, "dependency")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
for _, d := range data {
|
|
if errs.Err() != nil {
|
|
break
|
|
}
|
|
|
|
if err := storer(d); err != nil {
|
|
// Since we're at the top of the iteration, we need
|
|
// to add each error to the fault.Errors struct.
|
|
errs.Add(err)
|
|
}
|
|
}
|
|
}(nil, nil)
|
|
|
|
// then at the end of the oper.Run, we investigate the results.
|
|
if errs.Err() != nil {
|
|
// handle the primary error
|
|
fmt.Println("err occurred", errs.Err())
|
|
}
|
|
|
|
for _, err := range errs.Errs() {
|
|
// handle each recoverable error
|
|
fmt.Println("recoverable err occurred", err)
|
|
}
|
|
}
|
|
|
|
// ExampleErrors_Err_return showcases when to return err or nil vs errs.Err()
|
|
func ExampleErrors_Err_return() {
|
|
// The general rule of thumb is to always handle the error directly
|
|
// by returning err, or nil, or any variety of extension (wrap,
|
|
// stack, clues, etc).
|
|
fn := func() error {
|
|
if err := dependency.do(); err != nil {
|
|
return errors.Wrap(err, "direct")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
if err := fn(); err != nil {
|
|
fmt.Println(err)
|
|
}
|
|
|
|
// The exception is if you're handling recoverable errors. Those
|
|
// funcs should always return errs.Err(), in case a recoverable
|
|
// error happened on the last round of iteration.
|
|
fn2 := func(todo []string, errs *fault.Errors) error {
|
|
for range todo {
|
|
if errs.Err() != nil {
|
|
return errs.Err()
|
|
}
|
|
|
|
if err := dependency.do(); err != nil {
|
|
errs.Add(errors.Wrap(err, "recoverable"))
|
|
}
|
|
}
|
|
|
|
return errs.Err()
|
|
}
|
|
if err := fn2([]string{"a"}, fault.New(true)); err != nil {
|
|
fmt.Println(err)
|
|
}
|
|
|
|
// Output: direct: caught one
|
|
// recoverable: caught one
|
|
}
|