Skip to content

Commit ca34c2b

Browse files
committed
Support wrapped errors
Wrapping errors instead of merely embedding error messages allows calling code to use `errors.Is` and `errors.As` to more precisely check the reasons for various errors instead of having to rely only on substring searches. We implement our own wrapper error to retain backward compatibility prior to Go 1.13, where there is no support for the "%w" format string in `fmt.Errorf`.
1 parent 2385abb commit ca34c2b

File tree

13 files changed

+147
-39
lines changed

13 files changed

+147
-39
lines changed

internal/run.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import (
99
"os/exec"
1010
"runtime"
1111
"strings"
12+
13+
"github.com/magefile/mage/mg"
1214
)
1315

1416
var debug *log.Logger = log.New(ioutil.Discard, "", 0)
@@ -52,7 +54,7 @@ func OutputDebug(cmd string, args ...string) (string, error) {
5254
if err := c.Run(); err != nil {
5355
errMsg := strings.TrimSpace(errbuf.String())
5456
debug.Print("error running '", cmd, strings.Join(args, " "), "': ", err, ": ", errMsg)
55-
return "", fmt.Errorf("error running \"%s %s\": %s\n%s", cmd, strings.Join(args, " "), err, errMsg)
57+
return "", mg.WrapError(err, fmt.Errorf("error running \"%s %s\": %v\n%s", cmd, strings.Join(args, " "), err, errMsg))
5658
}
5759
return strings.TrimSpace(buf.String()), nil
5860
}

mage/main.go

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -473,14 +473,14 @@ func listGoFiles(magePath, goCmd, tag string, envStr []string) ([]string, error)
473473
if !filepath.IsAbs(magePath) {
474474
cwd, err := os.Getwd()
475475
if err != nil {
476-
return nil, fmt.Errorf("can't get current working directory: %v", err)
476+
return nil, mg.WrapError(err, fmt.Errorf("can't get current working directory: %v", err))
477477
}
478478
magePath = filepath.Join(cwd, magePath)
479479
}
480480

481481
env, err := internal.SplitEnv(envStr)
482482
if err != nil {
483-
return nil, fmt.Errorf("error parsing environment variables: %v", err)
483+
return nil, mg.WrapError(err, fmt.Errorf("error parsing environment variables: %v", err))
484484
}
485485

486486
bctx := build.Default
@@ -502,7 +502,7 @@ func listGoFiles(magePath, goCmd, tag string, envStr []string) ([]string, error)
502502

503503
// Allow multiple packages in the same directory
504504
if _, ok := err.(*build.MultiplePackageError); !ok {
505-
return nil, fmt.Errorf("failed to parse go source files: %v", err)
505+
return nil, mg.WrapError(err, fmt.Errorf("failed to parse go source files: %v", err))
506506
}
507507
}
508508

@@ -530,7 +530,7 @@ func Magefiles(magePath, goos, goarch, goCmd string, stderr io.Writer, isMagefil
530530
debug.Println("getting all files including those with mage tag in", magePath)
531531
mageFiles, err := listGoFiles(magePath, goCmd, "mage", env)
532532
if err != nil {
533-
return nil, fmt.Errorf("listing mage files: %v", err)
533+
return nil, mg.WrapError(err, fmt.Errorf("listing mage files: %v", err))
534534
}
535535

536536
if isMagefilesDirectory {
@@ -546,7 +546,7 @@ func Magefiles(magePath, goos, goarch, goCmd string, stderr io.Writer, isMagefil
546546
debug.Println("getting all files without mage tag in", magePath)
547547
nonMageFiles, err := listGoFiles(magePath, goCmd, "", env)
548548
if err != nil {
549-
return nil, fmt.Errorf("listing non-mage files: %v", err)
549+
return nil, mg.WrapError(err, fmt.Errorf("listing non-mage files: %v", err))
550550
}
551551

552552
// convert non-Mage list to a map of files to exclude.
@@ -612,7 +612,7 @@ func GenerateMainfile(binaryName, path string, info *parse.PkgInfo) error {
612612

613613
f, err := os.Create(path)
614614
if err != nil {
615-
return fmt.Errorf("error creating generated mainfile: %v", err)
615+
return mg.WrapError(err, fmt.Errorf("error creating generated mainfile: %v", err))
616616
}
617617
defer f.Close()
618618
data := mainfileTemplateData{
@@ -629,16 +629,16 @@ func GenerateMainfile(binaryName, path string, info *parse.PkgInfo) error {
629629

630630
debug.Println("writing new file at", path)
631631
if err := mainfileTemplate.Execute(f, data); err != nil {
632-
return fmt.Errorf("can't execute mainfile template: %v", err)
632+
return mg.WrapError(err, fmt.Errorf("can't execute mainfile template: %v", err))
633633
}
634634
if err := f.Close(); err != nil {
635-
return fmt.Errorf("error closing generated mainfile: %v", err)
635+
return mg.WrapError(err, fmt.Errorf("error closing generated mainfile: %v", err))
636636
}
637637
// we set an old modtime on the generated mainfile so that the go tool
638638
// won't think it has changed more recently than the compiled binary.
639639
longAgo := time.Now().Add(-time.Hour * 24 * 365 * 10)
640640
if err := os.Chtimes(path, longAgo, longAgo); err != nil {
641-
return fmt.Errorf("error setting old modtime on generated mainfile: %v", err)
641+
return mg.WrapError(err, fmt.Errorf("error setting old modtime on generated mainfile: %v", err))
642642
}
643643
return nil
644644
}
@@ -675,13 +675,13 @@ func ExeName(goCmd, cacheDir string, files []string) (string, error) {
675675
func hashFile(fn string) (string, error) {
676676
f, err := os.Open(fn)
677677
if err != nil {
678-
return "", fmt.Errorf("can't open input file for hashing: %#v", err)
678+
return "", mg.WrapError(err, fmt.Errorf("can't open input file for hashing: %v", err))
679679
}
680680
defer f.Close()
681681

682682
h := sha1.New()
683683
if _, err := io.Copy(h, f); err != nil {
684-
return "", fmt.Errorf("can't write data to hash: %v", err)
684+
return "", mg.WrapError(err, fmt.Errorf("can't write data to hash: %v", err))
685685
}
686686
return fmt.Sprintf("%x", h.Sum(nil)), nil
687687
}
@@ -690,12 +690,12 @@ func generateInit(dir string) error {
690690
debug.Println("generating default magefile in", dir)
691691
f, err := os.Create(filepath.Join(dir, initFile))
692692
if err != nil {
693-
return fmt.Errorf("could not create mage template: %v", err)
693+
return mg.WrapError(err, fmt.Errorf("could not create mage template: %v", err))
694694
}
695695
defer f.Close()
696696

697697
if err := initOutput.Execute(f, nil); err != nil {
698-
return fmt.Errorf("can't execute magefile template: %v", err)
698+
return mg.WrapError(err, fmt.Errorf("can't execute magefile template: %v", err))
699699
}
700700

701701
return nil

mage/main_test.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"debug/macho"
77
"debug/pe"
88
"encoding/hex"
9+
"errors"
910
"flag"
1011
"fmt"
1112
"go/build"
@@ -1270,8 +1271,8 @@ func TestCompiledFlags(t *testing.T) {
12701271
cmd.Stderr = stderr
12711272
cmd.Stdout = stdout
12721273
if err := cmd.Run(); err != nil {
1273-
return fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1274-
filename, strings.Join(args, " "), err, stdout, stderr)
1274+
return mg.WrapError(err, fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1275+
filename, strings.Join(args, " "), err, stdout, stderr))
12751276
}
12761277
return nil
12771278
}
@@ -1315,6 +1316,10 @@ func TestCompiledFlags(t *testing.T) {
13151316
if err == nil {
13161317
t.Fatalf("expected an error because of timeout")
13171318
}
1319+
var exitError *exec.ExitError
1320+
if !errors.As(err, &exitError) {
1321+
t.Errorf("Expected wrapped ExitError from process; got %#v", err)
1322+
}
13181323
got = stderr.String()
13191324
want = "context deadline exceeded"
13201325
if strings.Contains(got, want) == false {
@@ -1357,8 +1362,8 @@ func TestCompiledEnvironmentVars(t *testing.T) {
13571362
cmd.Stderr = stderr
13581363
cmd.Stdout = stdout
13591364
if err := cmd.Run(); err != nil {
1360-
return fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1361-
filename, strings.Join(args, " "), err, stdout, stderr)
1365+
return mg.WrapError(err, fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1366+
filename, strings.Join(args, " "), err, stdout, stderr))
13621367
}
13631368
return nil
13641369
}
@@ -1511,8 +1516,8 @@ func TestSignals(t *testing.T) {
15111516
cmd.Stderr = stderr
15121517
cmd.Stdout = stdout
15131518
if err := cmd.Start(); err != nil {
1514-
return fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1515-
filename, target, err, stdout, stderr)
1519+
return mg.WrapError(err, fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1520+
filename, target, err, stdout, stderr))
15161521
}
15171522
pid := cmd.Process.Pid
15181523
go func() {
@@ -1523,8 +1528,8 @@ func TestSignals(t *testing.T) {
15231528
}
15241529
}()
15251530
if err := cmd.Wait(); err != nil {
1526-
return fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1527-
filename, target, err, stdout, stderr)
1531+
return mg.WrapError(err, fmt.Errorf("running '%s %s' failed with: %v\nstdout: %s\nstderr: %s",
1532+
filename, target, err, stdout, stderr))
15281533
}
15291534
return nil
15301535
}

magefile.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,20 @@ func Install() error {
4343
// in GOPATH environment string
4444
bin, err := sh.Output(gocmd, "env", "GOBIN")
4545
if err != nil {
46-
return fmt.Errorf("can't determine GOBIN: %v", err)
46+
return mg.WrapError(err, fmt.Errorf("can't determine GOBIN: %v", err))
4747
}
4848
if bin == "" {
4949
gopath, err := sh.Output(gocmd, "env", "GOPATH")
5050
if err != nil {
51-
return fmt.Errorf("can't determine GOPATH: %v", err)
51+
return mg.WrapError(err, fmt.Errorf("can't determine GOPATH: %v", err))
5252
}
5353
paths := strings.Split(gopath, string([]rune{os.PathListSeparator}))
5454
bin = filepath.Join(paths[0], "bin")
5555
}
5656
// specifically don't mkdirall, if you have an invalid gopath in the first
5757
// place, that's not on us to fix.
5858
if err := os.Mkdir(bin, 0700); err != nil && !os.IsExist(err) {
59-
return fmt.Errorf("failed to create %q: %v", bin, err)
59+
return mg.WrapError(err, fmt.Errorf("failed to create %q: %v", bin, err))
6060
}
6161
path := filepath.Join(bin, name)
6262

@@ -72,7 +72,7 @@ var releaseTag = regexp.MustCompile(`^v1\.[0-9]+\.[0-9]+$`)
7272
// Generates a new release. Expects a version tag in v1.x.x format.
7373
func Release(tag string) (err error) {
7474
if _, err := exec.LookPath("goreleaser"); err != nil {
75-
return fmt.Errorf("can't find goreleaser: %w", err)
75+
return mg.WrapError(err, fmt.Errorf("can't find goreleaser: %v", err))
7676
}
7777
if !releaseTag.MatchString(tag) {
7878
return errors.New("TAG environment variable must be in semver v1.x.x format, but was " + tag)

mg/errors.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,38 @@ func ExitStatus(err error) int {
4949
}
5050
return exit.ExitStatus()
5151
}
52+
53+
// wrappedError is an error that supports the Go 1.13+ mechanism of wrapping
54+
// errors. It implements Unwrap to return the underlying error, but it still
55+
// returns the string version of whatever "main" error it represents.
56+
type wrappedError struct {
57+
underlyingError error
58+
stringError error
59+
}
60+
61+
// WrapError returns an error that implements the Go 1.13 Unwrap interface. The
62+
// Error function returns the value of the "string" error, and the Unwrap
63+
// function returns the "underlying" error. Use this wherever one might
64+
// otherwise use the "%w" format string in [fmt.Errorf].
65+
// err := doSomething()
66+
// if err != nil {
67+
// return WrapError(err, fmt.Errorf("Could not do something: %v", err))
68+
// }
69+
// The premise is that the "string" error is not an interesting one to try to
70+
// inspect with [errors.Is] or [errors.As] because it has no other wrapped
71+
// errors of its own, and it will usually be of whatever error type
72+
// `fmt.Errorf` returns.
73+
func WrapError(underlying, str error) error {
74+
return &wrappedError{
75+
underlyingError: underlying,
76+
stringError: str,
77+
}
78+
}
79+
80+
func (e *wrappedError) Error() string {
81+
return e.stringError.Error()
82+
}
83+
84+
func (e *wrappedError) Unwrap() error {
85+
return e.underlyingError
86+
}

mg/errors113_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// The concept of "wrapping" errors was only introduced in Go 1.13, so we only
2+
// want to test that our errors behave like wrapped errors on Go versions that
3+
// support `errors.Is`.
4+
//go:build go1.13
5+
// +build go1.13
6+
7+
package mg
8+
9+
import (
10+
"errors"
11+
"testing"
12+
)
13+
14+
// TestErrorUnwrap checks that [errors.Is] can detect the underlying error in a
15+
// wrappedError.
16+
func TestErrorUnwrap(t *testing.T) {
17+
strError := errors.New("main error")
18+
underlyingError := errors.New("underlying error")
19+
actual := WrapError(underlyingError, strError)
20+
21+
if !errors.Is(actual, underlyingError) {
22+
t.Fatalf("Expected outer error %#v to include %#v", actual, underlyingError)
23+
}
24+
}

mg/errors_test.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package mg
22

3-
import "testing"
3+
import (
4+
"errors"
5+
"testing"
6+
)
47

58
func TestFatalExit(t *testing.T) {
69
expected := 99
@@ -17,3 +20,15 @@ func TestFatalfExit(t *testing.T) {
1720
t.Fatalf("Expected code %v but got %v", expected, code)
1821
}
1922
}
23+
24+
// TestBasicWrappedError confirms that a wrappedError returns the same string
25+
// as its "str" error (not its "underlying" error).
26+
func TestBasicWrappedError(t *testing.T) {
27+
strError := errors.New("main error")
28+
underlyingError := errors.New("underlying error")
29+
actual := WrapError(underlyingError, strError)
30+
31+
if actual.Error() != strError.Error() {
32+
t.Fatalf("Expected outer error to have Error() = %q but got %q", strError.Error(), actual.Error())
33+
}
34+
}

mg/fn.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func F(target interface{}, args ...interface{}) Fn {
3737
}
3838
id, err := json.Marshal(args)
3939
if err != nil {
40-
panic(fmt.Errorf("can't convert args into a mage-compatible id for mg.Deps: %s", err))
40+
panic(WrapError(err, fmt.Errorf("can't convert args into a mage-compatible id for mg.Deps: %v", err)))
4141
}
4242
return fn{
4343
name: funcName(target),

parse/parse.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/magefile/mage/internal"
18+
"github.com/magefile/mage/mg"
1819
)
1920

2021
const importTag = "mage:import"
@@ -735,7 +736,7 @@ func getPackage(path string, files []string, fset *token.FileSet) (*ast.Package,
735736

736737
pkgs, err := parser.ParseDir(fset, path, filter, parser.ParseComments)
737738
if err != nil {
738-
return nil, fmt.Errorf("failed to parse directory: %v", err)
739+
return nil, mg.WrapError(err, fmt.Errorf("failed to parse directory: %v", err))
739740
}
740741

741742
switch len(pkgs) {

sh/cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func Exec(env map[string]string, stdout, stderr io.Writer, cmd string, args ...s
120120
if ran {
121121
return ran, mg.Fatalf(code, `running "%s %s" failed with exit code %d`, cmd, strings.Join(args, " "), code)
122122
}
123-
return ran, fmt.Errorf(`failed to run "%s %s: %v"`, cmd, strings.Join(args, " "), err)
123+
return ran, mg.WrapError(err, fmt.Errorf(`failed to run "%s %s: %v"`, cmd, strings.Join(args, " "), err))
124124
}
125125

126126
func run(env map[string]string, stdout, stderr io.Writer, cmd string, args ...string) (ran bool, code int, err error) {

sh/cmd113_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// The concept of "wrapping" errors was only introduced in Go 1.13, so we only
2+
// want to test that our errors behave like wrapped errors on Go versions that
3+
// support `errors.Is`.
4+
//go:build go1.13
5+
// +build go1.13
6+
7+
package sh
8+
9+
import (
10+
"errors"
11+
"os"
12+
"testing"
13+
)
14+
15+
func TestWrappedError(t *testing.T) {
16+
_, err := Exec(nil, nil, nil, os.Args[0]+"-doesnotexist", "-printArgs", "foo")
17+
if err == nil {
18+
t.Fatalf("Expected to fail running %s", os.Args[0]+"-doesnotexist")
19+
}
20+
if !errors.Is(err, os.ErrNotExist) {
21+
t.Fatalf("Expected error to be like ErrNotExist, got %#v", err)
22+
}
23+
}

0 commit comments

Comments
 (0)