Skip to content

Commit 4a9061a

Browse files
author
Antonio Salinas
authored
feat: Implement Retry Logic to Reduce Pkg Failures (#461)
* Added retry logic * Updated scenario * Addressed feedback * network retries flag set correctly
1 parent 434b289 commit 4a9061a

18 files changed

Lines changed: 530 additions & 38 deletions

internal/app/azldev/app.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/gim-home/azldev-preview/internal/global/opctx"
2020
"github.com/gim-home/azldev-preview/internal/projectconfig"
2121
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
22+
"github.com/gim-home/azldev-preview/internal/utils/retry"
2223
"github.com/lmittmann/tint"
2324
"github.com/mattn/go-isatty"
2425
"github.com/muesli/termenv"
@@ -44,6 +45,7 @@ type App struct {
4445
quiet bool
4546
acceptAllPrompts bool
4647
dryRun bool
48+
networkRetries int
4749
reportFormat ReportFormat
4850
disableDefaultConfig bool
4951
configFiles []string
@@ -124,6 +126,7 @@ func NewApp(fsFactory opctx.FileSystemFactory, osEnvFactory opctx.OSEnvFactory)
124126
env.SetDefaultReportFormat(app.reportFormat)
125127
env.SetAcceptAllPrompts(app.acceptAllPrompts)
126128
env.SetColorMode(app.colorMode)
129+
env.SetNetworkRetries(app.networkRetries)
127130

128131
return nil
129132
},
@@ -156,6 +159,8 @@ func NewApp(fsFactory opctx.FileSystemFactory, osEnvFactory opctx.OSEnvFactory)
156159
app.cmd.PersistentFlags().StringArrayVar(&app.configFiles, "config-file", nil,
157160
"additional TOML config file(s) to merge (may be repeated)")
158161
app.cmd.PersistentFlags().BoolVarP(&app.dryRun, "dry-run", "n", false, "dry run only (do not take action)")
162+
app.cmd.PersistentFlags().IntVar(&app.networkRetries, "network-retries", retry.DefaultMaxAttempts,
163+
"maximum number of attempts for network operations (minimum 1)")
159164
app.cmd.PersistentFlags().VarP(&app.reportFormat, "output-format", "O",
160165
"output format {csv, json, markdown, table}")
161166
app.cmd.PersistentFlags().Var(&app.colorMode, "color",
@@ -289,7 +294,6 @@ func (a *App) Execute(args []string) int {
289294
env := NewEnv(ctx, *envOptions)
290295
env.SetVerbose(a.verbose)
291296
env.SetQuiet(a.quiet)
292-
293297
env.SetColorMode(a.colorMode)
294298
//
295299
// If we managed to find a project + configuration, then we can let anyone who was

internal/app/azldev/env.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type Env struct {
6565
quiet bool
6666
promptsAllowed bool
6767
acceptAllPrompts bool
68+
networkRetries int
6869

6970
// Injected dependencies.
7071
cmdFactory opctx.CmdFactory
@@ -171,6 +172,21 @@ func (env *Env) SetVerbose(verbose bool) {
171172
env.verbose = verbose
172173
}
173174

175+
// NetworkRetries returns the maximum number of attempts for network operations.
176+
func (env *Env) NetworkRetries() int {
177+
return env.networkRetries
178+
}
179+
180+
// SetNetworkRetries sets the maximum number of attempts for network operations.
181+
// Values less than 1 are clamped to 1.
182+
func (env *Env) SetNetworkRetries(retries int) {
183+
if retries < 1 {
184+
retries = 1
185+
}
186+
187+
env.networkRetries = retries
188+
}
189+
174190
// SetEventListener registers the event listener to be used in this environment.
175191
func (env *Env) SetEventListener(eventListener opctx.EventListener) {
176192
env.eventListener = eventListener

internal/app/azldev/env_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,28 @@ func TestNewEnv(t *testing.T) {
7272
assert.Equal(t, env.Err(), ctx.Err())
7373
}
7474

75+
func TestSetNetworkRetries(t *testing.T) {
76+
tests := []struct {
77+
name string
78+
input int
79+
expected int
80+
}{
81+
{"positive value is preserved", 5, 5},
82+
{"one is preserved", 1, 1},
83+
{"zero is clamped to 1", 0, 1},
84+
{"negative value is clamped to 1", -1, 1},
85+
{"large negative is clamped to 1", -100, 1},
86+
}
87+
88+
for _, tt := range tests {
89+
t.Run(tt.name, func(t *testing.T) {
90+
testEnv := testutils.NewTestEnv(t)
91+
testEnv.Env.SetNetworkRetries(tt.input)
92+
assert.Equal(t, tt.expected, testEnv.Env.NetworkRetries())
93+
})
94+
}
95+
}
96+
7597
func TestEnvConstructionTime(t *testing.T) {
7698
testEnv := testutils.NewTestEnv(t)
7799

internal/providers/sourceproviders/fedorasource/fedorasource.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/gim-home/azldev-preview/internal/global/opctx"
1818
"github.com/gim-home/azldev-preview/internal/utils/downloader"
1919
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
20+
"github.com/gim-home/azldev-preview/internal/utils/retry"
2021
)
2122

2223
type FedoraSourceDownloader interface {
@@ -30,6 +31,7 @@ type FedoraSourceDownloaderImpl struct {
3031
dryRunnable opctx.DryRunnable
3132
fileSystem opctx.FS
3233
downloader downloader.Downloader
34+
retryConfig retry.Config
3335
}
3436

3537
// Ensure [FedoraSourceDownloaderImpl] implements [FedoraSourceDownloader].
@@ -80,6 +82,7 @@ func NewFedoraRepoExtractorImpl(
8082
dryRunnable opctx.DryRunnable,
8183
fileSystem opctx.FS,
8284
downloader downloader.Downloader,
85+
retryCfg retry.Config,
8386
) (*FedoraSourceDownloaderImpl, error) {
8487
if fileSystem == nil {
8588
return nil, errors.New("filesystem cannot be nil")
@@ -97,6 +100,7 @@ func NewFedoraRepoExtractorImpl(
97100
dryRunnable: dryRunnable,
98101
fileSystem: fileSystem,
99102
downloader: downloader,
103+
retryConfig: retryCfg,
100104
}, nil
101105
}
102106

@@ -181,12 +185,22 @@ func (g *FedoraSourceDownloaderImpl) downloadAndVerifySources(
181185
"destPath", destFilePath,
182186
)
183187

184-
if err := g.downloader.Download(ctx, sourceFile.uri, destFilePath); err != nil {
185-
return fmt.Errorf("failed to download from %#q to %#q:\n%w", sourceFile.uri, destFilePath, err)
186-
}
188+
if err := retry.Do(ctx, g.retryConfig, func() error {
189+
// Remove any partially written file from a prior failed attempt.
190+
_ = g.fileSystem.Remove(destFilePath)
191+
192+
if downloadErr := g.downloader.Download(ctx, sourceFile.uri, destFilePath); downloadErr != nil {
193+
return fmt.Errorf("failed to download from %#q to %#q:\n%w",
194+
sourceFile.uri, destFilePath, downloadErr)
195+
}
196+
197+
if hashErr := g.validateDownloadedFile(destFilePath, sourceFile); hashErr != nil {
198+
return fmt.Errorf("hash validation failed for %#q:\n%w", sourceFile.fileName, hashErr)
199+
}
187200

188-
if err := g.validateDownloadedFile(destFilePath, sourceFile); err != nil {
189-
return fmt.Errorf("hash validation failed for %#q:\n%w", sourceFile.fileName, err)
201+
return nil
202+
}); err != nil {
203+
return fmt.Errorf("failed to retrieve source file %#q:\n%w", sourceFile.fileName, err)
190204
}
191205
}
192206

internal/providers/sourceproviders/fedorasource/fedorasource_test.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/gim-home/azldev-preview/internal/global/testctx"
1313
"github.com/gim-home/azldev-preview/internal/utils/downloader/downloader_test"
1414
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
15+
"github.com/gim-home/azldev-preview/internal/utils/retry"
1516
"github.com/spf13/afero"
1617
"github.com/stretchr/testify/assert"
1718
"github.com/stretchr/testify/require"
@@ -51,7 +52,7 @@ func TestNewFedoraRepoExtractorImpl(t *testing.T) {
5152
ctx := testctx.NewCtx()
5253
mockDownloader := downloader_test.NewMockDownloader(ctrl)
5354

54-
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader)
55+
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader, retry.Disabled())
5556

5657
require.NoError(t, err)
5758
require.NotNil(t, extractor)
@@ -63,17 +64,17 @@ func TestNewFedoraRepoExtractorImplValidation(t *testing.T) {
6364
mockDownloader := downloader_test.NewMockDownloader(ctrl)
6465

6566
t.Run("nil dryRunnable should fail", func(t *testing.T) {
66-
extractor, err := NewFedoraRepoExtractorImpl(nil, ctx.FS(), mockDownloader)
67+
extractor, err := NewFedoraRepoExtractorImpl(nil, ctx.FS(), mockDownloader, retry.Disabled())
6768
require.Error(t, err)
6869
require.Nil(t, extractor)
6970
})
7071
t.Run("nil filesystem should fail", func(t *testing.T) {
71-
extractor, err := NewFedoraRepoExtractorImpl(ctx, nil, mockDownloader)
72+
extractor, err := NewFedoraRepoExtractorImpl(ctx, nil, mockDownloader, retry.Disabled())
7273
require.Error(t, err)
7374
require.Nil(t, extractor)
7475
})
7576
t.Run("nil downloader should fail", func(t *testing.T) {
76-
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), nil)
77+
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), nil, retry.Disabled())
7778
require.Error(t, err)
7879
require.Nil(t, extractor)
7980
})
@@ -84,7 +85,7 @@ func TestExtractSourcesFromRepo(t *testing.T) {
8485
ctx := testctx.NewCtx()
8586
mockDownloader := downloader_test.NewMockDownloader(ctrl)
8687

87-
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader)
88+
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader, retry.Disabled())
8889
require.NoError(t, err)
8990

9091
require.NoError(t, ctx.FS().MkdirAll(testRepoDir, testDirPerms))
@@ -118,7 +119,7 @@ func TestExtractSourcesFromRepoValidation(t *testing.T) {
118119
ctx := testctx.NewCtx()
119120
mockDownloader := downloader_test.NewMockDownloader(ctrl)
120121

121-
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader)
122+
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader, retry.Disabled())
122123
require.NoError(t, err)
123124

124125
t.Run("empty repo dir", func(t *testing.T) {
@@ -147,7 +148,7 @@ func TestExtractSourcesFromRepoDownloadFailure(t *testing.T) {
147148
ctx := testctx.NewCtx()
148149
mockDownloader := downloader_test.NewMockDownloader(ctrl)
149150

150-
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader)
151+
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader, retry.Disabled())
151152
require.NoError(t, err)
152153

153154
require.NoError(t, ctx.FS().MkdirAll(testRepoDir, testDirPerms))
@@ -168,7 +169,7 @@ func TestExtractSourcesFromRepoHashMismatch(t *testing.T) {
168169
ctx := testctx.NewCtx()
169170
mockDownloader := downloader_test.NewMockDownloader(ctrl)
170171

171-
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader)
172+
extractor, err := NewFedoraRepoExtractorImpl(ctx, ctx.FS(), mockDownloader, retry.Disabled())
172173
require.NoError(t, err)
173174

174175
require.NoError(t, ctx.FS().MkdirAll(testRepoDir, testDirPerms))

internal/providers/sourceproviders/fedorasourceprovider.go

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/gim-home/azldev-preview/internal/providers/sourceproviders/fedorasource"
1919
"github.com/gim-home/azldev-preview/internal/utils/fileutils"
2020
"github.com/gim-home/azldev-preview/internal/utils/git"
21+
"github.com/gim-home/azldev-preview/internal/utils/retry"
2122
)
2223

2324
// DistroResolver is a function type that resolves a distro reference to its definition and version.
@@ -32,6 +33,7 @@ type FedoraSourcesProviderImpl struct {
3233
gitProvider git.GitProvider
3334
downloader fedorasource.FedoraSourceDownloader
3435
distroResolver DistroResolver
36+
retryConfig retry.Config
3537
}
3638

3739
var _ ComponentSourceProvider = (*FedoraSourcesProviderImpl)(nil)
@@ -42,6 +44,7 @@ func NewFedoraSourcesProviderImpl(
4244
gitProvider git.GitProvider,
4345
downloader fedorasource.FedoraSourceDownloader,
4446
distroResolver DistroResolver,
47+
retryCfg retry.Config,
4548
) (*FedoraSourcesProviderImpl, error) {
4649
if fs == nil {
4750
return nil, errors.New("filesystem cannot be nil")
@@ -69,6 +72,7 @@ func NewFedoraSourcesProviderImpl(
6972
gitProvider: gitProvider,
7073
downloader: downloader,
7174
distroResolver: distroResolver,
75+
retryConfig: retryCfg,
7276
}, nil
7377
}
7478

@@ -113,38 +117,48 @@ func (g *FedoraSourcesProviderImpl) GetComponent(
113117

114118
defer fileutils.RemoveAllAndUpdateErrorIfNil(g.fs, tempDir, &err)
115119

116-
// Clone the repository to temp directory
117-
err = g.gitProvider.Clone(
118-
ctx,
119-
gitRepoURL,
120-
tempDir,
121-
git.WithGitBranch(distroGitBranch))
120+
// Clone the repository to temp directory with retry for transient network failures.
121+
err = retry.Do(ctx, g.retryConfig, func() error {
122+
// Clean up temp directory contents from any prior failed clone attempt.
123+
_ = g.fs.RemoveAll(tempDir)
124+
_ = fileutils.MkdirAll(g.fs, tempDir)
125+
126+
return g.gitProvider.Clone(ctx, gitRepoURL, tempDir, git.WithGitBranch(distroGitBranch))
127+
})
122128
if err != nil {
123129
return fmt.Errorf("failed to clone git repository %#q:\n%w", gitRepoURL, err)
124130
}
125131

132+
// Process the cloned repo: checkout target commit, extract sources, copy to destination.
133+
return g.processClonedRepo(ctx, effectiveDistroRef, tempDir, upstreamNameToUse, componentName,
134+
lookasideBaseURI, destDirPath)
135+
}
136+
137+
// processClonedRepo handles the post-clone steps: checking out the target commit,
138+
// extracting lookaside sources, renaming spec files, and copying to the destination.
139+
func (g *FedoraSourcesProviderImpl) processClonedRepo(
140+
ctx context.Context,
141+
effectiveDistroRef projectconfig.DistroReference,
142+
tempDir, upstreamName, componentName, lookasideBaseURI, destDirPath string,
143+
) error {
126144
// Checkout the appropriate commit based on component/distro config
127-
err = g.checkoutTargetCommit(ctx, effectiveDistroRef, tempDir)
128-
if err != nil {
145+
if err := g.checkoutTargetCommit(ctx, effectiveDistroRef, tempDir); err != nil {
129146
return fmt.Errorf("failed to checkout target commit:\n%w", err)
130147
}
131148

132149
// Delete the .git directory so it's not copied to destination.
133-
err = g.fs.RemoveAll(filepath.Join(tempDir, ".git"))
134-
if err != nil {
150+
if err := g.fs.RemoveAll(filepath.Join(tempDir, ".git")); err != nil {
135151
return fmt.Errorf("failed to remove .git directory from cloned repository at %#q:\n%w",
136152
tempDir, err)
137153
}
138154

139155
// Extract sources from repo (downloads lookaside files into the temp dir)
140-
err = g.downloader.ExtractSourcesFromRepo(ctx, tempDir, upstreamNameToUse, lookasideBaseURI)
141-
if err != nil {
156+
if err := g.downloader.ExtractSourcesFromRepo(ctx, tempDir, upstreamName, lookasideBaseURI); err != nil {
142157
return fmt.Errorf("failed to extract sources from git repository:\n%w", err)
143158
}
144159

145160
// If the upstream name differs from the component name, rename the spec in temp dir.
146-
err = g.renameSpecIfNeeded(tempDir, upstreamNameToUse, componentName)
147-
if err != nil {
161+
if err := g.renameSpecIfNeeded(tempDir, upstreamName, componentName); err != nil {
148162
return err
149163
}
150164

@@ -157,8 +171,7 @@ func (g *FedoraSourcesProviderImpl) GetComponent(
157171
FileFilter: fileutils.SkipExistingFiles,
158172
}
159173

160-
err = fileutils.CopyDirRecursive(g.dryRunnable, g.fs, tempDir, destDirPath, copyOptions)
161-
if err != nil {
174+
if err := fileutils.CopyDirRecursive(g.dryRunnable, g.fs, tempDir, destDirPath, copyOptions); err != nil {
162175
return fmt.Errorf("failed to copy files to destination:\n%w", err)
163176
}
164177

0 commit comments

Comments
 (0)