Skip to content

Commit 5baca04

Browse files
committed
feat: adopt an exponential backoff as a retry mechanism and integrate review findings
1 parent 255cce4 commit 5baca04

File tree

6 files changed

+167
-67
lines changed

6 files changed

+167
-67
lines changed

cmd/dex/config.go

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import (
55
"encoding/json"
66
"fmt"
77
"log/slog"
8+
"math"
89
"net/http"
910
"net/netip"
1011
"os"
1112
"strings"
13+
"time"
1214

1315
"golang.org/x/crypto/bcrypt"
1416

@@ -78,6 +80,13 @@ func (c Config) Validate() error {
7880
{c.GRPC.TLSMaxVersion != "" && c.GRPC.TLSMinVersion != "" && c.GRPC.TLSMinVersion > c.GRPC.TLSMaxVersion, "TLSMinVersion greater than TLSMaxVersion"},
7981
}
8082

83+
if err := c.Storage.Retry.Validate(); err != nil {
84+
checks = append(checks, struct {
85+
bad bool
86+
errMsg string
87+
}{true, err.Error()})
88+
}
89+
8190
var checkErrors []string
8291

8392
for _, check := range checks {
@@ -254,10 +263,52 @@ type GRPC struct {
254263

255264
// Storage holds app's storage configuration.
256265
type Storage struct {
257-
Type string `json:"type"`
258-
Config StorageConfig `json:"config"`
259-
RetryAttempts int `json:"retryAttempts"`
260-
RetryDelay string `json:"retryDelay"`
266+
Type string `json:"type"`
267+
Config StorageConfig `json:"config"`
268+
Retry Retry `json:"retry"`
269+
}
270+
271+
// Retry holds retry mechanism configuration.
272+
type Retry struct {
273+
MaxAttempts int `json:"maxAttempts"` // Defaults to 5
274+
InitialDelay string `json:"initialDelay"` // Defaults to 1s
275+
MaxDelay string `json:"maxDelay"` // Defaults to 5s
276+
BackoffFactor float64 `json:"backoffFactor"` // Defaults to 2
277+
}
278+
279+
func (r *Retry) Validate() error {
280+
// If retry is configured but empty, return an error
281+
if r.MaxAttempts == 0 && r.InitialDelay == "" && r.MaxDelay == "" && r.BackoffFactor == 0 {
282+
return fmt.Errorf("empty configuration is supplied for storage retry")
283+
}
284+
285+
if r.MaxAttempts < 1 {
286+
return fmt.Errorf("storage retry max attempts must be at least 1")
287+
}
288+
289+
initialDelay, err := time.ParseDuration(r.InitialDelay)
290+
if err != nil || initialDelay <= 0 {
291+
return fmt.Errorf("storage retry initial delay must be a positive duration in go time format")
292+
}
293+
294+
maxDelay, err := time.ParseDuration(r.MaxDelay)
295+
if err != nil || maxDelay <= 0 {
296+
return fmt.Errorf("storage retry max delay must be a positive duration in go time format")
297+
}
298+
299+
if maxDelay < initialDelay {
300+
return fmt.Errorf("storage retry max delay must be greater than or equal to initial delay")
301+
}
302+
303+
if r.BackoffFactor <= 1 {
304+
return fmt.Errorf("storage retry backoff factor must be greater than 1")
305+
}
306+
// exponential backoff algorithm-specific check
307+
if float64(maxDelay) < float64(initialDelay)*math.Pow(r.BackoffFactor, float64(r.MaxAttempts-1)) {
308+
return fmt.Errorf("storage retry max delay is too small for the given initial delay, backoff factor, and max attempts")
309+
}
310+
311+
return nil
261312
}
262313

263314
// StorageConfig is a configuration that can create a storage.
@@ -299,10 +350,9 @@ var storages = map[string]func() StorageConfig{
299350
// dynamically determine the type of the storage config.
300351
func (s *Storage) UnmarshalJSON(b []byte) error {
301352
var store struct {
302-
Type string `json:"type"`
303-
Config json.RawMessage `json:"config"`
304-
RetryAttempts int `json:"retryAttempts"`
305-
RetryDelay string `json:"retryDelay"`
353+
Type string `json:"type"`
354+
Config json.RawMessage `json:"config"`
355+
Retry Retry `json:"retry"`
306356
}
307357
if err := json.Unmarshal(b, &store); err != nil {
308358
return fmt.Errorf("parse storage: %v", err)
@@ -323,11 +373,16 @@ func (s *Storage) UnmarshalJSON(b []byte) error {
323373
return fmt.Errorf("parse storage config: %v", err)
324374
}
325375
}
376+
326377
*s = Storage{
327-
Type: store.Type,
328-
Config: storageConfig,
329-
RetryAttempts: store.RetryAttempts,
330-
RetryDelay: store.RetryDelay,
378+
Type: store.Type,
379+
Config: storageConfig,
380+
Retry: Retry{
381+
MaxAttempts: value(store.Retry.MaxAttempts, 5),
382+
InitialDelay: value(store.Retry.InitialDelay, time.Second.String()),
383+
MaxDelay: value(store.Retry.MaxDelay, (5 * time.Second).String()),
384+
BackoffFactor: value(store.Retry.BackoffFactor, 2),
385+
},
331386
}
332387
return nil
333388
}
@@ -428,3 +483,11 @@ type RefreshToken struct {
428483
AbsoluteLifetime string `json:"absoluteLifetime"`
429484
ValidIfNotUsedFor string `json:"validIfNotUsedFor"`
430485
}
486+
487+
func value[T comparable](val, defaultValue T) T {
488+
var zero T
489+
if val == zero {
490+
return defaultValue
491+
}
492+
return val
493+
}

cmd/dex/config_test.go

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77

88
"github.com/ghodss/yaml"
99
"github.com/kylelemons/godebug/pretty"
10-
"github.com/stretchr/testify/require"
1110

1211
"github.com/dexidp/dex/connector/mock"
1312
"github.com/dexidp/dex/connector/oidc"
@@ -26,6 +25,12 @@ func TestValidConfiguration(t *testing.T) {
2625
Config: &sql.SQLite3{
2726
File: "examples/dex.db",
2827
},
28+
Retry: Retry{
29+
MaxAttempts: 5,
30+
InitialDelay: "1s",
31+
MaxDelay: "5m",
32+
BackoffFactor: 2,
33+
},
2934
},
3035
Web: Web{
3136
HTTP: "127.0.0.1:5556",
@@ -55,7 +60,8 @@ func TestInvalidConfiguration(t *testing.T) {
5560
wanted := `invalid Config:
5661
- no issuer specified in config file
5762
- no storage supplied in config file
58-
- must supply a HTTP/HTTPS address to listen on`
63+
- must supply a HTTP/HTTPS address to listen on
64+
- empty configuration is supplied for storage retry`
5965
if got != wanted {
6066
t.Fatalf("Expected error message to be %q, got %q", wanted, got)
6167
}
@@ -73,6 +79,11 @@ storage:
7379
maxIdleConns: 3
7480
connMaxLifetime: 30
7581
connectionTimeout: 3
82+
retry:
83+
maxAttempts: 10
84+
initialDelay: "4s"
85+
maxDelay: "5m"
86+
backoffFactor: 2
7687
web:
7788
https: 127.0.0.1:5556
7889
tlsMinVersion: 1.3
@@ -153,6 +164,12 @@ additionalFeatures: [
153164
ConnectionTimeout: 3,
154165
},
155166
},
167+
Retry: Retry{
168+
MaxAttempts: 10,
169+
InitialDelay: "4s",
170+
MaxDelay: "5m",
171+
BackoffFactor: 2,
172+
},
156173
},
157174
Web: Web{
158175
HTTPS: "127.0.0.1:5556",
@@ -294,6 +311,11 @@ storage:
294311
maxIdleConns: 3
295312
connMaxLifetime: 30
296313
connectionTimeout: 3
314+
retry:
315+
maxAttempts: 5
316+
initialDelay: 2s
317+
maxDelay: 10s
318+
backoffFactor: 2
297319
web:
298320
http: 127.0.0.1:5556
299321
@@ -371,6 +393,12 @@ logger:
371393
ConnectionTimeout: 3,
372394
},
373395
},
396+
Retry: Retry{
397+
MaxAttempts: 5,
398+
InitialDelay: "2s",
399+
MaxDelay: "10s",
400+
BackoffFactor: 2,
401+
},
374402
},
375403
Web: Web{
376404
HTTP: "127.0.0.1:5556",
@@ -449,22 +477,23 @@ logger:
449477
}
450478
}
451479

452-
func TestUnmarshalConfigWithRetry(t *testing.T) {
453-
rawConfig := []byte(`
454-
storage:
455-
type: postgres
456-
config:
457-
host: 10.0.0.1
458-
port: 65432
459-
retryAttempts: 10
460-
retryDelay: "1s"
461-
`)
462-
463-
var c Config
464-
err := yaml.Unmarshal(rawConfig, &c)
465-
require.NoError(t, err)
466-
467-
require.Equal(t, "postgres", c.Storage.Type)
468-
require.Equal(t, 10, c.Storage.RetryAttempts)
469-
require.Equal(t, "1s", c.Storage.RetryDelay)
470-
}
480+
// func TestUnmarshalConfigWithRetry(t *testing.T) {
481+
// rawConfig := []byte(`
482+
// storage:
483+
// type: postgres
484+
// config:
485+
// host: 10.0.0.1
486+
// port: 65432
487+
// retry:
488+
// attempts: 10
489+
// delay: 1s
490+
// `)
491+
492+
// var c Config
493+
// err := yaml.Unmarshal(rawConfig, &c)
494+
// require.NoError(t, err)
495+
496+
// require.Equal(t, "postgres", c.Storage.Type)
497+
// require.Equal(t, 10, c.Storage.Retry.Attempts)
498+
// require.Equal(t, "1s", c.Storage.Retry.Delay)
499+
// }

cmd/dex/serve.go

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -695,31 +695,34 @@ func initializeStorageWithRetry(storageConfig Storage, logger *slog.Logger) (sto
695695
var s storage.Storage
696696
var err error
697697

698-
retryAttempts := storageConfig.RetryAttempts
699-
if retryAttempts == 0 {
700-
retryAttempts = 5 // Default to 5 attempts
701-
}
698+
maxAttempts := storageConfig.Retry.MaxAttempts
699+
initialDelay, _ := time.ParseDuration(storageConfig.Retry.InitialDelay)
700+
maxDelay, _ := time.ParseDuration(storageConfig.Retry.MaxDelay)
701+
backoffFactor := storageConfig.Retry.BackoffFactor
702702

703-
retryDelay, err := time.ParseDuration(storageConfig.RetryDelay)
704-
if err != nil {
705-
retryDelay = 5 * time.Second // Default to 5 seconds
706-
}
703+
delay := initialDelay
707704

708-
for attempt := 1; attempt <= retryAttempts; attempt++ {
705+
for attempt := 1; attempt <= maxAttempts; attempt++ {
709706
s, err = storageConfig.Config.Open(logger)
710707
if err == nil {
711708
return s, nil
712709
}
713710

714711
logger.Error("Failed to initialize storage",
715-
"attempt", fmt.Sprintf("%d/%d", attempt, retryAttempts),
712+
"attempt", fmt.Sprintf("%d/%d", attempt, maxAttempts),
716713
"error", err)
717714

718-
if attempt < retryAttempts {
715+
if attempt < maxAttempts {
719716
logger.Info("Retrying storage initialization",
720-
"nextAttemptIn", retryDelay.String())
721-
time.Sleep(retryDelay)
717+
"nextAttemptIn", delay.String())
718+
time.Sleep(delay)
719+
720+
// Calculate next delay using exponential backoff
721+
delay = time.Duration(float64(delay) * backoffFactor)
722+
if delay > maxDelay {
723+
delay = maxDelay
724+
}
722725
}
723726
}
724-
return nil, fmt.Errorf("failed to initialize storage after %d attempts: %v", retryAttempts, err)
727+
return nil, fmt.Errorf("failed to initialize storage after %d attempts: %v", maxAttempts, err)
725728
}

cmd/dex/serve_test.go

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
package main
22

33
import (
4-
"context"
5-
"errors"
4+
"fmt"
65
"log/slog"
76
"testing"
87

9-
"github.com/stretchr/testify/require"
10-
118
"github.com/dexidp/dex/storage"
129
"github.com/dexidp/dex/storage/memory"
10+
"github.com/stretchr/testify/require"
1311
)
1412

1513
func TestNewLogger(t *testing.T) {
@@ -32,11 +30,7 @@ func TestNewLogger(t *testing.T) {
3230
require.Equal(t, (*slog.Logger)(nil), logger)
3331
})
3432
}
35-
3633
func TestStorageInitializationRetry(t *testing.T) {
37-
_, cancel := context.WithCancel(context.Background())
38-
defer cancel()
39-
4034
// Create a mock storage that fails a certain number of times before succeeding
4135
mockStorage := &mockRetryStorage{
4236
failuresLeft: 3,
@@ -45,10 +39,14 @@ func TestStorageInitializationRetry(t *testing.T) {
4539
config := Config{
4640
Issuer: "http://127.0.0.1:5556/dex",
4741
Storage: Storage{
48-
Type: "mock",
49-
Config: mockStorage,
50-
RetryAttempts: 5,
51-
RetryDelay: "1s",
42+
Type: "mock",
43+
Config: mockStorage,
44+
Retry: Retry{
45+
MaxAttempts: 5,
46+
InitialDelay: "1s",
47+
MaxDelay: "10s",
48+
BackoffFactor: 2,
49+
},
5250
},
5351
Web: Web{
5452
HTTP: "127.0.0.1:5556",
@@ -59,7 +57,8 @@ func TestStorageInitializationRetry(t *testing.T) {
5957
},
6058
}
6159

62-
logger, _ := newLogger(config.Logger.Level, config.Logger.Format)
60+
logger, err := newLogger(config.Logger.Level, config.Logger.Format)
61+
require.NoError(t, err)
6362

6463
s, err := initializeStorageWithRetry(config.Storage, logger)
6564
require.NoError(t, err)
@@ -75,7 +74,7 @@ type mockRetryStorage struct {
7574
func (m *mockRetryStorage) Open(logger *slog.Logger) (storage.Storage, error) {
7675
if m.failuresLeft > 0 {
7776
m.failuresLeft--
78-
return nil, errors.New("mock storage failure")
77+
return nil, fmt.Errorf("mock storage failure")
7978
}
8079
return memory.New(logger), nil
8180
}

config.yaml.dist

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,12 @@ storage:
4848
# kubeConfigFile: $HOME/.kube/config
4949

5050
# Configuration of the retry mechanism upon a failure to storage database
51-
# If not defined, the default will apply 5 attempts with 5s timeout
52-
# retryAttempts: 5
53-
# retryDelay: "5s"
51+
# If not defined, the defaults below are applied
52+
# retry:
53+
# maxAttempts: 5
54+
# initialDelay: "1s"
55+
# maxDelay: "5s"
56+
# backoffFactor: 2
5457

5558
# HTTP service configuration
5659
web:

0 commit comments

Comments
 (0)