Skip to content

Commit 5ffc09f

Browse files
authored
Add project clone retries functionality (#1613)
Signed-off-by: David Kwon <dakwon@redhat.com> Assisted-by: Claude Code Opus 4.6
1 parent fcee0cd commit 5ffc09f

File tree

2 files changed

+47
-5
lines changed

2 files changed

+47
-5
lines changed

project-clone/internal/git/setup.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright (c) 2019-2025 Red Hat, Inc.
2+
// Copyright (c) 2019-2026 Red Hat, Inc.
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
55
// You may obtain a copy of the License at
@@ -20,6 +20,7 @@ import (
2020
"log"
2121
"os"
2222
"path"
23+
"time"
2324

2425
dw "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
2526
projectslib "github.com/devfile/devworkspace-operator/pkg/library/projects"
@@ -46,9 +47,24 @@ func doInitialGitClone(project *dw.Project) error {
4647
// Clone into a temp dir and then move set up project to PROJECTS_ROOT to try and make clone atomic in case
4748
// project-clone container is terminated
4849
tmpClonePath := path.Join(internal.CloneTmpDir, projectslib.GetClonePath(project))
49-
err := CloneProject(project, tmpClonePath)
50-
if err != nil {
51-
return fmt.Errorf("failed to clone project: %s", err)
50+
var cloneErr error
51+
for attempt := 0; attempt <= internal.CloneRetries; attempt++ {
52+
if attempt > 0 {
53+
delayBeforeRetry(project.Name, attempt)
54+
if err := os.RemoveAll(tmpClonePath); err != nil {
55+
log.Printf("Warning: cleanup before retry failed: %s", err)
56+
}
57+
}
58+
cloneErr = CloneProject(project, tmpClonePath)
59+
if cloneErr == nil {
60+
break
61+
}
62+
if attempt < internal.CloneRetries {
63+
log.Printf("Failed git clone for project %s (attempt %d/%d): %s", project.Name, attempt+1, internal.CloneRetries+1, cloneErr)
64+
}
65+
}
66+
if cloneErr != nil {
67+
return fmt.Errorf("failed to clone project: %w", cloneErr)
5268
}
5369

5470
if project.Attributes.Exists(internal.ProjectSparseCheckout) {
@@ -83,6 +99,12 @@ func doInitialGitClone(project *dw.Project) error {
8399
return nil
84100
}
85101

102+
func delayBeforeRetry(projectName string, attempt int) {
103+
delay := internal.BaseRetryDelay * (1 << (attempt - 1))
104+
log.Printf("Retrying git clone for project %s (attempt %d/%d) after %s", projectName, attempt+1, internal.CloneRetries+1, delay)
105+
time.Sleep(delay)
106+
}
107+
86108
func setupRemotesForExistingProject(project *dw.Project) error {
87109
projectPath := path.Join(internal.ProjectsRoot, projectslib.GetClonePath(project))
88110
repo, err := internal.OpenRepo(projectPath)

project-clone/internal/global.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//
2-
// Copyright (c) 2019-2025 Red Hat, Inc.
2+
// Copyright (c) 2019-2026 Red Hat, Inc.
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
55
// You may obtain a copy of the License at
@@ -21,6 +21,8 @@ import (
2121
"log"
2222
"os"
2323
"regexp"
24+
"strconv"
25+
"time"
2426

2527
"github.com/devfile/devworkspace-operator/pkg/library/constants"
2628
gittransport "github.com/go-git/go-git/v5/plumbing/transport"
@@ -33,11 +35,16 @@ const (
3335
credentialsMountPath = "/.git-credentials/credentials"
3436
sshConfigMountPath = "/etc/ssh/ssh_config"
3537
publicCertsDir = "/public-certs"
38+
cloneRetriesEnvVar = "PROJECT_CLONE_RETRIES"
39+
defaultCloneRetries = 3
40+
maxCloneRetries = 10
41+
BaseRetryDelay = 1 * time.Second
3642
)
3743

3844
var (
3945
ProjectsRoot string
4046
CloneTmpDir string
47+
CloneRetries int
4148
tokenAuthMethod map[string]*githttp.BasicAuth
4249
credentialsRegex = regexp.MustCompile(`https://(.+):(.+)@(.+)`)
4350
)
@@ -59,6 +66,19 @@ func init() {
5966
log.Printf("Using temporary directory %s", tmpDir)
6067
CloneTmpDir = tmpDir
6168

69+
CloneRetries = defaultCloneRetries
70+
if val := os.Getenv(cloneRetriesEnvVar); val != "" {
71+
parsed, err := strconv.Atoi(val)
72+
if err != nil || parsed < 0 {
73+
log.Printf("Invalid value for %s: %q, using default (%d)", cloneRetriesEnvVar, val, defaultCloneRetries)
74+
} else if parsed > maxCloneRetries {
75+
log.Printf("Value for %s (%d) exceeds maximum (%d), using maximum", cloneRetriesEnvVar, parsed, maxCloneRetries)
76+
CloneRetries = maxCloneRetries
77+
} else {
78+
CloneRetries = parsed
79+
}
80+
}
81+
6282
setupAuth()
6383
}
6484

0 commit comments

Comments
 (0)