Skip to content

Commit beff199

Browse files
authored
feat(submitqueue): terminate a request only on a validator.ErrRejected rejection and classify every other validator error
1 parent f845116 commit beff199

9 files changed

Lines changed: 140 additions & 11 deletions

File tree

‎submitqueue/extension/validator/composite/validator_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package composite
1717
import (
1818
"context"
1919
"errors"
20+
"fmt"
2021
"testing"
2122

2223
"github.com/stretchr/testify/assert"
@@ -104,3 +105,35 @@ func TestNew(t *testing.T) {
104105
})
105106
}
106107
}
108+
109+
func TestNew_RejectsWhenAnyChildRejects(t *testing.T) {
110+
rejection := fmt.Errorf("%w: missing approval", validator.ErrRejected)
111+
failure := errors.New("could not reach the policy service")
112+
113+
tests := []struct {
114+
name string
115+
children []error
116+
wantRejected bool
117+
}{
118+
{name: "one child rejects", children: []error{nil, rejection}, wantRejected: true},
119+
{name: "a rejection beside a failure still rejects", children: []error{failure, rejection}, wantRejected: true},
120+
{name: "failures alone do not reject", children: []error{failure, nil}},
121+
}
122+
123+
for _, tt := range tests {
124+
t.Run(tt.name, func(t *testing.T) {
125+
ctrl := gomock.NewController(t)
126+
var children []validator.Validator
127+
for _, err := range tt.children {
128+
child := validatormock.NewMockValidator(ctrl)
129+
child.EXPECT().Validate(gomock.Any(), gomock.Any()).Return(err)
130+
children = append(children, child)
131+
}
132+
133+
err := New(validator.Config{QueueName: "test-queue"}, children).Validate(context.Background(), entity.Request{})
134+
135+
require.Error(t, err)
136+
assert.Equal(t, tt.wantRejected, errors.Is(err, validator.ErrRejected))
137+
})
138+
}
139+
}

‎submitqueue/extension/validator/github/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ URIs that are not GitHub change IDs are skipped, so the validator composes with
3333

3434
## Failures
3535

36-
A rejection is an error naming the pull request and the rule it breaks. A failure to read GitHub — a transport error, an HTTP status, a GraphQL error such as a pull request that does not exist — is returned wrapped, and an HTTP status stays in the chain as `platform/http.StatusError` so `platform/errs/http` can classify it. Neither is classified here, as for any extension, and the validate stage currently rejects the request on either.
36+
A rejection is an error naming the pull request and the rule it breaks, wrapping `validator.ErrRejected`; the validate stage terminates the request with it as the reason. A failure to read GitHub — a transport error, an HTTP status, a GraphQL error such as a pull request that does not exist — does not wrap it, so the validate stage hands it to the consumer's classifiers like any other stage failure: an HTTP status stays in the chain as `platform/http.StatusError`, so `platform/errs/http` retries a 5xx or 429 and dead-letters a 403 or 404.
3737

3838
## Auth and hosts
3939

‎submitqueue/extension/validator/github/evaluate.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"strings"
2121

2222
entitygithub "github.com/uber/submitqueue/platform/base/change/github"
23+
"github.com/uber/submitqueue/submitqueue/extension/validator"
2324
)
2425

2526
// rejectionReason names why a pull request was rejected. The values are metric
@@ -53,6 +54,9 @@ func (r rejection) Error() string {
5354
return fmt.Sprintf("%s: %s", r.uri, r.detail)
5455
}
5556

57+
// Unwrap marks every rejection as validator.ErrRejected for the validate stage.
58+
func (r rejection) Unwrap() error { return validator.ErrRejected }
59+
5660
// checkOutcome is ordered worst to best; requiredChecksNotPassed relies on it.
5761
type checkOutcome int
5862

‎submitqueue/extension/validator/github/validator.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,10 @@ func New(params Params) (validator.Validator, error) {
8181
}
8282

8383
// Validate checks the request's GitHub pull requests bottom first and returns
84-
// the first rule one breaks. A failure to read GitHub is returned wrapped,
85-
// keeping an HTTP status code in the chain for platform/errs/http. URIs that
86-
// are not GitHub change IDs are skipped, so this validator composes with
87-
// validators for other change providers.
84+
// the first rule one breaks, wrapping validator.ErrRejected. A failure to read
85+
// GitHub is returned without it, keeping an HTTP status code in the chain for
86+
// platform/errs/http. URIs that are not GitHub change IDs are skipped, so this
87+
// validator composes with validators for other change providers.
8888
func (v *githubValidator) Validate(ctx context.Context, request entity.Request) error {
8989
op := coremetrics.Begin(v.metricsScope, "validate", coremetrics.LongLatencyBuckets)
9090
rej, rejected, err := v.findRejection(ctx, request)

‎submitqueue/extension/validator/github/validator_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ func TestValidate(t *testing.T) {
208208
assert.NoError(t, err)
209209
return
210210
}
211+
require.ErrorIs(t, err, validator.ErrRejected)
211212
var rej rejection
212213
require.True(t, errors.As(err, &rej), "want a rejection, got %v", err)
213214
assert.Equal(t, tt.wantReason, rej.reason)
@@ -250,7 +251,7 @@ func TestValidateReadFailures(t *testing.T) {
250251
err := v.Validate(context.Background(), request(testURI(1)))
251252

252253
require.Error(t, err)
253-
assert.False(t, errors.As(err, &rejection{}), "a failure to read GitHub is not a rejection")
254+
assert.NotErrorIs(t, err, validator.ErrRejected, "a failure to read GitHub is not a rejection")
254255
var statusErr *phttp.StatusError
255256
if tt.wantStatus != 0 {
256257
require.True(t, errors.As(err, &statusErr))

‎submitqueue/extension/validator/validator.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,27 @@ package validator
1818

1919
import (
2020
"context"
21+
"errors"
2122

2223
"github.com/uber/submitqueue/submitqueue/entity"
2324
)
2425

26+
// ErrRejected signals that the request must not land: a policy verdict, not a
27+
// failure. Validators wrap it into the error they return for a rejection, and
28+
// the validate stage terminates the request with that error's message as the
29+
// reason. Any other error is a failure to validate, classified and retried or
30+
// dead-lettered like any other stage failure.
31+
var ErrRejected = errors.New("rejected")
32+
2533
// Validator runs custom validation checks against a request.
2634
// Implementations are provided by integrators for company-specific checks
2735
// (e.g. org-policy enforcement, repo-specific rules). The built-in validate
2836
// controller invokes this after fetching change metadata and before claiming
2937
// URIs in the change store.
3038
type Validator interface {
31-
// Validate runs custom validation on the request.
32-
// Returns nil if validation passes, error if the request should be rejected.
39+
// Validate runs custom validation on the request. It returns nil if the
40+
// request passes, an error wrapping ErrRejected if it must not land, and any
41+
// other error if it could not be validated.
3342
Validate(ctx context.Context, request entity.Request) error
3443
}
3544

‎submitqueue/orchestrator/controller/validate/BUILD.bazel‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ go_test(
4040
"//platform/consumer/mock:go_default_library",
4141
"//platform/errs:go_default_library",
4242
"//platform/extension/messagequeue/mock:go_default_library",
43+
"//platform/http:go_default_library",
4344
"//submitqueue/core/messagequeue:go_default_library",
4445
"//submitqueue/core/topickey:go_default_library",
4546
"//submitqueue/entity:go_default_library",

‎submitqueue/orchestrator/controller/validate/validate.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) er
202202
}
203203
if v != nil {
204204
if err := v.Validate(ctx, request); err != nil {
205+
// Only a rejection is the request's outcome. A failure to validate
206+
// is left to the consumer's classifiers, so a transient one retries
207+
// instead of failing a request nothing was wrong with.
208+
if !errors.Is(err, validator.ErrRejected) {
209+
coremetrics.NamedCounter(c.metricsScope, "process", "custom_validation_errors", 1)
210+
return fmt.Errorf("failed to validate request %s: %w", request.ID, err)
211+
}
205212
coremetrics.NamedCounter(c.metricsScope, "process", "custom_validation_failures", 1)
206213
c.logger.Infow("custom validation rejected request",
207214
"request_id", request.ID,

‎submitqueue/orchestrator/controller/validate/validate_test.go‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package validate
1616

1717
import (
1818
"context"
19+
"errors"
1920
"fmt"
2021
"testing"
2122

@@ -31,6 +32,7 @@ import (
3132
consumermock "github.com/uber/submitqueue/platform/consumer/mock"
3233
"github.com/uber/submitqueue/platform/errs"
3334
queuemock "github.com/uber/submitqueue/platform/extension/messagequeue/mock"
35+
phttp "github.com/uber/submitqueue/platform/http"
3436
sqmq "github.com/uber/submitqueue/submitqueue/core/messagequeue"
3537
"github.com/uber/submitqueue/submitqueue/core/topickey"
3638
"github.com/uber/submitqueue/submitqueue/entity"
@@ -735,8 +737,7 @@ func TestController_Process_CustomValidatorFails(t *testing.T) {
735737
cpFactory.EXPECT().For(gomock.Any()).Return(&mockChangeProvider{}, nil).AnyTimes()
736738

737739
mockValidator := validatormock.NewMockValidator(ctrl)
738-
// mockValidator returns an error - validation failed
739-
mockValidator.EXPECT().Validate(gomock.Any(), gomock.Any()).Return(fmt.Errorf("some validation error"))
740+
mockValidator.EXPECT().Validate(gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: some validation error", validator.ErrRejected))
740741
mockValidatorFactory := validatormock.NewMockFactory(ctrl)
741742
mockValidatorFactory.EXPECT().For(validator.Config{
742743
QueueName: request.Queue,
@@ -758,6 +759,79 @@ func TestController_Process_CustomValidatorFails(t *testing.T) {
758759
assert.Contains(t, gotLog.LastError, "some validation error")
759760
}
760761

762+
// A validator that could not validate has said nothing about the request, so
763+
// the failure goes to the consumer's classifiers instead of terminating it.
764+
func TestController_Process_CustomValidatorCannotValidate(t *testing.T) {
765+
tests := []struct {
766+
name string
767+
err error
768+
}{
769+
{name: "plain failure", err: errors.New("policy service unreachable")},
770+
{name: "HTTP status", err: fmt.Errorf("read pull request: %w", phttp.NewStatusError(502, nil))},
771+
}
772+
773+
for _, tt := range tests {
774+
t.Run(tt.name, func(t *testing.T) {
775+
ctrl := gomock.NewController(t)
776+
777+
request := entity.Request{
778+
ID: "123",
779+
Queue: "test-queue",
780+
Change: change.Change{URIs: []string{"github://uber/service/pull/456/abcdef0123456789abcdef0123456789abcdef01"}},
781+
LandStrategy: mergestrategy.MergeStrategyRebase,
782+
State: entity.RequestStateStarted,
783+
Version: 1,
784+
}
785+
// No Update and no claim Create are expected: the request must stay
786+
// live and unclaimed for the redelivery to validate it again.
787+
store, _ := newMockStorage(ctrl, request)
788+
cs := storagemock.NewMockChangeStore(ctrl)
789+
cs.EXPECT().GetByURI(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
790+
store.EXPECT().GetChangeStore().Return(cs).AnyTimes()
791+
792+
var statuses []entity.RequestStatus
793+
mockPub := queuemock.NewMockPublisher(ctrl)
794+
mockPub.EXPECT().Publish(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
795+
func(_ context.Context, _ string, msg entityqueue.Message) error {
796+
if log, err := sqmq.UnmarshalRequestLog(msg.Payload); err == nil {
797+
statuses = append(statuses, log.Status)
798+
}
799+
return nil
800+
},
801+
).AnyTimes()
802+
mockQ := queuemock.NewMockQueue(ctrl)
803+
mockQ.EXPECT().Publisher().Return(mockPub).AnyTimes()
804+
registry, err := consumer.NewTopicRegistry(
805+
[]consumer.TopicConfig{
806+
{Key: runwaymq.TopicKeyMergeConflictCheck, Name: "merge-conflict-check", Queue: mockQ},
807+
{Key: topickey.TopicKeyLog, Name: "log", Queue: mockQ},
808+
},
809+
)
810+
require.NoError(t, err)
811+
812+
cpFactory := changeprovidermock.NewMockFactory(ctrl)
813+
cpFactory.EXPECT().For(gomock.Any()).Return(&mockChangeProvider{}, nil).AnyTimes()
814+
mockValidator := validatormock.NewMockValidator(ctrl)
815+
mockValidator.EXPECT().Validate(gomock.Any(), gomock.Any()).Return(tt.err)
816+
mockValidatorFactory := validatormock.NewMockFactory(ctrl)
817+
mockValidatorFactory.EXPECT().For(validator.Config{QueueName: request.Queue}).Return(mockValidator, nil)
818+
819+
controller := NewController(zaptest.NewLogger(t).Sugar(), tally.NoopScope, staticStorageFactory{store: store}, registry, cpFactory, mockValidatorFactory, runwaymq.TopicKeyMergeConflictCheck, topickey.TopicKeyValidate, "orchestrator-validate")
820+
msg := entityqueue.NewMessage(request.ID, requestIDPayload(t, request.ID), request.Queue, nil)
821+
msg.Tenant = request.Queue
822+
delivery := consumermock.NewMockDelivery(ctrl)
823+
delivery.EXPECT().Message().Return(msg).AnyTimes()
824+
delivery.EXPECT().Attempt().Return(1).AnyTimes()
825+
826+
err = controller.Process(context.Background(), delivery)
827+
828+
require.ErrorIs(t, err, tt.err)
829+
assert.NotErrorIs(t, err, validator.ErrRejected)
830+
assert.NotContains(t, statuses, entity.RequestStatusError, "the request must not be terminated")
831+
})
832+
}
833+
}
834+
761835
func TestController_Process_CustomValidatorFailure_TerminationPublishFails(t *testing.T) {
762836
ctrl := gomock.NewController(t)
763837

@@ -800,7 +874,7 @@ func TestController_Process_CustomValidatorFailure_TerminationPublishFails(t *te
800874
cpFactory.EXPECT().For(gomock.Any()).Return(&mockChangeProvider{}, nil).AnyTimes()
801875

802876
mockValidator := validatormock.NewMockValidator(ctrl)
803-
mockValidator.EXPECT().Validate(gomock.Any(), gomock.Any()).Return(fmt.Errorf("some validation error"))
877+
mockValidator.EXPECT().Validate(gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: some validation error", validator.ErrRejected))
804878
mockValidatorFactory := validatormock.NewMockFactory(ctrl)
805879
mockValidatorFactory.EXPECT().For(validator.Config{
806880
QueueName: request.Queue,

0 commit comments

Comments
 (0)