Skip to content
Merged
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`).
Comment thread
sbalabanov marked this conversation as resolved.

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. Git has no typed status to read — a connection reset and a deleted branch both leave `fetch` at a non-zero exit — so the classifier pairs the subcommand with the diagnostic git printed: a transport fragment counts only against a command that talks to the remote, and a lock fragment counts against any command that writes to the checkout. Only a recognised pair is retryable; every other Git failure, including a diagnostic the package has never seen, is a permanent infrastructure failure attributed to the remote or to this service. The direction is deliberate — an unlisted transient failure costs one lost retry, while a permanent failure defaulting to retryable would replay a deterministic error through the whole retry budget before dead-lettering anyway — and it is what makes the fragment lists safe to extend as Git's wording drifts between versions. Cancellation is not the Git classifier's to report: `os/exec` kills a context-cancelled child and reports only `signal: killed`, so `gitexec.CommandFailure` puts `context.Canceled` back in the chain and the generic classifier recognises it there.

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/errs/generic:go_default_library",
"//platform/git/exec:go_default_library",
"@com_github_stretchr_testify//assert:go_default_library",
],
)
147 changes: 147 additions & 0 deletions platform/errs/git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// 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.
//
// Git has no typed status to read: it reports almost everything as a non-zero
// exit and a line of prose, so a connection reset and a deleted branch both
// leave `git fetch` looking identical to a caller that only checks the code.
// The subcommand that was run and the diagnostic git printed are therefore the
// only signals available, and the classification pairs them: a fragment is
// evidence of a transient failure only for the operations it can actually
// arise from, so a transport fault counts against a command that talks to the
// remote and lock contention counts against any command that writes to the
// checkout.
//
// Only a recognised pair is retryable. Everything else is a permanent
// infrastructure failure, including a diagnostic this package has never seen.
// The direction is deliberate: an unlisted transient failure costs one lost
// retry, while a permanent failure that defaulted to retryable would replay a
// deterministic error — a deleted target branch, an empty squash commit, a
// rejected push — through the whole retry budget, re-running the fetch, reset
// and cherry-picks behind it each time, before dead-lettering anyway.
//
// Git's wording drifts between versions, so the fragment lists are expected to
// grow. Adding one is cheap and safe; the cost of a missing fragment is bounded
// at a single lost retry, which is what makes the allowlist maintainable.
//
// Cancellation is deliberately absent. A git process killed because its
// context ended dies with "signal: killed" and no trace of the cancellation,
// so it is gitexec.CommandFailure — not this classifier — that puts
// context.Canceled back in the chain, leaving the generic classifier to
// recognise it as it does for every other cancelled operation.
package git

import (
"strings"

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

// Classifier recognises Git process failures, reporting a known transient
// diagnostic on an operation it can arise from as retryable and every other
// Git failure as permanent. See the package doc for why the default runs that
// way.
//
// The classifier is stateless; this package-level singleton is the canonical
// handle. Pass it as one of the variadic classifiers to
// errs.NewClassifierProcessor; the resulting processor is what gets handed to
// consumer.New.
var Classifier errs.Classifier = classifier{}

type classifier struct{}

// remoteOperations are the Git subcommands that exchange data with the
// configured remote. They attribute their failures to that remote, and they
// are the only operations a transport fragment can legitimately describe.
var remoteOperations = map[string]bool{
"clone": true,
"fetch": true,
"ls-remote": true,
"pull": true,
"push": true,
}

// transientTransportFragments are diagnostics that mean the exchange with the
// remote did not complete, weighed only for a remoteOperations subcommand.
// A rejected push or a failed authentication is the remote answering, not
// failing to answer, and stays permanent.
var transientTransportFragments = []string{
"502 bad gateway",
"503 service unavailable",
"504 gateway timeout",
"broken pipe",
"connection refused",
"connection reset by peer",
"connection timed out",
"could not resolve host",
"early eof",
"network is unreachable",
"no route to host",
"operation timed out",
"ssh_exchange_identification",
"temporary failure in name resolution",
"transfer closed with outstanding read data remaining",
}

// transientCheckoutFragments are diagnostics that mean another process held
// the checkout, weighed for every subcommand: a remote operation writes refs
// and the index too, so it can lose the same race a local one can.
var transientCheckoutFragments = []string{
".lock': file exists",
"cannot lock ref",
"index.lock",
"resource temporarily unavailable",
}

// Classify inspects a single node. Per the errs.Classifier contract, this must
// not call errors.Is / errors.As — the classifier-processor owns the chain
// walk.
func (classifier) Classify(err error) errs.Verdict {
commandErr, ok := err.(*gitexec.CommandError)
if !ok {
// The only Unknown this classifier returns, and it means "not my
// node" rather than "no opinion on this failure". Returning a verdict
// here would claim every error the walk passes — a MySQL driver error
// among them — before its own classifier were asked.
return errs.Unknown
}
Comment thread
sbalabanov marked this conversation as resolved.

diagnostic := strings.ToLower(commandErr.Diagnostic())
remote := remoteOperations[commandErr.Operation()]

transient := containsAny(diagnostic, transientCheckoutFragments) ||
(remote && containsAny(diagnostic, transientTransportFragments))

switch {
case transient && remote:
return errs.InfraDependencyRetryable
case transient:
return errs.InfraRetryable
case remote:
return errs.InfraDependency
default:
return errs.Infra
}
}

func containsAny(diagnostic string, fragments []string) bool {
for _, fragment := range fragments {
if strings.Contains(diagnostic, fragment) {
return true
}
}
return false
}
Loading
Loading