├── .gitignore ├── LICENSE ├── README.md ├── errgroup.go ├── errgroup_test.go ├── go.mod └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | ## JetBrains.gitignore 18 | 19 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 20 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 21 | .idea 22 | # User-specific stuff 23 | .idea/**/workspace.xml 24 | .idea/**/tasks.xml 25 | .idea/**/usage.statistics.xml 26 | .idea/**/dictionaries 27 | .idea/**/shelf 28 | 29 | # Generated files 30 | .idea/**/contentModel.xml 31 | 32 | # Sensitive or high-churn files 33 | .idea/**/dataSources/ 34 | .idea/**/dataSources.ids 35 | .idea/**/dataSources.local.xml 36 | .idea/**/sqlDataSources.xml 37 | .idea/**/dynamic.xml 38 | .idea/**/uiDesigner.xml 39 | .idea/**/dbnavigator.xml 40 | 41 | # Gradle 42 | .idea/**/gradle.xml 43 | .idea/**/libraries 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | 71 | ## VisualStudioCode.gitignore 72 | 73 | .vscode/* 74 | !.vscode/settings.json 75 | !.vscode/tasks.json 76 | !.vscode/launch.json 77 | !.vscode/extensions.json 78 | 79 | # Android studio 3.1+ serialized cache file 80 | .idea/caches/build_file_checksums.ser 81 | .idea 82 | 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Steve Coffman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Actions Status](https://github.com/neilotoole/errgroup/workflows/Go/badge.svg)](https://github.com/neilotoole/errgroup/actions?query=workflow%3AGo) 2 | [![Go Report Card](https://goreportcard.com/badge/StevenACoffman/errgroup)](https://goreportcard.com/report/StevenACoffman/errgroup) 3 | [![GoDoc](https://godoc.org/github.com/golang/gddo?status.svg)](https://pkg.go.dev/github.com/StevenACoffman/errgroup) 4 | [![license](https://img.shields.io/github/license/StevenACoffman/errgroup)](./LICENSE) 5 | 6 | # StevenACoffman/errgroup 7 | `StevenACoffman/errgroup` is a drop-in alternative to Go's wonderful 8 | [`sync/errgroup`](https://pkg.go.dev/golang.org/x/sync/errgroup) but it converts goroutine panics to errors. 9 | 10 | While `net/http` installs a panic handler with each request-serving goroutine, 11 | goroutines **do not** and **cannot** inherit panic handlers from parent goroutines, 12 | so a `panic()` in one of the child goroutines will kill the whole program. 13 | 14 | So whenever you use an `sync.errgroup`, with some discipline, you can always remember to add a 15 | deferred `recover()` to every goroutine. This library just avoids that boilerplate and does that for you. 16 | 17 | You can [see it in use](https://play.golang.org/p/S8Gmr_sWZIi) 18 | 19 | ```go 20 | package main 21 | 22 | import ( 23 | "fmt" 24 | 25 | "github.com/StevenACoffman/errgroup" 26 | ) 27 | 28 | func main() { 29 | g := new(errgroup.Group) 30 | var urls = []string{ 31 | "http://www.golang.org/", 32 | "http://www.google.com/", 33 | "http://www.somestupidname.com/", 34 | } 35 | for i := range urls { 36 | // Launch a goroutine to fetch the URL. 37 | i := i // https://golang.org/doc/faq#closures_and_goroutines 38 | g.Go(func() error { 39 | 40 | // deliberate index out of bounds triggered 41 | fmt.Println("Fetching:", i, urls[i+1]) 42 | 43 | return nil 44 | }) 45 | } 46 | // Wait for all HTTP fetches to complete. 47 | err := g.Wait() 48 | if err == nil { 49 | fmt.Println("Successfully fetched all URLs.") 50 | } else { 51 | fmt.Println(err) 52 | } 53 | } 54 | ``` 55 | 56 | This work was done by my co-worker [Ben Kraft](https://github.com/benjaminjkraft), and, with his permission, I lightly modified it to 57 | lift it out of our repository for Go community discussion. 58 | 59 | ### Counterpoint 60 | There is [an interesting discussion](https://github.com/oklog/run/issues/10) which has an alternative view that, 61 | with few exceptions, panics **should** crash your program. 62 | 63 | ### Prior Art 64 | With only a cursory search, I found a few existing open source examples. 65 | 66 | #### [Kratos](https://github.com/go-kratos/kratos errgroup 67 | 68 | Kratos Go framework for microservices has a similar [errgroup](https://github.com/go-kratos/kratos/blob/master/pkg/sync/errgroup/errgroup.go) 69 | solution. 70 | 71 | #### PanicGroup by Sergey Alexandrovich 72 | 73 | In the article [Errors in Go: 74 | From denial to acceptance](https://evilmartians.com/chronicles/errors-in-go-from-denial-to-acceptance), 75 | (which advocates panic based flow control 😱), they have a PanicGroup that's roughly equivalent: 76 | 77 | ``` 78 | type PanicGroup struct { 79 | wg sync.WaitGroup 80 | errOnce sync.Once 81 | err error 82 | } 83 | 84 | func (g *PanicGroup) Wait() error { 85 | g.wg.Wait() 86 | return g.err 87 | } 88 | 89 | func (g *PanicGroup) Go(f func()) { 90 | g.wg.Add(1) 91 | 92 | go func() { 93 | defer g.wg.Done() 94 | defer func(){ 95 | if r := recover(); r != nil { 96 | if err, ok := r.(error); ok { 97 | // We need only the first error, sync.Once is useful here. 98 | g.errOnce.Do(func() { 99 | g.err = err 100 | }) 101 | } else { 102 | panic(r) 103 | } 104 | } 105 | }() 106 | 107 | f() 108 | }() 109 | } 110 | ``` 111 | -------------------------------------------------------------------------------- /errgroup.go: -------------------------------------------------------------------------------- 1 | // Package errgroup provides synchronization, error propagation, and Context 2 | // cancellation for groups of goroutines working on subtasks of a common task. 3 | // 4 | // It wraps, and exposes a similar API to, the upstream package 5 | // golang.org/x/sync/errgroup. Our version additionally recovers from panics, 6 | // converting them into errors. 7 | package errgroup 8 | 9 | import ( 10 | "context" 11 | "fmt" 12 | "runtime" 13 | "sync" 14 | ) 15 | 16 | 17 | // FromPanicValue takes a value recovered from a panic and converts it into an 18 | // error, for logging purposes. If the value is nil, it returns nil instead of 19 | // an error. 20 | // 21 | // Use like: 22 | // defer func() { 23 | // err := FromPanicValue(recover()) 24 | // // log or otheriwse use err 25 | // }() 26 | func FromPanicValue(i interface{}) error { 27 | switch value := i.(type) { 28 | case nil: 29 | return nil 30 | case string: 31 | return fmt.Errorf("panic: %v\n%s", value, CollectStack()) 32 | case error: 33 | return fmt.Errorf("panic in errgroup goroutine %w\n%s", value, CollectStack()) 34 | default: 35 | return fmt.Errorf("unknown panic: %+v\n%s", value, CollectStack()) 36 | } 37 | } 38 | 39 | func CollectStack() []byte { 40 | buf := make([]byte, 64<<10) 41 | buf = buf[:runtime.Stack(buf, false)] 42 | return buf 43 | } 44 | 45 | func catchPanics(f func() error) func() error { 46 | return func() (err error) { 47 | defer func() { 48 | // modified from log.PanicHandler, except instead of log.Panic we 49 | // set `err`, which is the named-return from our closure to 50 | // `g.Group.Go`, to an error based on the panic value. 51 | // We do not log here -- we are effectively returning the (panic) 52 | // error to our caller which suffices. 53 | if r := recover(); r != nil { 54 | err = FromPanicValue(r) 55 | } 56 | }() 57 | 58 | return f() 59 | } 60 | } 61 | 62 | // A Group is a collection of goroutines working on subtasks that are part of 63 | // the same overall task. 64 | // 65 | // A zero Group is valid and does not cancel on error. 66 | type Group struct { 67 | // Sadly, we have to copy the whole implementation, because: 68 | // - we want a zero errgroup to work, which means we'd need to embed the 69 | // upstream errgroup by value 70 | // - we can't copy an errgroup, which means we can't embed by value 71 | // (We could get around this with our own initialization-Once, but that 72 | // seems even more convoluted.) So we just copy -- it's not that much 73 | // code. The only change below is to add catchPanics(), in Go(). 74 | cancel func() 75 | wg sync.WaitGroup 76 | errOnce sync.Once 77 | err error 78 | } 79 | 80 | // WithContext returns a new Group and an associated Context derived from ctx. 81 | // 82 | // The derived Context is canceled the first time a function passed to Go 83 | // returns a non-nil error or panics, or the first time Wait returns, 84 | // whichever occurs first. 85 | func WithContext(ctx context.Context) (*Group, context.Context) { 86 | ctx, cancel := context.WithCancel(ctx) 87 | return &Group{cancel: cancel}, ctx 88 | } 89 | 90 | // Wait blocks until all function calls from the Go method have returned, then 91 | // returns the first non-nil error (if any) from them. 92 | func (g *Group) Wait() error { 93 | g.wg.Wait() 94 | if g.cancel != nil { 95 | g.cancel() 96 | } 97 | return g.err 98 | } 99 | 100 | // Go calls the given function in a new goroutine. 101 | // 102 | // The first call to return a non-nil error cancels the group; its error will 103 | // be returned by Wait. 104 | // 105 | // If the function panics, this is treated as if it returned an error. 106 | func (g *Group) Go(f func() error) { 107 | g.wg.Add(1) 108 | 109 | go func() { 110 | defer g.wg.Done() 111 | 112 | // here's the only change from upstream: this was 113 | // err := f(); ... 114 | if err := catchPanics(f)(); err != nil { 115 | g.errOnce.Do(func() { 116 | g.err = err 117 | if g.cancel != nil { 118 | g.cancel() 119 | } 120 | }) 121 | } 122 | }() 123 | } -------------------------------------------------------------------------------- /errgroup_test.go: -------------------------------------------------------------------------------- 1 | package errgroup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/suite" 9 | ) 10 | 11 | type errgroupSuite struct{ suite.Suite } 12 | 13 | func (suite *errgroupSuite) TestPanicWithString() { 14 | g, ctx := WithContext(context.Background()) 15 | g.Go(func() error { panic("oh noes") }) 16 | // this function ensures that the panic in fact cancels the context, by not 17 | // returning until it's been cancelled; it should return context.Canceled 18 | g.Go(func() error { 19 | <-ctx.Done() 20 | return ctx.Err() 21 | }) 22 | 23 | // Wait() will finish only once all goroutines do, but returns the first 24 | // error 25 | err := g.Wait() 26 | suite.Require().Error(err) 27 | suite.Require().Contains(err.Error(), "oh noes") 28 | 29 | // ctx should now be canceled. 30 | suite.Require().Error(ctx.Err()) 31 | } 32 | 33 | func (suite *errgroupSuite) TestPanicWithError() { 34 | g, ctx := WithContext(context.Background()) 35 | 36 | panicErr := errors.New("oh noes") 37 | g.Go(func() error { panic(panicErr) }) 38 | // this function ensures that the panic in fact cancels the context, by not 39 | // returning until it's been cancelled; it should return context.Canceled 40 | g.Go(func() error { 41 | <-ctx.Done() 42 | return ctx.Err() 43 | }) 44 | 45 | // Wait() will finish only once all goroutines do, but returns the first 46 | // error 47 | err := g.Wait() 48 | suite.Require().Error(err) 49 | suite.Require().Contains(err.Error(), "oh noes") 50 | suite.Require().True(errors.Is(err, panicErr)) 51 | 52 | // ctx should now be canceled. 53 | suite.Require().Error(ctx.Err()) 54 | } 55 | 56 | func (suite *errgroupSuite) TestPanicWithOtherValue() { 57 | g, ctx := WithContext(context.Background()) 58 | 59 | panicVal := struct { 60 | int 61 | string 62 | }{1234567890, "oh noes"} 63 | g.Go(func() error { panic(panicVal) }) 64 | // this function ensures that the panic in fact cancels the context, by not 65 | // returning until it's been cancelled; it should return context.Canceled 66 | g.Go(func() error { 67 | <-ctx.Done() 68 | return ctx.Err() 69 | }) 70 | 71 | // Wait() will finish only once all goroutines do, but returns the first 72 | // error 73 | err := g.Wait() 74 | suite.Require().Error(err) 75 | suite.Require().Contains(err.Error(), "oh noes") 76 | suite.Require().Contains(err.Error(), "1234567890") 77 | 78 | // ctx should now be canceled. 79 | suite.Require().Error(ctx.Err()) 80 | } 81 | 82 | func (suite *errgroupSuite) TestError() { 83 | g, ctx := WithContext(context.Background()) 84 | 85 | goroutineErr := errors.New("oh noes") 86 | g.Go(func() error { return goroutineErr }) 87 | // this function ensures that the panic in fact cancels the context, by not 88 | // returning until it's been cancelled; it should return context.Canceled 89 | g.Go(func() error { 90 | <-ctx.Done() 91 | return ctx.Err() 92 | }) 93 | 94 | // Wait() will finish only once all goroutines do, but returns the first 95 | // error 96 | err := g.Wait() 97 | suite.Require().Error(err) 98 | suite.Require().Contains(err.Error(), "oh noes") 99 | suite.Require().True(errors.Is(err, goroutineErr)) 100 | 101 | // ctx should now be canceled. 102 | suite.Require().Error(ctx.Err()) 103 | } 104 | 105 | func (suite *errgroupSuite) TestSuccess() { 106 | g, ctx := WithContext(context.Background()) 107 | 108 | g.Go(func() error { return nil }) 109 | // since no goroutine errored, ctx.Err() should be nil 110 | // (until all goroutines are done) 111 | g.Go(ctx.Err) 112 | 113 | err := g.Wait() 114 | suite.Require().NoError(err) 115 | 116 | // ctx should now still be canceled. 117 | suite.Require().Error(ctx.Err()) 118 | } 119 | 120 | func (suite *errgroupSuite) TestManyGoroutines() { 121 | n := 100 122 | g, ctx := WithContext(context.Background()) 123 | 124 | for i := 0; i < n; i++ { 125 | // put in a bunch of goroutines that just return right away 126 | g.Go(func() error { return nil }) 127 | // and also a bunch that wait for the error 128 | g.Go(func() error { 129 | <-ctx.Done() 130 | return ctx.Err() 131 | }) 132 | } 133 | 134 | // finally, put in a panic 135 | g.Go(func() error { panic("oh noes") }) 136 | 137 | // as before, Wait() will finish only once all goroutines do, but returns 138 | // the first error (namely the panic) 139 | err := g.Wait() 140 | suite.Require().Error(err) 141 | suite.Require().Contains(err.Error(), "oh noes") 142 | 143 | // ctx should now be canceled. 144 | suite.Require().Error(ctx.Err()) 145 | } 146 | 147 | func (suite *errgroupSuite) TestZeroGroupPanic() { 148 | var g Group 149 | 150 | // either of these could happen first, since a zero group does not cancel 151 | g.Go(func() error { panic("oh noes") }) 152 | g.Go(func() error { return nil }) 153 | 154 | // Wait() still returns the error. 155 | err := g.Wait() 156 | suite.Require().Error(err) 157 | suite.Require().Contains(err.Error(), "oh noes") 158 | } 159 | 160 | func (suite *errgroupSuite) TestZeroGroupSuccess() { 161 | var g Group 162 | 163 | g.Go(func() error { return nil }) 164 | g.Go(func() error { return nil }) 165 | 166 | err := g.Wait() 167 | suite.Require().NoError(err) 168 | } 169 | 170 | func TestErrgroup(t *testing.T) { 171 | suite.Run(t, new(errgroupSuite)) 172 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/StevenACoffman/errgroup 2 | 3 | go 1.14 4 | 5 | require github.com/stretchr/testify v1.7.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 6 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 7 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 9 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 10 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 11 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 12 | --------------------------------------------------------------------------------