diff --git a/service/submitqueue/orchestrator/server/BUILD.bazel b/service/submitqueue/orchestrator/server/BUILD.bazel index d921c16a1..a1ec263bc 100644 --- a/service/submitqueue/orchestrator/server/BUILD.bazel +++ b/service/submitqueue/orchestrator/server/BUILD.bazel @@ -19,7 +19,9 @@ go_library( "//api/base/hook:go_default_library", "//api/submitqueue/orchestrator/protopb:go_default_library", "//platform/buildkite:go_default_library", + "//platform/errs:go_default_library", "//platform/errs/generic:go_default_library", + "//platform/errs/http:go_default_library", "//platform/errs/mysql:go_default_library", "//platform/extension/consumergate:go_default_library", "//platform/extension/consumergate/file:go_default_library", @@ -107,6 +109,7 @@ go_test( name = "go_default_test", srcs = [ "config_test.go", + "main_test.go", "profiles_test.go", ], # The pinned git, for the tests that resolve a git change provider against a @@ -118,7 +121,9 @@ go_test( }, deps = [ "//platform/base/change:go_default_library", + "//platform/errs:go_default_library", "//platform/git/exectest:go_default_library", + "//platform/githubactions:go_default_library", "//submitqueue/entity:go_default_library", "//submitqueue/extension/buildrunner:go_default_library", "//submitqueue/extension/changeprovider:go_default_library", diff --git a/service/submitqueue/orchestrator/server/main.go b/service/submitqueue/orchestrator/server/main.go index e46ae4071..40f6fdbfb 100644 --- a/service/submitqueue/orchestrator/server/main.go +++ b/service/submitqueue/orchestrator/server/main.go @@ -33,7 +33,9 @@ import ( "github.com/uber-go/tally" basehook "github.com/uber/submitqueue/api/base/hook" pb "github.com/uber/submitqueue/api/submitqueue/orchestrator/protopb" + "github.com/uber/submitqueue/platform/errs" genericerrs "github.com/uber/submitqueue/platform/errs/generic" + httperrs "github.com/uber/submitqueue/platform/errs/http" mysqlerrs "github.com/uber/submitqueue/platform/errs/mysql" "github.com/uber/submitqueue/platform/extension/consumergate" consumergatefile "github.com/uber/submitqueue/platform/extension/consumergate/file" @@ -231,14 +233,7 @@ func run() error { deps, orchestrator.Stages, pipeline.PublishOnly(orchestrator.PublishOnlyTopics...), - pipeline.Classifiers( - genericerrs.Classifier, - // Storage (submitqueue/orchestrator/extension/storage/mysql) and queue - // (platform/extension/messagequeue/mysql) both run on the same - // MySQL driver, so a single classifier covers errors surfaced - // from either backend. - mysqlerrs.Classifier, - ), + pipeline.Classifiers(primaryErrorClassifiers()...), pipeline.Gate(newConsumerGate(logger)), ) if err != nil { @@ -432,6 +427,15 @@ func defaultProfilesConfig() profilesConfig { return cfg } +func primaryErrorClassifiers() []errs.Classifier { + return []errs.Classifier{ + genericerrs.Classifier, + // HTTP must precede MySQL's broad net.Error match to retain dependency attribution. + httperrs.Classifier, + mysqlerrs.Classifier, + } +} + // getEnv returns environment variable value or default if not set. func getEnv(key, defaultVal string) string { if val := os.Getenv(key); val != "" { diff --git a/service/submitqueue/orchestrator/server/main_test.go b/service/submitqueue/orchestrator/server/main_test.go new file mode 100644 index 000000000..3ac20b2b0 --- /dev/null +++ b/service/submitqueue/orchestrator/server/main_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "database/sql/driver" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber/submitqueue/platform/errs" + "github.com/uber/submitqueue/platform/githubactions" +) + +func TestPrimaryErrorClassifiers_BuildStatus(t *testing.T) { + processor := errs.NewClassifierProcessor(primaryErrorClassifiers()...) + tests := []struct { + name string + status int + transportErr error + retryable bool + dependency bool + }{ + {name: "rate limited", status: http.StatusTooManyRequests, retryable: true, dependency: true}, + {name: "service unavailable", status: http.StatusServiceUnavailable, retryable: true, dependency: true}, + {name: "bad request", status: http.StatusBadRequest, dependency: true}, + {name: "unauthorized", status: http.StatusUnauthorized, dependency: true}, + {name: "deadline exceeded", transportErr: context.DeadlineExceeded, retryable: true, dependency: true}, + {name: "shutdown", transportErr: context.Canceled, retryable: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := githubactions.NewClient(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if tt.transportErr != nil { + return nil, tt.transportErr + } + return &http.Response{ + StatusCode: tt.status, + Body: io.NopCloser(strings.NewReader("upstream failure")), + Request: req, + }, nil + }), + }, "owner", "repo", "ci.yml") + + _, err := client.GetRun(context.Background(), 42) + require.Error(t, err) + out := processor.Process(fmt.Errorf("failed to get build status: %w", err)) + assert.Equal(t, tt.retryable, errs.IsRetryable(out)) + assert.Equal(t, tt.dependency, errs.IsDependencyError(out)) + }) + } +} + +func TestPrimaryErrorClassifiers_Storage(t *testing.T) { + processor := errs.NewClassifierProcessor(primaryErrorClassifiers()...) + err := processor.Process(fmt.Errorf("load build: %w", driver.ErrBadConn)) + assert.True(t, errs.IsRetryable(err)) + assert.False(t, errs.IsDependencyError(err)) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +}