Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions platform/errs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ One operational consequence worth knowing before relying on any of this: **retry

## Adding a Backend-Specific Classifier

Backend classifiers live alongside the extension they classify, under `platform/errs/<backend>/`. The canonical examples are `platform/errs/mysql` (MySQL driver errors), `platform/errs/http` (rejected status codes and transport failures from clients built on `platform/http`), `platform/errs/yarpc` (YARPC status codes), and `platform/errs/generic` (transport-agnostic concerns such as `context.Canceled`).
Backend classifiers live alongside the extension they classify, under `platform/errs/<backend>/`. The canonical examples are `platform/errs/mysql` (MySQL driver errors), `platform/errs/http` (rejected status codes and transport failures from clients built on `platform/http`), `platform/errs/git` (structured Git process failures), `platform/errs/yarpc` (YARPC status codes), and `platform/errs/generic` (transport-agnostic concerns such as `context.Canceled`).

A classifier:

Expand Down Expand Up @@ -122,6 +122,7 @@ Servers wire each classifier into the consumer's `ErrorProcessor`. Order matters
import (
"github.com/uber/submitqueue/platform/errs"
genericerrs "github.com/uber/submitqueue/platform/errs/generic"
giterrs "github.com/uber/submitqueue/platform/errs/git"
httperrs "github.com/uber/submitqueue/platform/errs/http"
mysqlerrs "github.com/uber/submitqueue/platform/errs/mysql"
yarpcerrs "github.com/uber/submitqueue/platform/errs/yarpc"
Expand All @@ -130,6 +131,7 @@ import (
c := consumer.New(logger, scope, registry,
errs.NewClassifierProcessor(
genericerrs.Classifier,
giterrs.Classifier,
httperrs.Classifier,
yarpcerrs.Classifier,
mysqlerrs.Classifier,
Expand All @@ -143,7 +145,9 @@ Classifiers are not installed globally. A host that wants YARPC statuses classif

The YARPC classifier reads the typed status code rather than matching its rendered message. Cancellation is retryable caller-side infrastructure; transient or ambiguous server codes (`Unknown`, `DeadlineExceeded`, `ResourceExhausted`, `Aborted`, `Internal`, and `Unavailable`) are retryable dependency failures; request verdicts and permanent server failures are non-retryable dependency failures. A deadline may expire after a mutating RPC succeeded, so this classification relies on the repository-wide requirement that queue-driven operations are idempotent.

Tests follow the same shape: assert per-node behaviour against `Classifier.Classify(node)` directly, and assert end-to-end behaviour by running `errs.NewClassifierProcessor(Classifier).Process(err)` and checking the helpers (`IsRetryable`, `IsUserError`, …) on the result. See `platform/errs/mysql/mysql_test.go`, `platform/errs/yarpc/yarpc_test.go`, and `platform/errs/generic/generic_test.go`.
The Git classifier reads `gitexec.CommandError`, which preserves the Git subcommand and the underlying `os/exec` error through contextual wrapping. A started `fetch`, `push`, or `ls-remote` process is a retryable dependency failure unless its diagnostic identifies a permanent authentication, repository, invocation, or configuration problem. Started local repository operations are retryable infrastructure failures under the same exception; commands that never started and unknown operations remain non-retryable by default.

Tests follow the same shape: assert per-node behaviour against `Classifier.Classify(node)` directly, and assert end-to-end behaviour by running `errs.NewClassifierProcessor(Classifier).Process(err)` and checking the helpers (`IsRetryable`, `IsUserError`, …) on the result. See `platform/errs/mysql/mysql_test.go`, `platform/errs/git/git_test.go`, `platform/errs/yarpc/yarpc_test.go`, and `platform/errs/generic/generic_test.go`.

## Overriding Classification from a Controller

Expand Down
24 changes: 24 additions & 0 deletions platform/errs/git/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = ["git.go"],
importpath = "github.com/uber/submitqueue/platform/errs/git",
visibility = ["//visibility:public"],
deps = [
"//platform/errs:go_default_library",
"//platform/git/exec:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = ["git_test.go"],
embed = [":go_default_library"],
deps = [
"//platform/errs:go_default_library",
"//platform/git/exec:go_default_library",
"@com_github_stretchr_testify//assert:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
],
)
80 changes: 80 additions & 0 deletions platform/errs/git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 git provides an errs.Classifier for failures from Git processes.
package git

import (
"strings"

"github.com/uber/submitqueue/platform/errs"
gitexec "github.com/uber/submitqueue/platform/git/exec"
)

// Classifier recognises structured Git command failures. Remote exchanges are
// retryable dependency failures; local repository operations are retryable
// infrastructure failures. Commands that never started and unknown operations
// remain unclassified and therefore fail fast.
var Classifier errs.Classifier = classifier{}

type classifier struct{}

var permanentDiagnosticFragments = []string{
"authentication failed",
"bad config line",
"does not appear to be a git repository",
"invalid refspec",
"not a git repository",
"permission denied (publickey)",
"repository not found",
"unknown option",
"unknown switch",
}

func (classifier) Classify(err error) errs.Verdict {
commandErr, ok := err.(*gitexec.CommandError)
if !ok || !commandErr.ProcessExited() {
return errs.Unknown
}

if isPermanentGitDiagnostic(commandErr.Diagnostic()) {
switch commandErr.Operation() {
case "fetch", "ls-remote", "push":
return errs.InfraDependency
default:
return errs.Infra
}
}

switch commandErr.Operation() {
case "fetch", "ls-remote", "push":
return errs.InfraDependencyRetryable
case "cat-file", "cherry-pick", "clean", "commit", "ls-files", "merge", "merge-base", "reset", "rev-list", "rev-parse", "show":
return errs.InfraRetryable
case "config":
return errs.Infra
default:
return errs.Unknown
}
}

func isPermanentGitDiagnostic(diagnostic string) bool {
diagnostic = strings.ToLower(diagnostic)
for _, fragment := range permanentDiagnosticFragments {
if strings.Contains(diagnostic, fragment) {
return true
}
}
return false
}
164 changes: 164 additions & 0 deletions platform/errs/git/git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// 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 git

import (
"errors"
"fmt"
"os"
"os/exec"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uber/submitqueue/platform/errs"
gitexec "github.com/uber/submitqueue/platform/git/exec"
)

type classifierFixtures struct {
exitError error
startError error
}

func setupClassifierFixtures(t *testing.T) classifierFixtures {
t.Helper()

err := exec.Command(os.Args[0], "-test.run=[").Run()
require.Error(t, err)
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)

return classifierFixtures{
exitError: exitErr,
startError: &exec.Error{Name: "git", Err: exec.ErrNotFound},
}
}

func TestClassifier(t *testing.T) {
fixtures := setupClassifierFixtures(t)
tests := []struct {
name string
err error
want errs.Verdict
}{
{
name: "remote fetch exit is retryable dependency failure",
err: gitexec.NewCommandError("fetch", "temporary remote failure", fixtures.exitError),
want: errs.InfraDependencyRetryable,
},
{
name: "remote push exit is retryable dependency failure",
err: gitexec.NewCommandError("push", "temporary remote failure", fixtures.exitError),
want: errs.InfraDependencyRetryable,
},
{
name: "remote authentication failure is permanent dependency failure",
err: gitexec.NewCommandError("fetch", "fatal: Authentication failed", fixtures.exitError),
want: errs.InfraDependency,
},
{
name: "local reset exit is retryable infrastructure failure",
err: gitexec.NewCommandError("reset", "checkout unavailable", fixtures.exitError),
want: errs.InfraRetryable,
},
{
name: "invalid local refspec is permanent infrastructure failure",
err: gitexec.NewCommandError("reset", "fatal: invalid refspec", fixtures.exitError),
want: errs.Infra,
},
{
name: "local configuration exit is permanent infrastructure failure",
err: gitexec.NewCommandError("config", "invalid configuration", fixtures.exitError),
want: errs.Infra,
},
{
name: "process start failure remains non-retryable",
err: gitexec.NewCommandError("fetch", "git executable missing", fixtures.startError),
want: errs.Unknown,
},
{
name: "unknown operation remains non-retryable",
err: gitexec.NewCommandError("unknown", "unsupported command", fixtures.exitError),
want: errs.Unknown,
},
{
name: "plain error remains unknown",
err: errors.New("anything"),
want: errs.Unknown,
},
{
name: "nil remains unknown",
err: nil,
want: errs.Unknown,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, Classifier.Classify(tt.err))
})
}
}

func TestClassifier_AppliedViaProcessor(t *testing.T) {
fixtures := setupClassifierFixtures(t)
tests := []struct {
name string
err error
wantRetryable bool
wantDependency bool
wantSame bool
}{
{
name: "wrapped fetch failure is retryable dependency",
err: fmt.Errorf("reset checkout: %w", gitexec.NewCommandError("fetch", "connection reset", fixtures.exitError)),
wantRetryable: true,
wantDependency: true,
},
{
name: "wrapped cherry-pick process failure is retryable locally",
err: fmt.Errorf("apply change: %w", gitexec.NewCommandError("cherry-pick", "process killed", fixtures.exitError)),
wantRetryable: true,
},
{
name: "configuration failure stays non-retryable",
err: fmt.Errorf("prepare checkout: %w", gitexec.NewCommandError("config", "invalid key", fixtures.exitError)),
wantSame: true,
},
{
name: "authentication failure stays non-retryable dependency",
err: fmt.Errorf("fetch target: %w", gitexec.NewCommandError("fetch", "fatal: Authentication failed", fixtures.exitError)),
wantDependency: true,
wantSame: false,
},
{
name: "unknown error stays non-retryable",
err: errors.New("anything"),
wantSame: true,
},
}

processor := errs.NewClassifierProcessor(Classifier)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := processor.Process(tt.err)
assert.Equal(t, tt.wantRetryable, errs.IsRetryable(got))
assert.Equal(t, tt.wantDependency, errs.IsDependencyError(got))
if tt.wantSame {
assert.Same(t, tt.err, got)
}
})
}
}
5 changes: 4 additions & 1 deletion platform/git/exec/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = ["gitexec.go"],
srcs = [
"command_error.go",
"gitexec.go",
],
importpath = "github.com/uber/submitqueue/platform/git/exec",
visibility = ["//visibility:public"],
)
Expand Down
74 changes: 74 additions & 0 deletions platform/git/exec/command_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// 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 gitexec

import "os/exec"

// CommandError preserves the failed Git operation and its process error for
// backend-specific classification after callers add contextual wrapping.
type CommandError struct {
operation string
message string
cause error
}

// NewCommandError records a failed Git operation without assigning retry
// policy. Callers supply the rendered diagnostic they want Error to expose.
func NewCommandError(operation, message string, cause error) *CommandError {
return &CommandError{
operation: operation,
message: message,
cause: cause,
}
}

// Error returns the command diagnostic supplied by the execution boundary.
func (e *CommandError) Error() string {
if e.message != "" {
return e.message
}
if e.cause != nil {
return e.cause.Error()
}
return "git command failed"
}

// Unwrap returns the process error reported by os/exec.
func (e *CommandError) Unwrap() error {
return e.cause
}

// Operation returns the Git subcommand, such as fetch or cherry-pick.
func (e *CommandError) Operation() string {
return e.operation
}

// Diagnostic returns Git's rendered failure output.
func (e *CommandError) Diagnostic() string {
return e.message
}

// ProcessExited reports whether Git started and returned a non-zero exit.
func (e *CommandError) ProcessExited() bool {
_, ok := e.cause.(*exec.ExitError)
return ok
}

func commandOperation(args []string) string {
if len(args) == 0 {
return ""
}
return args[0]
}
Loading