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 string) 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 } // --------------------------------------------------------------------------- // examples // --------------------------------------------------------------------------- // ExampleNewErrors highlights assumptions and best practices // for generating Errors structs. func Example_new() { // 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 Errors instance into it. ctrl = mockController{ errors: fault.New(false), } } // ExampleErrorsFail describes the assumptions and best practices // for setting the Failure error. func Example_errors_Fail() { errs := fault.New(false) // Fail() should be used to record any error that highlights a // non-recoverable failure in a process. // // 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. if err := connectClient(); err != nil { // normally, you'd want to // return errs.Fail(err) errs.Fail(err) } // Only the topmost handler of the error should set the Fail() err. // This will normally be the operation controller itself. // IE: Fail() is not Wrap(). In lower levels, errors should get // wrapped and returned like normal, and only handled by errors // 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) } } // ExampleErrorsAdd describes the assumptions and best practices // for aggregating iterable or recoverable errors. func Example_errors_Add() { errs := fault.New(false) // Add() should be used to record any error in a recoverable // part of processing. // // Add() should only get called in the last step in handling an // error within a loop or stream that does not otherwise return // an error. In all other cases, you should simply return an error // and expect the upstream point of iteration to call Add() for you. for _, i := range items { if err := getIthItem(i); err != nil { errs.Add(err) } } // In case of failFast behavior, iteration should exit as soon // as an error occurs. Errors does not expose the failFast flag // directly. Instead, iterators should check the value of Err(). // If it is non-nil, then the loop shold break. for _, i := range items { if errs.Err() != nil { break } errs.Add(getIthItem(i)) } // Only the topmost handler of the error should Add() the err. // This will normally be the iteration loop itself. // IE: Add() is not Wrap(). In lower levels, errors should get // wrapped and returned like normally, and only added to the // errors at the end. clientBasedGetter := func(s string) error { if err := dependencyCall(); err != nil { // wrap here, deeper into the stack return errors.Wrap(err, "dependency") } return nil } for _, i := range items { if err := clientBasedGetter(i); err != nil { // add here, within the iteraton loop errs.Add(err) } } } // ExampleErrorsErr describes retrieving the non-recoverable error. func Example_errors_Err() { errs := fault.New(false) errs.Fail(errors.New("catastrophe")) // Err() gets the primary failure error. 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. // // 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() } // ExampleErrorsErrs describes retrieving individual errors. func Example_errors_Errs() { errs := fault.New(false) errs.Add(errors.New("not catastrophic")) errs.Add(errors.New("something unwanted")) // Errs() gets the slice errors that were recorded, but were // considered recoverable. errSl := errs.Errs() for _, err := range errSl { fmt.Println(err) } // 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 an error if they // need upstream handlers to recognize failure states. // // 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 } // 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) } }