Skip to content

Commit 26cf446

Browse files
committed
feat(errs): classify version mismatches as retryable
1 parent e39b834 commit 26cf446

8 files changed

Lines changed: 46 additions & 9 deletions

File tree

platform/errs/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Errors are classified along two axes:
1212
| **Infra** | *(any unclassified error)* | `NewRetryableError` |
1313
| **Infra dep** | `NewDependencyError` | `NewRetryableDependencyError` |
1414

15-
**Non-retryable by default.** A plain `fmt.Errorf(...)` is treated as a non-retryable infra error. Retryability must be explicitly opted into by wrapping with `NewRetryableError`. This prevents accidental infinite retry loops from unclassified errors.
15+
**Non-retryable by default.** A plain `fmt.Errorf(...)` is treated as a non-retryable infra error. Retryability must be explicitly recognized by a registered classifier or framework wrapper. This prevents accidental infinite retry loops from unclassified errors.
1616

1717
**Only infra errors can be retryable.** User errors are never retryable — if a user action caused the failure, retrying the same operation will produce the same result. If an error is retryable, it is by definition an infrastructure issue.
1818

@@ -58,7 +58,7 @@ Two implementations ship in this package:
5858

5959
## Adding a Backend-Specific Classifier
6060

61-
Backend classifiers live alongside the extension they classify, under `platform/errs/<backend>/`. The canonical examples are `platform/errs/mysql` (MySQL driver errors) and `platform/errs/generic` (transport-agnostic concerns such as `context.Canceled`).
61+
Backend classifiers live alongside the extension they classify, under `platform/errs/<backend>/`. The canonical examples are `platform/errs/mysql` (MySQL driver errors) and `platform/errs/generic` (backend-independent errors such as `context.Canceled` and `errs.ErrVersionMismatch`).
6262

6363
A classifier:
6464

@@ -137,7 +137,7 @@ In particular, **do not reach for `NewRetryableError` just because replaying the
137137

138138
## Extensions Return Plain Go Errors
139139

140-
Extension interfaces (`MergeChecker`, `Storage`, `Publisher`) return standard `error` values. They may define their own domain-specific sentinel errors (e.g. `storage.ErrNotFound`, `storage.ErrVersionMismatch`) but they do **not** classify errors as user or infra — that is the controller's (and the consumer's `ErrorProcessor`'s) job.
140+
Extension interfaces (`MergeChecker`, `Storage`, `Publisher`) return standard `error` values. They may define domain-specific sentinel errors such as `storage.ErrNotFound` or expose shared semantic sentinels such as `errs.ErrVersionMismatch`, but they do **not** classify errors as user or infra. That is the controller's and the consumer's `ErrorProcessor`'s job.
141141

142142
This separation keeps extensions reusable across contexts. The same `storage.ErrNotFound` might be a user error in one controller (user requested a non-existent resource) and an infra error in another (expected record is missing).
143143

platform/errs/errs.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import (
1818
"errors"
1919
)
2020

21+
// ErrVersionMismatch indicates that an optimistic conditional update failed
22+
// because the persisted version no longer matches the expected version.
23+
var ErrVersionMismatch = errors.New("version mismatch")
24+
2125
// userError represents an error caused by invalid user input or actions.
2226
// User errors are never retryable — only infrastructure errors can be retryable.
2327
// Use NewUserError to wrap an underlying cause.

platform/errs/generic/generic.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ type classifier struct{}
3939
// must not call errors.Is / errors.As — the classifier-processor owns the
4040
// chain walk.
4141
func (classifier) Classify(err error) errs.Verdict {
42+
if err == errs.ErrVersionMismatch {
43+
return errs.InfraRetryable
44+
}
45+
4246
// Cancellation signals that the caller aborted the work in flight
4347
// (process shutdown, deadline on the inbound RPC, parent operation gone) —
4448
// it is not a statement about the work itself being invalid. The same

platform/errs/generic/generic_test.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,20 @@ import (
2424
"github.com/uber/submitqueue/platform/errs"
2525
)
2626

27-
func TestClassifier_ContextCanceled(t *testing.T) {
28-
assert.Equal(t, errs.InfraRetryable, Classifier.Classify(context.Canceled))
27+
func TestClassifier_Retryable(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
err error
31+
}{
32+
{"context canceled", context.Canceled},
33+
{"version mismatch", errs.ErrVersionMismatch},
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
assert.Equal(t, errs.InfraRetryable, Classifier.Classify(tt.err))
39+
})
40+
}
2941
}
3042

3143
func TestClassifier_Unknown(t *testing.T) {
@@ -37,6 +49,7 @@ func TestClassifier_Unknown(t *testing.T) {
3749
// context.Canceled; the surrounding classifier-processor walk will
3850
// reach the inner node and ask Classifier again there.
3951
{"wrapped context.Canceled", fmt.Errorf("op: %w", context.Canceled)},
52+
{"wrapped version mismatch", fmt.Errorf("op: %w", errs.ErrVersionMismatch)},
4053
{"deadline exceeded", context.DeadlineExceeded},
4154
{"plain error", errors.New("anything")},
4255
{"nil", nil},
@@ -65,6 +78,12 @@ func TestClassifier_AppliedViaProcessor(t *testing.T) {
6578
assert.True(t, errs.IsRetryable(out))
6679
})
6780

81+
t.Run("wrapped version mismatch becomes retryable infra", func(t *testing.T) {
82+
wrapped := fmt.Errorf("update: %w", errs.ErrVersionMismatch)
83+
out := p.Process(wrapped)
84+
assert.True(t, errs.IsRetryable(out))
85+
})
86+
6887
t.Run("framework wrap in chain wins", func(t *testing.T) {
6988
// A controller explicitly classified the shutdown as non-retryable.
7089
// The pass-1 framework-wrap check short-circuits before Classifier

stovepipe/extension/storage/BUILD.bazel

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,8 @@ go_library(
1111
],
1212
importpath = "github.com/uber/submitqueue/stovepipe/extension/storage",
1313
visibility = ["//visibility:public"],
14-
deps = ["//stovepipe/entity:go_default_library"],
14+
deps = [
15+
"//platform/errs:go_default_library",
16+
"//stovepipe/entity:go_default_library",
17+
],
1518
)

stovepipe/extension/storage/storage.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package storage
1919
import (
2020
"errors"
2121
"fmt"
22+
23+
platformerrs "github.com/uber/submitqueue/platform/errs"
2224
)
2325

2426
// ErrNotFound is returned by storage implementations when the requested record is not found in the database.
@@ -40,7 +42,7 @@ var ErrAlreadyExists = errors.New("record already exists")
4042
// ErrVersionMismatch is returned by storage implementations when a conditional (CAS) update finds that
4143
// the stored version does not match the expected version. It backs optimistic locking, letting callers
4244
// retry or converge instead of overwriting a concurrent change.
43-
var ErrVersionMismatch = errors.New("version mismatch")
45+
var ErrVersionMismatch = platformerrs.ErrVersionMismatch
4446

4547
// Storage is a factory interface that aggregates all entity stores into a single injectable dependency.
4648
type Storage interface {

submitqueue/extension/storage/BUILD.bazel

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,8 @@ go_library(
1616
],
1717
importpath = "github.com/uber/submitqueue/submitqueue/extension/storage",
1818
visibility = ["//visibility:public"],
19-
deps = ["//submitqueue/entity:go_default_library"],
19+
deps = [
20+
"//platform/errs:go_default_library",
21+
"//submitqueue/entity:go_default_library",
22+
],
2023
)

submitqueue/extension/storage/storage.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ package storage
1919
import (
2020
"errors"
2121
"fmt"
22+
23+
platformerrs "github.com/uber/submitqueue/platform/errs"
2224
)
2325

2426
// ErrNotFound is returned by storage implementations when the requested record is not found in the database.
@@ -40,7 +42,7 @@ var ErrAlreadyExists = errors.New("record already exists")
4042
// ErrVersionMismatch is returned by storage implementations when the expected entity version does not match the current version of the object.
4143
// This is used to implement an optimistic locking mechanism, allowing multiple clients to update the same entity concurrently
4244
// and either retry or implement idempotent operations.
43-
var ErrVersionMismatch = errors.New("version mismatch")
45+
var ErrVersionMismatch = platformerrs.ErrVersionMismatch
4446

4547
// Storage is a factory interface that aggregates all entity stores into a single injectable dependency.
4648
type Storage interface {

0 commit comments

Comments
 (0)