Skip to content

Commit d81471b

Browse files
authored
fix(sync): enable exponential HTTP retry backoff via maxRetryDelay (#4175)
Add optional maxRetryDelay to sync registry config and pass separate init/max values to regclient WithDelay instead of using retryDelay for both. When maxRetryDelay is unset, it defaults to retryDelay so existing configs keep a fixed HTTP retry interval. Validate that maxRetryDelay requires retryDelay and is not less than it. Update examples and docs for the new option. Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
1 parent 0a99eda commit d81471b

8 files changed

Lines changed: 119 additions & 5 deletions

File tree

examples/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,7 +1358,8 @@ Configure each registry sync:
13581358
"tlsVerify": true, # whether or not to verify tls (default is true)
13591359
"certDir": "/home/user/certs", # use certificates at certDir path similar to Docker's /etc/docker/certs.d., if not specified then use the default certs dir,
13601360
"maxRetries": 5, # maxRetries in case of temporary errors (default: no retries)
1361-
"retryDelay": "10m", # delay between retries, retry options are applied for both on demand and periodically sync and retryDelay is mandatory when using maxRetries.
1361+
"retryDelay": "1s", # initial HTTP retry delay; mandatory when using maxRetries
1362+
"maxRetryDelay": "30s", # max HTTP retry backoff; optional, defaults to retryDelay (fixed interval). Set higher than retryDelay for exponential backoff.
13621363
"onlySigned": true, # sync only signed images (either notary or cosign)
13631364
"content":[ # which content to periodically pull, also it's used for filtering ondemand images, if not set then periodically polling will not run
13641365
{
@@ -1405,7 +1406,8 @@ Configure each registry sync:
14051406
"onDemand": true, # doesn't have content, don't periodically pull, pull just on demand.
14061407
"tlsVerify": true,
14071408
"maxRetries": 3,
1408-
"retryDelay": "15m"
1409+
"retryDelay": "15m", # initial HTTP retry delay; fixed 15m interval unless maxRetryDelay is set higher
1410+
"maxRetryDelay": "15m" # optional; omit or set equal to retryDelay for fixed interval (as here)
14091411
}
14101412
]
14111413
}

examples/config-sync.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@
7474
"onDemand": true,
7575
"tlsVerify": true,
7676
"maxRetries": 5,
77-
"retryDelay": "30s",
77+
"retryDelay": "1s",
78+
"maxRetryDelay": "30s",
7879
"syncTimeout": "10m"
7980
},
8081
{

pkg/cli/server/root.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,6 +1644,23 @@ func validateSync(config *config.Config, logger zlog.Logger) error {
16441644
return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg)
16451645
}
16461646

1647+
if regCfg.MaxRetryDelay != nil && regCfg.RetryDelay == nil {
1648+
msg := "retryDelay is required when using maxRetryDelay"
1649+
logger.Error().Err(zerr.ErrBadConfig).Int("id", regID).Interface("extensions.sync.registries[id]",
1650+
extensionsConfig.Sync.Registries[regID]).Msg(msg)
1651+
1652+
return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg)
1653+
}
1654+
1655+
if regCfg.MaxRetryDelay != nil && regCfg.RetryDelay != nil &&
1656+
*regCfg.MaxRetryDelay < *regCfg.RetryDelay {
1657+
msg := "maxRetryDelay must be greater than or equal to retryDelay"
1658+
logger.Error().Err(zerr.ErrBadConfig).Int("id", regID).Interface("extensions.sync.registries[id]",
1659+
extensionsConfig.Sync.Registries[regID]).Msg(msg)
1660+
1661+
return fmt.Errorf("%w: %s", zerr.ErrBadConfig, msg)
1662+
}
1663+
16471664
// check preserveDigest without compat
16481665
if regCfg.PreserveDigest && !config.IsCompatEnabled() {
16491666
msg := "can not use PreserveDigest option without enabling http.Compat"

pkg/cli/server/root_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,6 +1850,49 @@ storage:
18501850
So(err, ShouldNotBeNil)
18511851
})
18521852

1853+
Convey("Test verify sync with maxRetryDelay without retryDelay", t, func(c C) {
1854+
content := `{"storage":{"rootDirectory":"/tmp/zot"},
1855+
"http":{"address":"127.0.0.1","port":"8080","realm":"zot",
1856+
"auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}},
1857+
"extensions":{"sync": {"registries": [{"urls":["localhost:9999"],
1858+
"maxRetryDelay": "30s"}]}}}`
1859+
tmpfile := MakeTempFileWithContent(t, "zot-test.json", content)
1860+
1861+
os.Args = []string{"cli_test", "verify", tmpfile}
1862+
err := cli.NewServerRootCmd().Execute()
1863+
So(err, ShouldNotBeNil)
1864+
So(err, ShouldWrap, zerr.ErrBadConfig)
1865+
So(err.Error(), ShouldContainSubstring, "retryDelay is required when using maxRetryDelay")
1866+
})
1867+
1868+
Convey("Test verify sync with maxRetryDelay less than retryDelay", t, func(c C) {
1869+
content := `{"storage":{"rootDirectory":"/tmp/zot"},
1870+
"http":{"address":"127.0.0.1","port":"8080","realm":"zot",
1871+
"auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}},
1872+
"extensions":{"sync": {"registries": [{"urls":["localhost:9999"],
1873+
"retryDelay": "30s", "maxRetryDelay": "1s"}]}}}`
1874+
tmpfile := MakeTempFileWithContent(t, "zot-test.json", content)
1875+
1876+
os.Args = []string{"cli_test", "verify", tmpfile}
1877+
err := cli.NewServerRootCmd().Execute()
1878+
So(err, ShouldNotBeNil)
1879+
So(err, ShouldWrap, zerr.ErrBadConfig)
1880+
So(err.Error(), ShouldContainSubstring, "maxRetryDelay must be greater than or equal to retryDelay")
1881+
})
1882+
1883+
Convey("Test verify sync with valid maxRetryDelay", t, func(c C) {
1884+
content := `{"storage":{"rootDirectory":"/tmp/zot"},
1885+
"http":{"address":"127.0.0.1","port":"8080","realm":"zot",
1886+
"auth":{"htpasswd":{"path":"test/data/htpasswd"},"failDelay":1}},
1887+
"extensions":{"sync": {"registries": [{"urls":["localhost:9999"],
1888+
"maxRetries": 3, "retryDelay": "1s", "maxRetryDelay": "30s"}]}}}`
1889+
tmpfile := MakeTempFileWithContent(t, "zot-test.json", content)
1890+
1891+
os.Args = []string{"cli_test", "verify", tmpfile}
1892+
err := cli.NewServerRootCmd().Execute()
1893+
So(err, ShouldBeNil)
1894+
})
1895+
18531896
Convey("Test verify config with unknown keys", t, func(c C) {
18541897
content := `{"distSpecVersion": "1.0.0", "storage": {"rootDirectory": "/tmp/zot"},
18551898
"http": {"url": "127.0.0.1", "port": "8080"},

pkg/cli/server/schema_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ func TestSchemaAllowsNullForPointerFields(t *testing.T) {
6464
map[string]any{
6565
"tlsVerify": nil,
6666
"retryDelay": nil,
67+
"maxRetryDelay": nil,
6768
"onlySigned": nil,
6869
"syncLegacyCosignTags": nil,
6970
},

pkg/extensions/config/sync/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type RegistryConfig struct {
3131
CertDir string
3232
MaxRetries *int
3333
RetryDelay *time.Duration
34+
MaxRetryDelay *time.Duration // max HTTP retry backoff; when unset defaults to retryDelay (fixed delay)
3435
OnlySigned *bool
3536
SyncLegacyCosignTags *bool // when unset, defaults to true
3637
CredentialHelper string

pkg/extensions/sync/service.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -806,6 +806,24 @@ func getTLSConfigOption(url *url.URL, tlsVerify *bool) config.TLSConf {
806806
return tls
807807
}
808808

809+
// httpRetryDelayBounds returns regclient WithDelay arguments for HTTP retry backoff.
810+
// When maxRetryDelay is unset, delayMax defaults to delayInit so existing configs keep a fixed retry interval.
811+
// Set maxRetryDelay greater than retryDelay to enable exponential backoff up to that cap.
812+
func httpRetryDelayBounds(opts syncconf.RegistryConfig) (time.Duration, time.Duration, bool) {
813+
if opts.RetryDelay == nil {
814+
return 0, 0, false
815+
}
816+
817+
delayInit := *opts.RetryDelay
818+
delayMax := delayInit
819+
820+
if opts.MaxRetryDelay != nil {
821+
delayMax = *opts.MaxRetryDelay
822+
}
823+
824+
return delayInit, delayMax, true
825+
}
826+
809827
func newClient(opts syncconf.RegistryConfig, credentials syncconf.CredentialsFile, logger log.Logger,
810828
) (*regclient.RegClient, []config.Host, error) {
811829
urls, err := parseRegistryURLs(opts.URLs)
@@ -889,8 +907,8 @@ func newClient(opts syncconf.RegistryConfig, credentials syncconf.CredentialsFil
889907
regOpts = append(regOpts, reg.WithRetryLimit(*opts.MaxRetries))
890908
}
891909

892-
if opts.RetryDelay != nil {
893-
regOpts = append(regOpts, reg.WithDelay(*opts.RetryDelay, *opts.RetryDelay))
910+
if delayInit, delayMax, ok := httpRetryDelayBounds(opts); ok {
911+
regOpts = append(regOpts, reg.WithDelay(delayInit, delayMax))
894912
}
895913

896914
// Configure transport with timeouts to prevent indefinite hangs.

pkg/extensions/sync/sync_internal_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1387,3 +1387,34 @@ func TestNewClientTimeoutBehavior(t *testing.T) {
13871387
})
13881388
})
13891389
}
1390+
1391+
func TestHTTPRetryDelayBounds(t *testing.T) {
1392+
Convey("httpRetryDelayBounds maps sync config to regclient delay args", t, func() {
1393+
retryDelay := 1 * time.Second
1394+
maxRetryDelay := 30 * time.Second
1395+
1396+
Convey("no retryDelay returns ok=false", func() {
1397+
delayInit, delayMax, ok := httpRetryDelayBounds(syncconf.RegistryConfig{})
1398+
So(ok, ShouldBeFalse)
1399+
So(delayInit, ShouldEqual, time.Duration(0))
1400+
So(delayMax, ShouldEqual, time.Duration(0))
1401+
})
1402+
1403+
Convey("retryDelay only defaults max to init (fixed interval)", func() {
1404+
delayInit, delayMax, ok := httpRetryDelayBounds(syncconf.RegistryConfig{RetryDelay: &retryDelay})
1405+
So(ok, ShouldBeTrue)
1406+
So(delayInit, ShouldEqual, retryDelay)
1407+
So(delayMax, ShouldEqual, retryDelay)
1408+
})
1409+
1410+
Convey("maxRetryDelay greater than retryDelay enables exponential backoff cap", func() {
1411+
delayInit, delayMax, ok := httpRetryDelayBounds(syncconf.RegistryConfig{
1412+
RetryDelay: &retryDelay,
1413+
MaxRetryDelay: &maxRetryDelay,
1414+
})
1415+
So(ok, ShouldBeTrue)
1416+
So(delayInit, ShouldEqual, retryDelay)
1417+
So(delayMax, ShouldEqual, maxRetryDelay)
1418+
})
1419+
})
1420+
}

0 commit comments

Comments
 (0)