Skip to content

Commit f0fa68c

Browse files
committed
FFM-8662 Add WaitForInitialzed / Improve Auth Retries
1 parent 4fd36ec commit f0fa68c

File tree

12 files changed

+855
-132
lines changed

12 files changed

+855
-132
lines changed

README.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,48 @@ To follow along with our test code sample, make sure you’ve:
2929
- [Created a server SDK key and made a copy of it](https://ngdocs.harness.io/article/1j7pdkqh7j-create-a-feature-flag#step_3_create_an_sdk_key)
3030

3131

32-
### Install the SDK
32+
## Install the SDK
3333
Install the golang SDK using go
3434
```golang
3535
go get github.com/harness/ff-golang-server-sdk
3636
```
3737

38+
## Initialize the SDK
39+
### Non-blocking Initialization (Default)
40+
By default, when initializing the Harness Feature Flags client, the initialization process is non-blocking. This means that the client creation call will return immediately, allowing your application to continue its startup process without waiting for the client to be fully initialized. Here’s an example of creating a non-blocking client:
41+
42+
```go
43+
client, err := harness.NewCfClient(apiKey)
44+
```
45+
In this scenario, the client will initialize in the background, making it possible to use the client even if it hasn’t finished initializing.
46+
Be mindful that if you attempt to evaluate a feature flag before the client has fully initialized, it will return the default value provided in the evaluation call.
47+
48+
### Blocking Initialization
49+
In some cases, you may want your application to wait for the client to finish initializing before continuing. To achieve this, you can use the `WithWaitForInitialized` option, which will block until the client is fully initialized. Example usage:
50+
51+
```go
52+
client, err := harness.NewCfClient(sdkKey, harness.WithWaitForInitialized(true))
53+
54+
if err != nil {
55+
log.ErrorF("could not connect to FF servers %s", err)
56+
}
57+
```
58+
59+
60+
In this example, WaitForInitialized will block for up to 5 authentication attempts. If the client is not initialized within 5 authentication attempts, it will return an error.
61+
62+
This can be useful if you need to unblock after a certain time. **NOTE**: if you evaluate a feature flag in this state
63+
the default variation will be returned.
64+
65+
```go
66+
// Try to authenticate only 5 times before returning a result
67+
client, err := harness.NewCfClient(sdkKey, harness.WithWaitForInitialized(true), harness.WithMaxAuthRetries(5))
68+
69+
if err != nil {
70+
log.Fatalf("client did not initialize in time: %s", err)
71+
}
72+
```
73+
3874
### Code Sample
3975
The following is a complete code example that you can use to test the `harnessappdemodarkmode` Flag you created on the Harness Platform. When you run the code it will:
4076
- Connect to the FF service.
@@ -114,6 +150,8 @@ export FF_API_KEY=<your key here>
114150
docker run -e FF_API_KEY=$FF_API_KEY -v $(pwd):/app -w /app golang:1.17 go run examples/getting_started.go
115151
```
116152

153+
154+
117155
### Additional Reading
118156

119157
For further examples and config options, see the [Golang SDK Reference](https://ngdocs.harness.io/article/4c8wljx60w-feature-flag-sdks-go-application).

analyticsservice/analytics.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const (
2424
variationValueAttribute string = "featureValue"
2525
targetAttribute string = "target"
2626
sdkVersionAttribute string = "SDK_VERSION"
27-
sdkVersion string = "1.0.0"
27+
sdkVersion string = "1.12.0"
2828
sdkTypeAttribute string = "SDK_TYPE"
2929
sdkType string = "server"
3030
sdkLanguageAttribute string = "SDK_LANGUAGE"

client/client.go

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"log"
9+
"math/rand"
910
"net/http"
1011
"strings"
1112
"sync"
@@ -53,8 +54,10 @@ type CfClient struct {
5354
streamConnectedLock sync.RWMutex
5455
authenticated chan struct{}
5556
postEvalChan chan evaluation.PostEvalData
56-
initialized bool
57-
initializedLock sync.RWMutex
57+
initializedBool bool
58+
initializedBoolLock sync.RWMutex
59+
initialized chan struct{}
60+
initializedErr chan error
5861
analyticsService *analyticsservice.AnalyticsService
5962
clusterIdentifier string
6063
stop chan struct{}
@@ -82,10 +85,13 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {
8285
postEvalChan: make(chan evaluation.PostEvalData),
8386
stop: make(chan struct{}),
8487
stopped: newAtomicBool(false),
88+
initialized: make(chan struct{}),
89+
initializedErr: make(chan error),
8590
}
8691

8792
if sdkKey == "" {
88-
return client, types.ErrSdkCantBeEmpty
93+
config.Logger.Errorf("Initialization failed: SDK Key cannot be empty. Please provide a valid SDK Key to initialize the client.")
94+
return client, EmptySDKKeyError
8995
}
9096

9197
var err error
@@ -106,6 +112,25 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {
106112
}
107113

108114
client.start()
115+
if config.waitForInitialized {
116+
var initErr error
117+
118+
select {
119+
case <-client.initialized:
120+
return client, nil
121+
case err := <-client.initializedErr:
122+
initErr = err
123+
}
124+
125+
if initErr != nil {
126+
config.Logger.Errorf("Initialization failed: '%v'", initErr)
127+
// We return the client but leave it in un-initialized state by not setting the relevant initialized flag.
128+
// This ensures any subsequent calls to the client don't potentially result in a panic. For example, if a user
129+
// calls BoolVariation we can log that the client is not initialized and return the user the default variation.
130+
return client, initErr
131+
}
132+
}
133+
109134
return client, nil
110135
}
111136

@@ -117,7 +142,11 @@ func (c *CfClient) start() {
117142
cancel()
118143
}()
119144

120-
go c.initAuthentication(ctx)
145+
go func() {
146+
if err := c.initAuthentication(context.Background()); err != nil {
147+
c.initializedErr <- err
148+
}
149+
}()
121150
go c.setAnalyticsServiceClient(ctx)
122151
go c.pullCronJob(ctx)
123152
}
@@ -141,15 +170,15 @@ func (c *CfClient) GetClusterIdentifier() string {
141170
// and successfully retrieved flags. If it takes longer than 1 minute the call will timeout and return an error.
142171
func (c *CfClient) IsInitialized() (bool, error) {
143172
for i := 0; i < 30; i++ {
144-
c.initializedLock.RLock()
145-
if c.initialized {
146-
c.initializedLock.RUnlock()
173+
c.initializedBoolLock.RLock()
174+
if c.initializedBool {
175+
c.initializedBoolLock.RUnlock()
147176
return true, nil
148177
}
149-
c.initializedLock.RUnlock()
150-
time.Sleep(time.Second * 2)
178+
c.initializedBoolLock.RUnlock()
179+
c.config.sleeper.Sleep(time.Second * 2)
151180
}
152-
return false, fmt.Errorf("timeout waiting to initialize")
181+
return false, InitializeTimeoutError{}
153182
}
154183

155184
func (c *CfClient) retrieve(ctx context.Context) bool {
@@ -185,9 +214,12 @@ func (c *CfClient) retrieve(ctx context.Context) bool {
185214
}
186215

187216
if ok {
188-
c.initializedLock.Lock()
189-
c.initialized = true
190-
c.initializedLock.Unlock()
217+
// This flag is used by `IsInitialized` so set to true.
218+
c.initializedBoolLock.Lock()
219+
c.initializedBool = true
220+
c.initializedBoolLock.Unlock()
221+
222+
close(c.initialized)
191223
}
192224
return ok
193225
}
@@ -233,15 +265,45 @@ func (c *CfClient) streamConnect(ctx context.Context) {
233265
c.streamConnected = true
234266
}
235267

236-
func (c *CfClient) initAuthentication(ctx context.Context) {
237-
// attempt to authenticate every minute until we succeed
268+
func (c *CfClient) initAuthentication(ctx context.Context) error {
269+
baseDelay := 1 * time.Second
270+
maxDelay := 1 * time.Minute
271+
factor := 2.0
272+
currentDelay := baseDelay
273+
274+
attempts := 0
275+
238276
for {
239277
err := c.authenticate(ctx)
240278
if err == nil {
241-
return
279+
return nil
280+
}
281+
282+
var nonRetryableAuthError NonRetryableAuthError
283+
if errors.As(err, &nonRetryableAuthError) {
284+
c.config.Logger.Error("Authentication failed with a non-retryable error: '%s %s' Default variations will now be served", nonRetryableAuthError.StatusCode, nonRetryableAuthError.Message)
285+
return err
286+
}
287+
288+
// -1 is the default maxAuthRetries option and indicates there should be no max attempts
289+
if c.config.maxAuthRetries != -1 && attempts >= c.config.maxAuthRetries {
290+
c.config.Logger.Errorf("Authentication failed with error: '%s'. Exceeded max attempts: '%v'.", err, c.config.maxAuthRetries)
291+
return err
292+
}
293+
294+
jitter := time.Duration(rand.Float64() * float64(currentDelay))
295+
delayWithJitter := currentDelay + jitter
296+
297+
c.config.Logger.Errorf("Authentication failed with error: '%s'. Retrying in %v.", err, delayWithJitter)
298+
c.config.sleeper.Sleep(delayWithJitter)
299+
300+
currentDelay *= time.Duration(factor)
301+
if currentDelay > maxDelay {
302+
currentDelay = maxDelay
242303
}
243-
c.config.Logger.Errorf("Authentication failed. Trying again in 1 minute: %s", err)
244-
time.Sleep(1 * time.Minute)
304+
305+
attempts++
306+
245307
}
246308
}
247309

@@ -262,9 +324,31 @@ func (c *CfClient) authenticate(ctx context.Context) error {
262324
if err != nil {
263325
return err
264326
}
265-
// should be login to harness and get account data (JWT token)
327+
328+
responseError := findErrorInResponse(response)
329+
330+
// Indicate that we should retry
331+
if responseError != nil && responseError.Code == "500" {
332+
return RetryableAuthError{
333+
StatusCode: responseError.Code,
334+
Message: responseError.Message,
335+
}
336+
}
337+
338+
// Indicate that we shouldn't retry on non-500 errors
339+
if responseError != nil {
340+
return NonRetryableAuthError{
341+
StatusCode: responseError.Code,
342+
Message: responseError.Message,
343+
}
344+
}
345+
346+
// Defensive check to handle the case that all responses are nil
266347
if response.JSON200 == nil {
267-
return fmt.Errorf("error while authenticating %v", ErrUnauthorized)
348+
return RetryableAuthError{
349+
StatusCode: "No errpr status code returned from server",
350+
Message: "No error message returned from server ",
351+
}
268352
}
269353

270354
c.token = response.JSON200.AuthToken
@@ -427,6 +511,10 @@ func (c *CfClient) setAnalyticsServiceClient(ctx context.Context) {
427511
//
428512
// Returns defaultValue if there is an error or if the flag doesn't exist
429513
func (c *CfClient) BoolVariation(key string, target *evaluation.Target, defaultValue bool) (bool, error) {
514+
if !c.initializedBool {
515+
c.config.Logger.Info("Error when calling BoolVariation and returning default variation: 'Client is not initialized'")
516+
return defaultValue, fmt.Errorf("%w: Client is not initialized", DefaultVariationReturnedError)
517+
}
430518
value := c.evaluator.BoolVariation(key, target, defaultValue)
431519
return value, nil
432520
}
@@ -435,6 +523,10 @@ func (c *CfClient) BoolVariation(key string, target *evaluation.Target, defaultV
435523
//
436524
// Returns defaultValue if there is an error or if the flag doesn't exist
437525
func (c *CfClient) StringVariation(key string, target *evaluation.Target, defaultValue string) (string, error) {
526+
if !c.initializedBool {
527+
c.config.Logger.Info("Error when calling StringVariation and returning default variation: 'Client is not initialized'")
528+
return defaultValue, fmt.Errorf("%w: Client is not initialized", DefaultVariationReturnedError)
529+
}
438530
value := c.evaluator.StringVariation(key, target, defaultValue)
439531
return value, nil
440532
}
@@ -443,6 +535,10 @@ func (c *CfClient) StringVariation(key string, target *evaluation.Target, defaul
443535
//
444536
// Returns defaultValue if there is an error or if the flag doesn't exist
445537
func (c *CfClient) IntVariation(key string, target *evaluation.Target, defaultValue int64) (int64, error) {
538+
if !c.initializedBool {
539+
c.config.Logger.Info("Error when calling IntVariation and returning default variation: 'Client is not initialized'")
540+
return defaultValue, fmt.Errorf("%w: Client is not initialized", DefaultVariationReturnedError)
541+
}
446542
value := c.evaluator.IntVariation(key, target, int(defaultValue))
447543
return int64(value), nil
448544
}
@@ -451,6 +547,10 @@ func (c *CfClient) IntVariation(key string, target *evaluation.Target, defaultVa
451547
//
452548
// Returns defaultValue if there is an error or if the flag doesn't exist
453549
func (c *CfClient) NumberVariation(key string, target *evaluation.Target, defaultValue float64) (float64, error) {
550+
if !c.initializedBool {
551+
c.config.Logger.Info("Error when calling NumberVariation and returning default variation: 'Client is not initialized'")
552+
return defaultValue, fmt.Errorf("%w: Client is not initialized", DefaultVariationReturnedError)
553+
}
454554
value := c.evaluator.NumberVariation(key, target, defaultValue)
455555
return value, nil
456556
}
@@ -460,13 +560,20 @@ func (c *CfClient) NumberVariation(key string, target *evaluation.Target, defaul
460560
//
461561
// Returns defaultValue if there is an error or if the flag doesn't exist
462562
func (c *CfClient) JSONVariation(key string, target *evaluation.Target, defaultValue types.JSON) (types.JSON, error) {
563+
if !c.initializedBool {
564+
c.config.Logger.Info("Error when calling JSONVariation and returning default variation: 'Client is not initialized'")
565+
return defaultValue, fmt.Errorf("%w: Client is not initialized", DefaultVariationReturnedError)
566+
}
463567
value := c.evaluator.JSONVariation(key, target, defaultValue)
464568
return value, nil
465569
}
466570

467571
// Close shuts down the Feature Flag client. After calling this, the client
468572
// should no longer be used
469573
func (c *CfClient) Close() error {
574+
if !c.initializedBool {
575+
return errors.New("attempted to close client that is not initialized")
576+
}
470577
if c.stopped.get() {
471578
return errors.New("client already closed")
472579
}
@@ -526,3 +633,14 @@ func getLogger(options ...ConfigOption) logger.Logger {
526633
}
527634
return dummyConfig.Logger
528635
}
636+
637+
// findErrorInResponse parses an auth response and returns the response error if it exists
638+
func findErrorInResponse(resp *rest.AuthenticateResponse) *rest.Error {
639+
responseErrors := []*rest.Error{resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON500}
640+
for _, responseError := range responseErrors {
641+
if responseError != nil {
642+
return responseError
643+
}
644+
}
645+
return nil
646+
}

0 commit comments

Comments
 (0)