Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add retry mechanism for storage initialization #3701

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions cmd/dex/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,10 @@ type GRPC struct {

// Storage holds app's storage configuration.
type Storage struct {
Type string `json:"type"`
Config StorageConfig `json:"config"`
Type string `json:"type"`
Config StorageConfig `json:"config"`
RetryAttempts int `json:"retryAttempts"`
RetryDelay string `json:"retryDelay"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Probably it is better to use token/bucket algorithm or an exponential backoff.
  2. Let's put options under a single key
retry:
  attempts: 3
  delay: 5s

}

// StorageConfig is a configuration that can create a storage.
Expand Down Expand Up @@ -297,8 +299,10 @@ var storages = map[string]func() StorageConfig{
// dynamically determine the type of the storage config.
func (s *Storage) UnmarshalJSON(b []byte) error {
var store struct {
Type string `json:"type"`
Config json.RawMessage `json:"config"`
Type string `json:"type"`
Config json.RawMessage `json:"config"`
RetryAttempts int `json:"retryAttempts"`
RetryDelay string `json:"retryDelay"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to validate here that delay is in the go time format, e.g., 5s, 10m?

}
if err := json.Unmarshal(b, &store); err != nil {
return fmt.Errorf("parse storage: %v", err)
Expand All @@ -320,8 +324,10 @@ func (s *Storage) UnmarshalJSON(b []byte) error {
}
}
*s = Storage{
Type: store.Type,
Config: storageConfig,
Type: store.Type,
Config: storageConfig,
RetryAttempts: store.RetryAttempts,
RetryDelay: store.RetryDelay,
}
return nil
}
Expand Down
21 changes: 21 additions & 0 deletions cmd/dex/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/ghodss/yaml"
"github.com/kylelemons/godebug/pretty"
"github.com/stretchr/testify/require"

"github.com/dexidp/dex/connector/mock"
"github.com/dexidp/dex/connector/oidc"
Expand Down Expand Up @@ -447,3 +448,23 @@ logger:
t.Errorf("got!=want: %s", diff)
}
}

func TestUnmarshalConfigWithRetry(t *testing.T) {
rawConfig := []byte(`
storage:
type: postgres
config:
host: 10.0.0.1
port: 65432
retryAttempts: 10
retryDelay: "1s"
`)

var c Config
err := yaml.Unmarshal(rawConfig, &c)
require.NoError(t, err)

require.Equal(t, "postgres", c.Storage.Type)
require.Equal(t, 10, c.Storage.RetryAttempts)
require.Equal(t, "1s", c.Storage.RetryDelay)
}
36 changes: 35 additions & 1 deletion cmd/dex/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ func runServe(options serveOptions) error {
grpcOptions = append(grpcOptions, grpc.Creds(credentials.NewTLS(tlsConfig)))
}

s, err := c.Storage.Config.Open(logger)
s, err := initializeStorageWithRetry(c.Storage, logger)
if err != nil {
return fmt.Errorf("failed to initialize storage: %v", err)
}
Expand Down Expand Up @@ -689,3 +689,37 @@ func loadTLSConfig(certFile, keyFile, caFile string, baseConfig *tls.Config) (*t
func recordBuildInfo() {
buildInfo.WithLabelValues(version, runtime.Version(), fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)).Set(1)
}

// initializeStorageWithRetry opens a connection to the storage backend with a retry mechanism.
func initializeStorageWithRetry(storageConfig Storage, logger *slog.Logger) (storage.Storage, error) {
var s storage.Storage
var err error

retryAttempts := storageConfig.RetryAttempts
if retryAttempts == 0 {
retryAttempts = 5 // Default to 5 attempts
}

retryDelay, err := time.ParseDuration(storageConfig.RetryDelay)
if err != nil {
retryDelay = 5 * time.Second // Default to 5 seconds
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this code to the config parsing, we usually enforce config default there, not in the method. I expect it to be in cmd/dex/config.go


for attempt := 1; attempt <= retryAttempts; attempt++ {
s, err = storageConfig.Config.Open(logger)
if err == nil {
return s, nil
}

logger.Error("Failed to initialize storage",
"attempt", fmt.Sprintf("%d/%d", attempt, retryAttempts),
"error", err)

if attempt < retryAttempts {
logger.Info("Retrying storage initialization",
"nextAttemptIn", retryDelay.String())
time.Sleep(retryDelay)
}
}
return nil, fmt.Errorf("failed to initialize storage after %d attempts: %v", retryAttempts, err)
}
52 changes: 52 additions & 0 deletions cmd/dex/serve_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package main

import (
"context"
"errors"
"log/slog"
"testing"

"github.com/stretchr/testify/require"

"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/memory"
)

func TestNewLogger(t *testing.T) {
Expand All @@ -27,3 +32,50 @@ func TestNewLogger(t *testing.T) {
require.Equal(t, (*slog.Logger)(nil), logger)
})
}

func TestStorageInitializationRetry(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
defer cancel()

// Create a mock storage that fails a certain number of times before succeeding
mockStorage := &mockRetryStorage{
failuresLeft: 3,
}

config := Config{
Issuer: "http://127.0.0.1:5556/dex",
Storage: Storage{
Type: "mock",
Config: mockStorage,
RetryAttempts: 5,
RetryDelay: "1s",
},
Web: Web{
HTTP: "127.0.0.1:5556",
},
Logger: Logger{
Level: slog.LevelInfo,
Format: "json",
},
}

logger, _ := newLogger(config.Logger.Level, config.Logger.Format)

s, err := initializeStorageWithRetry(config.Storage, logger)
require.NoError(t, err)
require.NotNil(t, s)

require.Equal(t, 0, mockStorage.failuresLeft)
}

type mockRetryStorage struct {
failuresLeft int
}

func (m *mockRetryStorage) Open(logger *slog.Logger) (storage.Storage, error) {
if m.failuresLeft > 0 {
m.failuresLeft--
return nil, errors.New("mock storage failure")
}
return memory.New(logger), nil
}
5 changes: 5 additions & 0 deletions config.yaml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ storage:
# config:
# kubeConfigFile: $HOME/.kube/config

# Configuration of the retry mechanism upon a failure to storage database
# If not defined, the default will apply 5 attempts with 5s timeout
# retryAttempts: 5
# retryDelay: "5s"

# HTTP service configuration
web:
http: 127.0.0.1:5556
Expand Down
5 changes: 5 additions & 0 deletions examples/config-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ storage:
# config:
# kubeConfigFile: $HOME/.kube/config

# Configuration of the retry mechanism upon a failure to storage database
# If not defined, the default will apply 5 attempts with 5s timeout
# retryAttempts: 5
# retryDelay: "5s"

# Configuration for the HTTP endpoints.
web:
http: 0.0.0.0:5556
Expand Down
Loading