Retry git clone on secondary rate limit (bare 403/429) (#5260)
Lint / golangci-lint (push) Waiting to run
Lint / man-page-staleness (push) Waiting to run
Lint / semgrep (push) Waiting to run
Lint / checksecretparts (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run
Release / mark-latest (push) Canceled after 0s
Release / Release (push) Canceled after 0s
Lint / golangci-lint (push) Waiting to run
Lint / man-page-staleness (push) Waiting to run
Lint / semgrep (push) Waiting to run
Lint / checksecretparts (push) Waiting to run
Scan for secrets / test (push) Waiting to run
Test / test (push) Waiting to run
Test / test-community (push) Waiting to run
Release / mark-latest (push) Canceled after 0s
Release / Release (push) Canceled after 0s
Clone-time 403s were classified as permanent permission errors and failed the scan immediately, even though a bare 403 (no auth/remote denial message) matches GitHub/GitLab secondary rate limiting, which per their docs returns 403 or 429. Retry those like 429 with backoff.
This commit is contained in:
+40
-10
@@ -41,11 +41,18 @@ import (
|
|||||||
const (
|
const (
|
||||||
SourceType = sourcespb.SourceType_SOURCE_TYPE_GIT
|
SourceType = sourcespb.SourceType_SOURCE_TYPE_GIT
|
||||||
// maxCloneAttempts is the total number of times a clone is attempted when
|
// maxCloneAttempts is the total number of times a clone is attempted when
|
||||||
// each failure is classified as a transient network error.
|
// each failure is classified as a transient network or rate-limit error.
|
||||||
maxCloneAttempts = 3
|
maxCloneAttempts = 3
|
||||||
// cloneRetryBackoff is the base wait between clone attempts; it is
|
// cloneRetryBackoff is the base wait between clone attempts after a
|
||||||
// multiplied by the number of failed attempts so far.
|
// transient network error; it is multiplied by the number of failed
|
||||||
|
// attempts so far.
|
||||||
cloneRetryBackoff = 5 * time.Second
|
cloneRetryBackoff = 5 * time.Second
|
||||||
|
// cloneRateLimitBackoff is the base wait after a clone attempt fails
|
||||||
|
// with what looks like a GitHub/GitLab secondary rate limit (bare 403
|
||||||
|
// or 429). GitHub's guidance for secondary rate limits is to wait at
|
||||||
|
// least a minute before retrying, so this starts well above the
|
||||||
|
// network-error backoff and grows per attempt.
|
||||||
|
cloneRateLimitBackoff = 60 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type Source struct {
|
type Source struct {
|
||||||
@@ -484,8 +491,9 @@ type cloneParams struct {
|
|||||||
// outer function for centralized error handling and cleanup.
|
// outer function for centralized error handling and cleanup.
|
||||||
//
|
//
|
||||||
// Failures classified as transient network errors (e.g. a connection reset
|
// Failures classified as transient network errors (e.g. a connection reset
|
||||||
// mid-transfer) are retried up to maxCloneAttempts times, each attempt
|
// mid-transfer) or as a secondary rate limit (a bare 403 or 429 from the
|
||||||
// starting from a fresh clone directory. All other failures, including clone
|
// remote) are retried up to maxCloneAttempts times, each attempt starting
|
||||||
|
// from a fresh clone directory. All other failures, including clone
|
||||||
// timeouts (see feature.GitCloneTimeoutDuration), are returned immediately.
|
// timeouts (see feature.GitCloneTimeoutDuration), are returned immediately.
|
||||||
func CloneRepo(ctx context.Context, userInfo *url.Userinfo, gitURL string, clonePath string, authInUrl bool, args ...string) (string, *git.Repository, error) {
|
func CloneRepo(ctx context.Context, userInfo *url.Userinfo, gitURL string, clonePath string, authInUrl bool, args ...string) (string, *git.Repository, error) {
|
||||||
timeout := time.Duration(feature.GitCloneTimeoutDuration.Load())
|
timeout := time.Duration(feature.GitCloneTimeoutDuration.Load())
|
||||||
@@ -520,15 +528,17 @@ func CloneRepo(ctx context.Context, userInfo *url.Userinfo, gitURL string, clone
|
|||||||
return "", nil, fmt.Errorf("failed to clean clone path for retry: %w (original clone error: %w)", rmErr, err)
|
return "", nil, fmt.Errorf("failed to clean clone path for retry: %w (original clone error: %w)", rmErr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Logger().Info("git clone interrupted by network error; retrying",
|
delay := cloneRetryDelay(err, attempt)
|
||||||
|
ctx.Logger().Info("git clone interrupted; retrying",
|
||||||
"attempt", attempt,
|
"attempt", attempt,
|
||||||
"max_attempts", maxCloneAttempts,
|
"max_attempts", maxCloneAttempts,
|
||||||
|
"delay", delay.String(),
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
)
|
)
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return "", nil, ctx.Err()
|
return "", nil, ctx.Err()
|
||||||
case <-time.After(cloneRetryBackoff * time.Duration(attempt)):
|
case <-time.After(delay):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,10 +546,30 @@ func CloneRepo(ctx context.Context, userInfo *url.Userinfo, gitURL string, clone
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isRetryableCloneError reports whether a clone failure looks like a
|
// isRetryableCloneError reports whether a clone failure looks like a
|
||||||
// transient network interruption (e.g. a connection reset mid-transfer)
|
// transient condition (a network interruption, e.g. a connection reset
|
||||||
// rather than a permanent condition like an auth or permission error.
|
// mid-transfer, or a secondary rate limit) rather than a permanent
|
||||||
|
// condition like an auth or permission error.
|
||||||
func isRetryableCloneError(err error) bool {
|
func isRetryableCloneError(err error) bool {
|
||||||
return err != nil && ClassifyCloneError(err.Error()) == cloneFailureNetwork
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch ClassifyCloneError(err.Error()) {
|
||||||
|
case cloneFailureNetwork, cloneFailureRateLimit:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRetryDelay returns how long to wait before the next clone attempt,
|
||||||
|
// scaled by the failure class: rate-limit errors back off much more slowly
|
||||||
|
// than transient network errors, since GitHub/GitLab secondary rate limits
|
||||||
|
// take on the order of a minute or more to clear.
|
||||||
|
func cloneRetryDelay(err error, attempt int) time.Duration {
|
||||||
|
if ClassifyCloneError(err.Error()) == cloneFailureRateLimit {
|
||||||
|
return cloneRateLimitBackoff * time.Duration(attempt)
|
||||||
|
}
|
||||||
|
return cloneRetryBackoff * time.Duration(attempt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createClonePath creates the directory a repository will be cloned into and
|
// createClonePath creates the directory a repository will be cloned into and
|
||||||
|
|||||||
@@ -76,14 +76,36 @@ error: 7457 bytes of body are still expected
|
|||||||
fetch-pack: unexpected disconnect while reading sideband packet
|
fetch-pack: unexpected disconnect while reading sideband packet
|
||||||
fatal: early EOF
|
fatal: early EOF
|
||||||
fatal: fetch-pack: invalid index-pack output`,
|
fatal: fetch-pack: invalid index-pack output`,
|
||||||
|
// Bare 403/429 during clone matches GitHub/GitLab secondary rate
|
||||||
|
// limiting (no accompanying auth/permission message).
|
||||||
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
|
||||||
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, The requested URL returned error: 429",
|
||||||
|
// Normal clone progress ("remote: Counting objects...") must not be
|
||||||
|
// mistaken for a denial explanation when a later 403 is just throttling.
|
||||||
|
`could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, remote: Enumerating objects: 100, done.
|
||||||
|
remote: Counting objects: 100% (100/100), done.
|
||||||
|
fatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403`,
|
||||||
}
|
}
|
||||||
for _, msg := range retryable {
|
for _, msg := range retryable {
|
||||||
assert.True(t, isRetryableCloneError(errors.New(msg)), "expected retryable: %q", msg)
|
assert.True(t, isRetryableCloneError(errors.New(msg)), "expected retryable: %q", msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
notRetryable := []string{
|
notRetryable := []string{
|
||||||
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, remote: The requested URL returned error: 403",
|
// An explicit permission-denial message, as opposed to a bare 403.
|
||||||
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, The requested URL returned error: 429",
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, remote: You are not allowed to download code from this project.",
|
||||||
|
// The explicit denial message must win even when the combined clone
|
||||||
|
// output also contains a literal "403" elsewhere.
|
||||||
|
"could not clone repo: https://gitlab.com/org/repo.git, error executing git clone: exit status 128, fatal: unable to access 'https://gitlab.com/org/repo.git/': The requested URL returned error: 403\nremote: You are not allowed to download code from this project.",
|
||||||
|
// GitHub's explicit permission-denial message, which also co-occurs
|
||||||
|
// with a literal "403" in the combined clone output.
|
||||||
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, remote: Permission to org/repo.git denied to user.\nfatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
|
||||||
|
// GitHub Apps/fine-grained-token variant: "Write access ... not
|
||||||
|
// granted", also co-occurring with a literal 403.
|
||||||
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, remote: Write access to repository not granted.\nfatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
|
||||||
|
// SAML SSO enforcement: an unrecognized "remote:" explanation
|
||||||
|
// co-occurring with a 403, which the classifier must treat as a
|
||||||
|
// permanent failure without needing this exact wording enumerated.
|
||||||
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, remote: The organization has enabled or enforced SAML SSO.\nfatal: unable to access 'https://github.com/org/repo.git/': The requested URL returned error: 403",
|
||||||
"git clone timed out (after 1h0m0s)",
|
"git clone timed out (after 1h0m0s)",
|
||||||
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, fatal: repository 'https://github.com/org/repo.git/' not found",
|
"could not clone repo: https://github.com/org/repo.git, error executing git clone: exit status 128, fatal: repository 'https://github.com/org/repo.git/' not found",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,14 +105,27 @@ func ClassifyCloneError(errMsg string) string {
|
|||||||
strings.Contains(errMsg, "redirect:") && strings.Contains(errMsg, "users/sign_in"):
|
strings.Contains(errMsg, "redirect:") && strings.Contains(errMsg, "users/sign_in"):
|
||||||
return cloneFailureAuth
|
return cloneFailureAuth
|
||||||
|
|
||||||
case strings.Contains(errMsg, "The requested URL returned error: 429") ||
|
// Checked before the generic 403/429 case below: when the git backend
|
||||||
strings.Contains(errMsg, "remote: Retry later"):
|
// itself explains a 403/429 with a "remote:" line, that's a permanent
|
||||||
return cloneFailureRateLimit
|
// failure (permission denial, SAML SSO enforcement, org policy, etc.),
|
||||||
|
// not throttling — the backend only sends an explanatory message once
|
||||||
case strings.Contains(errMsg, "The requested URL returned error: 403") ||
|
// the request has been evaluated, whereas secondary-rate-limit responses
|
||||||
strings.Contains(errMsg, "remote: You are not allowed to download code from this project"):
|
// are either a bare 403/429 with no remote explanation or one of the
|
||||||
|
// small, stable set of known throttling messages. Matching on "any
|
||||||
|
// unrecognized remote: explanation" avoids having to enumerate every
|
||||||
|
// provider's denial wording, which changes per provider and per feature
|
||||||
|
// (SSO, fine-grained tokens, GitHub Apps, ...).
|
||||||
|
case isRemotePermissionDenial(errMsg):
|
||||||
return cloneFailurePermission
|
return cloneFailurePermission
|
||||||
|
|
||||||
|
case strings.Contains(errMsg, "The requested URL returned error: 429") ||
|
||||||
|
strings.Contains(errMsg, "The requested URL returned error: 403") ||
|
||||||
|
strings.Contains(errMsg, "remote: Retry later"):
|
||||||
|
// A bare "403" during clone (no accompanying auth/permission message)
|
||||||
|
// matches GitHub/GitLab secondary rate limiting and abuse-detection
|
||||||
|
// responses, which are documented to return either 403 or 429.
|
||||||
|
return cloneFailureRateLimit
|
||||||
|
|
||||||
case strings.Contains(errMsg, "RPC failed") ||
|
case strings.Contains(errMsg, "RPC failed") ||
|
||||||
strings.Contains(errMsg, "unexpected disconnect") ||
|
strings.Contains(errMsg, "unexpected disconnect") ||
|
||||||
strings.Contains(errMsg, "early EOF") ||
|
strings.Contains(errMsg, "early EOF") ||
|
||||||
@@ -128,3 +141,63 @@ func ClassifyCloneError(errMsg string) string {
|
|||||||
return cloneFailureOther
|
return cloneFailureOther
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// knownRateLimitRemoteMessages are the (small, stable) set of ways GitHub and
|
||||||
|
// GitLab phrase an actual throttling response in a "remote:" line. Any other
|
||||||
|
// "remote:" explanation accompanying a 403/429 is treated as a permanent
|
||||||
|
// failure rather than added here, since provider denial wording (permission,
|
||||||
|
// SSO, org policy, ...) varies far more than throttling wording does.
|
||||||
|
var knownRateLimitRemoteMessages = []string{
|
||||||
|
"retry later",
|
||||||
|
"secondary rate limit",
|
||||||
|
"rate limit exceeded",
|
||||||
|
"abuse detection",
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownBenignRemoteProgressMessages are the small, standardized set of
|
||||||
|
// progress lines git-upload-pack prints to "remote:" during a normal clone
|
||||||
|
// (e.g. "remote: Counting objects: 100% (5/5), done."). These can appear
|
||||||
|
// ahead of an unrelated 403/429 or network failure, so they must not be
|
||||||
|
// mistaken for a denial explanation.
|
||||||
|
var knownBenignRemoteProgressMessages = []string{
|
||||||
|
"enumerating objects",
|
||||||
|
"counting objects",
|
||||||
|
"compressing objects",
|
||||||
|
"writing objects",
|
||||||
|
"resolving deltas",
|
||||||
|
"total ",
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownBenignRemoteMessages is the combined set of "remote:" line contents
|
||||||
|
// that must NOT be treated as a permission denial: known throttling messages
|
||||||
|
// and known clone-progress output.
|
||||||
|
var knownBenignRemoteMessages = append(append([]string{}, knownRateLimitRemoteMessages...), knownBenignRemoteProgressMessages...)
|
||||||
|
|
||||||
|
// isRemotePermissionDenial reports whether errMsg contains a server-side
|
||||||
|
// "remote:" line explaining a 403/429 as something other than throttling or
|
||||||
|
// normal clone progress output, as opposed to a bare curl-level 403/429 with
|
||||||
|
// no explanation (or an explicit rate-limit message), which may just be
|
||||||
|
// transient rate limiting.
|
||||||
|
func isRemotePermissionDenial(errMsg string) bool {
|
||||||
|
if !strings.Contains(errMsg, "403") && !strings.Contains(errMsg, "429") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(errMsg, "\n") {
|
||||||
|
idx := strings.Index(line, "remote:")
|
||||||
|
if idx == -1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteMsg := strings.ToLower(line[idx:])
|
||||||
|
benign := false
|
||||||
|
for _, marker := range knownBenignRemoteMessages {
|
||||||
|
if strings.Contains(remoteMsg, marker) {
|
||||||
|
benign = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !benign {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user