Skip to content

Commit 9177bbf

Browse files
authored
Merge pull request #217 from cschleiden/error-handling2
Improve error handling
2 parents 434001f + eae6d2f commit 9177bbf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+1034
-168
lines changed

README.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ func Workflow2(ctx workflow.Context, msg string) (string, error) {
314314
From a workflow, call `workflow.ExecuteActivity` to execute an activity. The call returns a `Future[T]` you can await to get the result or any error it might return.
315315

316316
```go
317-
r1, err := workflow.ExecuteActivity[int](ctx, Activity1, 35, 12, nil, "test").Get(ctx)
317+
r1, err := workflow.ExecuteActivity[int](ctx, workflow.DefaultActivityOptions, Activity1, 35, 12, nil, "test").Get(ctx)
318318
if err != nil {
319319
panic("error getting activity 1 result")
320320
}
@@ -423,9 +423,86 @@ func SubWorkflow(ctx workflow.Context, msg string) (int, error) {
423423

424424
Similar to timer cancellation, you can pass a cancelable context to `CreateSubWorkflowInstance` and cancel the sub-workflow that way. Reacting to the cancellation is the same as canceling a workflow via the `Client`. See [Canceling workflows](#canceling-workflows) for more details.
425425

426+
### Error handling
427+
428+
#### Custom errors
429+
430+
Errors returned from activities and subworkflows need to be marshalled/unmarshalled by the library so they are wrapped in a `workflow.Error`. You can access the original type via the `err.Type` field. If a stacktrace was captured, you can access it via `err.Stack()`. Example (see also `samples/errors`):
431+
432+
```go
433+
func handleError(ctx workflow.Context, logger log.Logger, err error) {
434+
var werr *workflow.Error
435+
if errors.As(err, &werr) {
436+
switch werr.Type {
437+
case "CustomError": // This was a `type CustomError struct...` returned by an activity/subworkflow
438+
logger.Error("Custom error", "err", werr)
439+
return
440+
}
441+
442+
logger.Error("Generic workflow error", "err", werr, "stack", werr.Stack())
443+
return
444+
}
445+
446+
var perr *workflow.PanicError
447+
if errors.As(err, &perr) {
448+
// Activity/subworkflow ran into a panic
449+
logger.Error("Panic", "err", perr, "stack", perr.Stack())
450+
return
451+
}
452+
453+
logger.Error("Generic error", "err", err)
454+
}
455+
```
456+
457+
#### Panics
458+
459+
A panic in an activity will be captured by the library and made available as a `workflow.PanicError` in the calling workflow. Example:
460+
461+
462+
```go
463+
r1, err := workflow.ExecuteActivity[int](ctx, workflow.DefaultActivityOptions, Activity1, "test").Get(ctx)
464+
if err != nil {
465+
panic("error getting activity 1 result")
466+
}
467+
468+
var perr *workflow.PanicError
469+
if errors.As(err, &perr) {
470+
logger.Error("Panic", "err", perr, "stack", perr.Stack())
471+
return
472+
}
473+
```
474+
475+
#### Retries
476+
477+
With the default `DefaultActivityOptions`, Activities are retried up to three times when they return an error. If you want to keep automatic retries, but want to avoid them when hitting certain error types, you can wrap an error with `workflow.NewPermanentError`:
478+
479+
**Workflow**:
480+
481+
```go
482+
r1, err := workflow.ExecuteActivity[int](ctx, workflow.DefaultActivityOptions, Activity1, "test").Get(ctx)
483+
if err != nil {
484+
panic("error getting activity 1 result")
485+
}
486+
487+
log.Println(r1)
488+
```
489+
490+
**Activity**:
491+
492+
```go
493+
func Activity1(ctx context.Context, name string) (int, error) {
494+
if name == "test" {
495+
// No need to retry in this case, the activity will aways fail with the given inputs
496+
return 0, workflow.NewPermanentError(errors.New("test is not a valid name"))
497+
}
498+
499+
return http.Do("POST", "https://example.com", name)
500+
}
501+
```
502+
426503
### `ContinueAsNew`
427504

428-
```ContinueAsNew` allows you to restart workflow execution with different inputs. The purpose is to keep the history size small enough to avoid hitting size limits, running out of memory and impacting performance. It works by returning a special `error` from your workflow that contains the new inputs:
505+
`ContinueAsNew` allows you to restart workflow execution with different inputs. The purpose is to keep the history size small enough to avoid hitting size limits, running out of memory and impacting performance. It works by returning a special `error` from your workflow that contains the new inputs:
429506

430507
```go
431508
wf := func(ctx workflow.Context, run int) (int, error) {

backend/test/e2e.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ import (
1919
"github.com/stretchr/testify/require"
2020
)
2121

22+
type backendTest struct {
23+
name string
24+
options []backend.BackendOption
25+
withoutCache bool // If set, test will only be run when the cache is disabled
26+
f func(t *testing.T, ctx context.Context, c client.Client, w worker.Worker, b TestBackend)
27+
}
28+
2229
func EndToEndBackendTest(t *testing.T, setup func(options ...backend.BackendOption) TestBackend, teardown func(b TestBackend)) {
23-
tests := []struct {
24-
name string
25-
options []backend.BackendOption
26-
withoutCache bool // If set, test will only be run when the cache is disabled
27-
f func(t *testing.T, ctx context.Context, c client.Client, w worker.Worker, b TestBackend)
28-
}{
30+
tests := []backendTest{
2931
{
3032
name: "SimpleWorkflow",
3133
f: func(t *testing.T, ctx context.Context, c client.Client, w worker.Worker, b TestBackend) {
@@ -659,6 +661,8 @@ func EndToEndBackendTest(t *testing.T, setup func(options ...backend.BackendOpti
659661
},
660662
}
661663

664+
tests = append(tests, e2eActivityTests...)
665+
662666
run := func(suffix string, workerOptions *worker.Options) {
663667
for _, tt := range tests {
664668
if tt.withoutCache && workerOptions.WorkflowExecutorCache != nil {

backend/test/e2e_activity.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/cschleiden/go-workflows/client"
9+
"github.com/cschleiden/go-workflows/worker"
10+
"github.com/cschleiden/go-workflows/workflow"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
type CustomError struct {
15+
msg string
16+
}
17+
18+
func (e *CustomError) Error() string {
19+
return e.msg
20+
}
21+
22+
var e2eActivityTests = []backendTest{
23+
{
24+
name: "Activity_Panic",
25+
f: func(t *testing.T, ctx context.Context, c client.Client, w worker.Worker, b TestBackend) {
26+
a := func(context.Context) error {
27+
panic("activity panic")
28+
}
29+
30+
wf := func(ctx workflow.Context) (bool, error) {
31+
_, err := workflow.ExecuteActivity[int](ctx, workflow.ActivityOptions{
32+
RetryOptions: workflow.RetryOptions{
33+
MaxAttempts: 1,
34+
},
35+
}, a).Get(ctx)
36+
37+
var perr *workflow.PanicError
38+
return errors.As(err, &perr), nil
39+
}
40+
register(t, ctx, w, []interface{}{wf}, []interface{}{a})
41+
42+
output, err := runWorkflowWithResult[bool](t, ctx, c, wf)
43+
44+
require.True(t, output, "error should be PanicError")
45+
require.NoError(t, err)
46+
},
47+
},
48+
{
49+
name: "Activity_CustomError",
50+
f: func(t *testing.T, ctx context.Context, c client.Client, w worker.Worker, b TestBackend) {
51+
a := func(context.Context) error {
52+
return &CustomError{msg: "custom error"}
53+
}
54+
55+
wf := func(ctx workflow.Context) (bool, error) {
56+
_, err := workflow.ExecuteActivity[int](ctx, workflow.ActivityOptions{
57+
RetryOptions: workflow.RetryOptions{
58+
MaxAttempts: 1,
59+
},
60+
}, a).Get(ctx)
61+
62+
var werr *workflow.Error
63+
if errors.As(err, &werr) {
64+
return werr.Type == "CustomError" && werr.Error() == "custom error", nil
65+
}
66+
67+
return false, nil
68+
}
69+
register(t, ctx, w, []interface{}{wf}, []interface{}{a})
70+
71+
output, err := runWorkflowWithResult[bool](t, ctx, c, wf)
72+
73+
require.True(t, output, "error should be PanicError")
74+
require.NoError(t, err)
75+
},
76+
},
77+
}

client/client.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/cschleiden/go-workflows/internal/fn"
1515
"github.com/cschleiden/go-workflows/internal/history"
1616
"github.com/cschleiden/go-workflows/internal/metrickeys"
17+
"github.com/cschleiden/go-workflows/internal/workflowerrors"
1718
"github.com/cschleiden/go-workflows/log"
1819
"github.com/cschleiden/go-workflows/metrics"
1920
"github.com/cschleiden/go-workflows/workflow"
@@ -195,7 +196,7 @@ func GetWorkflowResult[T any](ctx context.Context, c Client, instance *workflow.
195196
return *new(T), fmt.Errorf("workflow did not finish in time: %w", err)
196197
}
197198

198-
h, err := b.GetWorkflowInstanceHistory(ctx, instance, nil)
199+
h, err := b.GetWorkflowInstanceHistory(ctx, instance, nil) // future: could optimize this by retriving only the very last entry in the history
199200
if err != nil {
200201
return *new(T), fmt.Errorf("getting workflow history: %w", err)
201202
}
@@ -206,8 +207,8 @@ func GetWorkflowResult[T any](ctx context.Context, c Client, instance *workflow.
206207
switch event.Type {
207208
case history.EventType_WorkflowExecutionFinished:
208209
a := event.Attributes.(*history.ExecutionCompletedAttributes)
209-
if a.Error != "" {
210-
return *new(T), errors.New(a.Error)
210+
if a.Error != nil {
211+
return *new(T), workflowerrors.ToError(a.Error)
211212
}
212213

213214
var r T

client/client_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func Test_Client_GetWorkflowResultSuccess(t *testing.T) {
8080
history.NewHistoryEvent(1, time.Now(), history.EventType_WorkflowExecutionStarted, &history.ExecutionStartedAttributes{}),
8181
history.NewHistoryEvent(2, time.Now(), history.EventType_WorkflowExecutionFinished, &history.ExecutionCompletedAttributes{
8282
Result: r,
83-
Error: "",
83+
Error: nil,
8484
}),
8585
}, nil)
8686
b.On("Converter").Return(converter.DefaultConverter)

diag/app/build/asset-manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
{
22
"files": {
33
"main.css": "./static/css/main.8b0db0ad.css",
4-
"main.js": "./static/js/main.cf32ac93.js",
4+
"main.js": "./static/js/main.b236aa1e.js",
55
"index.html": "./index.html",
66
"main.8b0db0ad.css.map": "./static/css/main.8b0db0ad.css.map",
7-
"main.cf32ac93.js.map": "./static/js/main.cf32ac93.js.map"
7+
"main.b236aa1e.js.map": "./static/js/main.b236aa1e.js.map"
88
},
99
"entrypoints": [
1010
"static/css/main.8b0db0ad.css",
11-
"static/js/main.cf32ac93.js"
11+
"static/js/main.b236aa1e.js"
1212
]
1313
}

diag/app/build/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>go-workflows</title><script defer="defer" src="./static/js/main.cf32ac93.js"></script><link href="./static/css/main.8b0db0ad.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
1+
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>go-workflows</title><script defer="defer" src="./static/js/main.b236aa1e.js"></script><link href="./static/css/main.8b0db0ad.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

diag/app/build/static/js/main.cf32ac93.js renamed to diag/app/build/static/js/main.b236aa1e.js

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
File renamed without changes.

diag/app/build/static/js/main.b236aa1e.js.map

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)