Skip to content

Commit 6e055ad

Browse files
committed
Better context timeout error messages
This one's aimed at producing somewhat better ergonomics when it comes to working with context timeouts throughout the project. A sizable problem that we have right now is that in case of timeout, all that comes back is the generic error from `context.DeadlineExceeded`, "context deadline exceeded". You don't know where it came from exactly as it might've bubble up through many layers of the stack. If multiple timeouts were in use simultaneously, either one might've caused the timeout. Here, introduce a timeout helper that aims to let us easily trace the origin of a timeout. Traditionally, use of a timeout looked like this: func (p *StandardPilot) JobGetAvailable(ctx context.Context, exec riverdriver.Executor, state ProducerState, params *riverdriver.JobGetAvailableParams) ([]*rivertype.JobRow, error) { ctx, cancel := context.WithTimeout(ctx, rivercommon.HotOperationTimeout) defer cancel() return exec.JobGetAvailable(ctx, params) Here, we add `WithTimeout`/`WithTimeoutV` which are used like this: func (p *StandardPilot) JobGetAvailable(ctx context.Context, exec riverdriver.Executor, state ProducerState, params *riverdriver.JobGetAvailableParams) ([]*rivertype.JobRow, error) { return timeoututil.WithTimeoutV(ctx, rivercommon.HotOperationTimeout, "StandardPilot.JobGetAvailable", func(ctx context.Context) ([]*rivertype.JobRow, error) { return exec.JobGetAvailable(ctx, params) }) The helpers apply a timeout with `context.WithTimeout` the same way the first block works, but when a return timeout error is detected, they wrap it in a more contextual form of the error. So previously, you'd get this: context deadline exceeded With the helpers, you get this, containing the name of the operation and the timeout that'd been applied: StandardPilot.JobGetAvailable timed out after 1ns: context deadline exceeded Unfortunately it has to be a helper function with inner callback because even using `context.WithTimeoutCause`, `ctx.Err` still returns only a naked `context.DeadlineExceeded`. You need to call `context.Cause(ctx)` to get the error that was set as cause. That said, everything else works. A `context.DeadlineExceeded` that was wrapped on the way out is still recognized and maintains its wrapper. Multiple levels of `WithTimeout` can be nested and the right error message still gets returned, depending on which level timed out.
1 parent f8ae57a commit 6e055ad

4 files changed

Lines changed: 236 additions & 4 deletions

File tree

rivershared/riverpilot/standard_pilot.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/riverqueue/river/internal/rivercommon"
88
"github.com/riverqueue/river/riverdriver"
99
"github.com/riverqueue/river/rivershared/baseservice"
10+
"github.com/riverqueue/river/rivershared/util/timeoututil"
1011
"github.com/riverqueue/river/rivertype"
1112
)
1213

@@ -21,10 +22,9 @@ func (p *StandardPilot) JobGetAvailable(ctx context.Context, exec riverdriver.Ex
2122
return nil, nil
2223
}
2324

24-
ctx, cancel := context.WithTimeout(ctx, rivercommon.HotOperationTimeout)
25-
defer cancel()
26-
27-
return exec.JobGetAvailable(ctx, params)
25+
return timeoututil.WithTimeoutV(ctx, rivercommon.HotOperationTimeout, "StandardPilot.JobGetAvailable", func(ctx context.Context) ([]*rivertype.JobRow, error) {
26+
return exec.JobGetAvailable(ctx, params)
27+
})
2828
}
2929

3030
func (p *StandardPilot) JobGetStuck(ctx context.Context, exec riverdriver.Executor, params *riverdriver.JobGetStuckParams) ([]*rivertype.JobRow, error) {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package timeoututil
2+
3+
import (
4+
"testing"
5+
6+
"github.com/riverqueue/river/rivershared/riversharedtest"
7+
)
8+
9+
func TestMain(m *testing.M) {
10+
riversharedtest.WrapTestMain(m)
11+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package timeoututil
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"time"
8+
)
9+
10+
// WithTimeout runs innerFunc with a timeout.
11+
//
12+
// If innerFunc returns context.DeadlineExceeded because this helper's local
13+
// timeout fired, WithTimeout returns an error that includes operation and wraps
14+
// context.DeadlineExceeded. This makes timeout errors easier to trace back to
15+
// the specific River operation that introduced the timeout instead of surfacing
16+
// only the generic "context deadline exceeded" message.
17+
func WithTimeout(ctx context.Context, timeout time.Duration, operation string, innerFunc func(ctx context.Context) error) error {
18+
_, err := WithTimeoutV(ctx, timeout, operation, func(ctx context.Context) (struct{}, error) {
19+
return struct{}{}, innerFunc(ctx)
20+
})
21+
return err
22+
}
23+
24+
// WithTimeoutV runs innerFunc with a timeout and returns its value.
25+
//
26+
// If innerFunc returns context.DeadlineExceeded because this helper's local
27+
// timeout fired, WithTimeoutV returns an error that includes operation and
28+
// wraps context.DeadlineExceeded. This makes timeout errors easier to trace
29+
// back to the specific River operation that introduced the timeout instead of
30+
// surfacing only the generic "context deadline exceeded" message.
31+
func WithTimeoutV[T any](ctx context.Context, timeout time.Duration, operation string, innerFunc func(ctx context.Context) (T, error)) (T, error) {
32+
// need a specific, local error that we can recognize in case multiple
33+
// levels of these helpers are wrapped within one another
34+
timeoutErr := fmt.Errorf("timeoututil.WithTimeout: %w", context.DeadlineExceeded)
35+
36+
ctx, cancel := context.WithTimeoutCause(ctx, timeout, timeoutErr)
37+
defer cancel()
38+
39+
ret, err := innerFunc(ctx)
40+
if err != nil && errors.Is(err, context.DeadlineExceeded) && errors.Is(context.Cause(ctx), timeoutErr) {
41+
var zero T
42+
return zero, fmt.Errorf("%s timed out after %s: %w", operation, timeout, err)
43+
}
44+
return ret, err
45+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package timeoututil
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"testing"
8+
"time"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestWithTimeout(t *testing.T) {
14+
t.Parallel()
15+
16+
t.Run("NestedTimeoutsReturnLocalCause", func(t *testing.T) {
17+
t.Parallel()
18+
19+
err := WithTimeout(context.Background(), time.Hour, "TestWithTimeout.NestedTimeoutsReturnLocalCause.Outer", func(ctx context.Context) error {
20+
return WithTimeout(ctx, time.Nanosecond, "TestWithTimeout.NestedTimeoutsReturnLocalCause.Inner", func(ctx context.Context) error {
21+
<-ctx.Done()
22+
return ctx.Err()
23+
})
24+
})
25+
require.EqualError(t, err, "TestWithTimeout.NestedTimeoutsReturnLocalCause.Inner timed out after 1ns: context deadline exceeded")
26+
require.ErrorIs(t, err, context.DeadlineExceeded)
27+
28+
err = WithTimeout(context.Background(), time.Nanosecond, "TestWithTimeout.NestedTimeoutsReturnLocalCause.Outer", func(ctx context.Context) error {
29+
return WithTimeout(ctx, time.Hour, "TestWithTimeout.NestedTimeoutsReturnLocalCause.Inner", func(ctx context.Context) error {
30+
<-ctx.Done()
31+
return ctx.Err()
32+
})
33+
})
34+
require.EqualError(t, err, "TestWithTimeout.NestedTimeoutsReturnLocalCause.Outer timed out after 1ns: context deadline exceeded")
35+
require.ErrorIs(t, err, context.DeadlineExceeded)
36+
})
37+
38+
t.Run("PreservesLocalTimeoutCause", func(t *testing.T) {
39+
t.Parallel()
40+
41+
err := WithTimeout(context.Background(), time.Nanosecond, "TestWithTimeout.PreservesLocalTimeoutCause", func(ctx context.Context) error {
42+
<-ctx.Done()
43+
return context.Cause(ctx)
44+
})
45+
require.EqualError(t, err, "TestWithTimeout.PreservesLocalTimeoutCause timed out after 1ns: timeoututil.WithTimeout: context deadline exceeded")
46+
require.ErrorIs(t, err, context.DeadlineExceeded)
47+
})
48+
49+
t.Run("PreservesWrappedDeadlineExceededFromLocalTimeout", func(t *testing.T) {
50+
t.Parallel()
51+
52+
innerErr := errors.New("inner error")
53+
54+
err := WithTimeout(context.Background(), time.Nanosecond, "TestWithTimeout.PreservesWrappedDeadlineExceededFromLocalTimeout", func(ctx context.Context) error {
55+
<-ctx.Done()
56+
return fmt.Errorf("%w: %w", innerErr, ctx.Err())
57+
})
58+
require.EqualError(t, err, "TestWithTimeout.PreservesWrappedDeadlineExceededFromLocalTimeout timed out after 1ns: inner error: context deadline exceeded")
59+
require.ErrorIs(t, err, context.DeadlineExceeded)
60+
require.ErrorIs(t, err, innerErr)
61+
})
62+
63+
t.Run("ReturnsInnerError", func(t *testing.T) {
64+
t.Parallel()
65+
66+
innerErr := errors.New("inner error")
67+
68+
err := WithTimeout(context.Background(), time.Hour, "TestWithTimeout.ReturnsInnerError", func(ctx context.Context) error {
69+
return innerErr
70+
})
71+
require.ErrorIs(t, err, innerErr)
72+
})
73+
74+
t.Run("ReturnsLocalTimeoutCause", func(t *testing.T) {
75+
t.Parallel()
76+
77+
err := WithTimeout(context.Background(), time.Nanosecond, "TestWithTimeout.ReturnsLocalTimeoutCause", func(ctx context.Context) error {
78+
<-ctx.Done()
79+
return ctx.Err()
80+
})
81+
require.EqualError(t, err, "TestWithTimeout.ReturnsLocalTimeoutCause timed out after 1ns: context deadline exceeded")
82+
require.ErrorIs(t, err, context.DeadlineExceeded)
83+
})
84+
}
85+
86+
func TestWithTimeoutV(t *testing.T) {
87+
t.Parallel()
88+
89+
t.Run("NestedTimeoutsReturnLocalCause", func(t *testing.T) {
90+
t.Parallel()
91+
92+
ret, err := WithTimeoutV(context.Background(), time.Hour, "TestWithTimeoutV.NestedTimeoutsReturnLocalCause.Outer", func(ctx context.Context) (int, error) {
93+
return WithTimeoutV(ctx, time.Nanosecond, "TestWithTimeoutV.NestedTimeoutsReturnLocalCause.Inner", func(ctx context.Context) (int, error) {
94+
<-ctx.Done()
95+
return 9, ctx.Err()
96+
})
97+
})
98+
require.Zero(t, ret)
99+
require.EqualError(t, err, "TestWithTimeoutV.NestedTimeoutsReturnLocalCause.Inner timed out after 1ns: context deadline exceeded")
100+
require.ErrorIs(t, err, context.DeadlineExceeded)
101+
102+
ret, err = WithTimeoutV(context.Background(), time.Nanosecond, "TestWithTimeoutV.NestedTimeoutsReturnLocalCause.Outer", func(ctx context.Context) (int, error) {
103+
return WithTimeoutV(ctx, time.Hour, "TestWithTimeoutV.NestedTimeoutsReturnLocalCause.Inner", func(ctx context.Context) (int, error) {
104+
<-ctx.Done()
105+
return 9, ctx.Err()
106+
})
107+
})
108+
require.Zero(t, ret)
109+
require.EqualError(t, err, "TestWithTimeoutV.NestedTimeoutsReturnLocalCause.Outer timed out after 1ns: context deadline exceeded")
110+
require.ErrorIs(t, err, context.DeadlineExceeded)
111+
})
112+
113+
t.Run("PreservesLocalTimeoutCause", func(t *testing.T) {
114+
t.Parallel()
115+
116+
ret, err := WithTimeoutV(context.Background(), time.Nanosecond, "TestWithTimeoutV.PreservesLocalTimeoutCause", func(ctx context.Context) (int, error) {
117+
<-ctx.Done()
118+
return 9, context.Cause(ctx)
119+
})
120+
require.Zero(t, ret)
121+
require.EqualError(t, err, "TestWithTimeoutV.PreservesLocalTimeoutCause timed out after 1ns: timeoututil.WithTimeout: context deadline exceeded")
122+
require.ErrorIs(t, err, context.DeadlineExceeded)
123+
})
124+
125+
t.Run("PreservesParentCancellation", func(t *testing.T) {
126+
t.Parallel()
127+
128+
parentErr := errors.New("parent cancelled")
129+
parentCtx, cancel := context.WithCancelCause(context.Background())
130+
cancel(parentErr)
131+
132+
ret, err := WithTimeoutV(parentCtx, time.Hour, "TestWithTimeoutV.PreservesParentCancellation", func(ctx context.Context) (int, error) {
133+
<-ctx.Done()
134+
return 0, context.Cause(ctx)
135+
})
136+
require.Zero(t, ret)
137+
require.ErrorIs(t, err, parentErr)
138+
})
139+
140+
t.Run("PreservesWrappedDeadlineExceededFromLocalTimeout", func(t *testing.T) {
141+
t.Parallel()
142+
143+
innerErr := errors.New("inner error")
144+
145+
ret, err := WithTimeoutV(context.Background(), time.Nanosecond, "TestWithTimeoutV.PreservesWrappedDeadlineExceededFromLocalTimeout", func(ctx context.Context) (int, error) {
146+
<-ctx.Done()
147+
return 9, fmt.Errorf("%w: %w", innerErr, ctx.Err())
148+
})
149+
require.Zero(t, ret)
150+
require.EqualError(t, err, "TestWithTimeoutV.PreservesWrappedDeadlineExceededFromLocalTimeout timed out after 1ns: inner error: context deadline exceeded")
151+
require.ErrorIs(t, err, context.DeadlineExceeded)
152+
require.ErrorIs(t, err, innerErr)
153+
})
154+
155+
t.Run("ReturnsInnerValue", func(t *testing.T) {
156+
t.Parallel()
157+
158+
ret, err := WithTimeoutV(context.Background(), time.Hour, "TestWithTimeoutV.ReturnsInnerValue", func(ctx context.Context) (int, error) {
159+
return 7, nil
160+
})
161+
require.NoError(t, err)
162+
require.Equal(t, 7, ret)
163+
})
164+
165+
t.Run("WrapsDeadlineExceededFromLocalTimeout", func(t *testing.T) {
166+
t.Parallel()
167+
168+
ret, err := WithTimeoutV(context.Background(), time.Nanosecond, "TestWithTimeoutV.WrapsDeadlineExceededFromLocalTimeout", func(ctx context.Context) (int, error) {
169+
<-ctx.Done()
170+
return 9, ctx.Err()
171+
})
172+
require.Zero(t, ret)
173+
require.EqualError(t, err, "TestWithTimeoutV.WrapsDeadlineExceededFromLocalTimeout timed out after 1ns: context deadline exceeded")
174+
require.ErrorIs(t, err, context.DeadlineExceeded)
175+
})
176+
}

0 commit comments

Comments
 (0)