Skip to content
Open
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`).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The git classifier is added to the examples list and the wiring snippet, but not to the semantics section further down — where httperrs/mysqlerrs ordering and the YARPC status-code mapping each get a paragraph explaining why they classify the way they do.

That gap matters more than usual here, because two paragraphs below this line the README says:

The YARPC classifier reads the typed status code rather than matching its rendered message.

and the git classifier does the opposite. If the message-matching approach survives review, it needs a paragraph saying why git is the exception, what happens when the fragment list goes stale, and which direction the default errs in. If it's inverted per the main comment, it needs a paragraph saying the retryable set is a closed allowlist of transient signals and that unlisted failures dead-letter on the first attempt.

Also worth stating explicitly: this classifier is registered before mysqlerrs, and unlike the httperrs/mysqlerrs pair there's no net.Error ambiguity between them (*exec.ExitError doesn't satisfy it), so the ordering is free. A sentence saying so saves the next person the analysis.


🤖 [posted by agent] — automated review by Claude Code, requested by @sbalabanov.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged, and my comment was wrong on its premise: the PR did add a semantics paragraph. I skipped that hunk when reading the diff and claimed it was missing. Apologies.

I have not acted on what the comment actually asked for — no note about the YARPC contrast, none about classifier ordering. The paragraph is edited only because the behaviour it documents changed: it described retryable-unless-permanent, which is no longer what the code does. It now states the pair rule, that only a recognised pair is retryable, and where cancellation is handled. Same position, comparable length.


🤖 [addressed by agent] — Claude Code, at @sbalabanov's request.


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",
],
)
150 changes: 150 additions & 0 deletions platform/errs/git/git.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// 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",
"remote end hung up unexpectedly",
"rpc failed",
"ssh_exchange_identification",
"temporary failure in name resolution",
"transfer closed with outstanding read data remaining",
"unexpected disconnect while reading sideband packet",
Comment on lines +94 to +99

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three fragments are git's generic trailers, not transport evidence. They will nack permanent git-http 4xx the same way they nack a connection reset.

A typical forbidden push looks like:

error: RPC failed; HTTP 403 curl 22 The requested URL returned error: 403
send-pack: unexpected disconnect while reading sideband packet
fatal: the remote end hung up unexpectedly

Lowercased, that hits rpc failed, unexpected disconnect while reading sideband packet, and remote end hung up unexpectedlyInfraDependencyRetryable. The PR's invariant is that authentication / rejected pushes stay non-retryable. The tests only cover the clean strings (Authentication failed, ! [rejected] … fetch first), not the trailer-bearing stderr git actually emits.

Same class of over-match you already excluded for Could not read from remote repository.

Fix: drop these three as standalone evidence (or only pair them with a real transport token). Prefer the curl/OS fragments already on the list (connection reset by peer, connection timed out, could not resolve host, …) plus specific ones like curl 56 / curl 28 / curl 7. If you keep HTTP status text, allow 502/503/504 (and maybe 429/408) and exclude HTTP 401 / HTTP 403 / HTTP 404. Please add a classifier case with the 403 stderr above expecting InfraDependency.


Generated by Cursor. Posted on behalf of @preetam_UBER.

}

// 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 on lines +115 to +123

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gate throws away the one signal that reliably means "transient".

*exec.ExitError carries ProcessState, so ExitCode() and Signaled()/Signal() are both available here — and a git process killed by a signal (OOM-killer, SIGTERM during drain, the kill exec.CommandContext issues on context cancellation) is the clearest transient case there is. Right now that case is only classified correctly by accident, because the blanket default happens to cover it.

Note that context.Canceled never reaches genericerrs.Classifier on this path either: CommandContext kills the child and returns *exec.ExitError ("signal: killed"), so the chain is fmt.Errorf → *CommandError → *exec.ExitError with no context.Canceled node anywhere. Graceful-shutdown nacking works today only via the blanket default. If the default is inverted per the other comment, signal-detection has to be added explicitly or shutdown behaviour regresses — worth a test either way.

Concretely: have CommandError capture exitCode int and signaled bool at construction, and branch on those rather than on the subcommand.


🤖 [posted by agent] — automated review by Claude Code, requested by @sbalabanov.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signal termination should be handled by generic classifier, not git-specific classifier. Context cancellation should be properly surfaced up. Investigate if it is not the case and post here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — signal handling is out of the Git classifier, and the investigation you asked for is below.

Context cancellation is not surfaced today. os/exec swallows it. Probe against Go 1.26:

cancelled  err="signal: killed"  errors.Is(err, context.Canceled)=false  errors.As(err, &exec.ExitError)=true  ctx.Err()=context canceled
deadline   err="signal: killed"  errors.Is(err, context.DeadlineExceeded)=false  errors.As(err, &exec.ExitError)=true  ctx.Err()=context deadline exceeded

exec.CommandContext kills the child and Wait reports only the death. Neither context sentinel appears anywhere in the chain — ctx.Err() is the only place the distinction still exists. So genericerrs.Classifier, which tests err == context.Canceled, could never fire on a cancelled merge no matter where it sits in the list. That was true before this PR too; nothing was routing it.

Fix: gitexec.CommandFailure(ctx, args, message, cause) is now the single construction point for a failed invocation, used by Output, runAs, runCombinedAs and isAncestor. When ctx.Err() != nil it returns that error wrapped with the operation and diagnostic, so the chain carries context.Canceled and the generic classifier recognises it; otherwise it returns a *CommandError as before. The Git classifier no longer looks at signals at all, and TestClassifier_LeavesCancellationToTheGenericClassifier pins that it returns Unknown for a cancelled command so the generic one gets the node.

Two things worth knowing, neither fixed here:

  1. genericerrs.Classifier handles context.Canceled but not context.DeadlineExceeded. A merge that blows its deadline now surfaces DeadlineExceeded into the chain, matches nothing, and stays non-retryable. Cheap to add — one case in platform/errs/generic — but that classifier is shared across every domain, so I left it alone rather than changing cross-domain retry behaviour inside a Runway fix. Happy to do it here or in a follow-up, your call.
  2. A git process killed by something other than a context — OOM killer, an operator SIGKILL — has no context error to read and no transient fragment in its output, so it is now permanent where the previous revision retried it. That follows directly from "only known combinations become retryable", so I have left it that way; if you want it retryable the honest place is a signal check in the generic classifier, since it is not Git-specific.

🤖 [addressed by agent] — Claude Code, at @sbalabanov's request.


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