Repository: Unleash/unleash-client-go
Pull request #196 (2025-05-28 03:57:30)

Add support for OnUpdate hook
This is what I believe were the requested changes in https://github.com/Unleash/unleash-client-go/pull/193. It also addresses some (what I believe to be) issues in the original PR, such as the unnecessary declaration and assignment of the `onUpdate` channel. The only required channel is the `update` channel that was actually implemented.

The original issue addressed by this PR is #192.

I'm posting the original PR description below.

---

> ### Describe the feature request
> 
> I am attempting to support switching the log level for my application via an unleash flag. However unless there is a different approach to dealing with update hooks besides implementing my own timer, I am overlooking it.
> 
> I would like to use a callback such as OnChange or even OnUpdate to detect when flags are modified instead of testing for the flag in every log call regardless of the metrics.
> 
> Do you have any ideas on how to approach this?
> ### Background
> 
> _No response_
> ### Solution suggestions
> 
> _No response_

nathan-fiscaletti: @chriswk I pushed one more update that addresses that issue with a new error value.
nathan-fiscaletti: @chriswk I just updated `repository_test.go` with a test that should cover this new logic. Let me know if it looks sane.
nathan-fiscaletti: @chriswk Haven't heard anything in a week, hoping we can get this in

Patch:
diff --git a/client.go b/client.go
index b4f1faf..c9fe010 100644
--- a/client.go
+++ b/client.go
@@ -54,6 +54,7 @@ type Client struct {
 	impressionListener ImpressionListener
 	ready              chan bool
 	onReady            chan struct{}
+	update             chan bool
 	close              chan struct{}
 	closed             chan struct{}
 	count              chan metric
@@ -78,7 +79,8 @@ func (ec errorChannels) err(err error) {
 
 type repositoryChannels struct {
 	errorChannels
-	ready chan bool
+	ready  chan bool
+	update chan bool
 }
 
 type metricsChannels struct {
@@ -108,6 +110,7 @@ func NewClient(options ...ConfigOption) (*Client, error) {
 		errorChannels: errChannels,
 		onReady:       make(chan struct{}),
 		ready:         make(chan bool, 1),
+		update:        make(chan bool, 1),
 		count:         make(chan metric),
 		sent:          make(chan MetricsData),
 		registered:    make(chan ClientData, 1),
@@ -197,6 +200,7 @@ func NewClient(options ...ConfigOption) (*Client, error) {
 		repositoryChannels{
 			errorChannels: errChannels,
 			ready:         uc.ready,
+			update:        uc.update,
 		},
 	)
 
@@ -246,6 +250,10 @@ func (uc *Client) sync() {
 			if uc.repositoryListener != nil {
 				uc.repositoryListener.OnReady()
 			}
+		case <-uc.update:
+			if uc.repositoryListener != nil {
+				uc.repositoryListener.OnUpdate()
+			}
 		case m := <-uc.count:
 			if uc.metricsListener != nil {
 				uc.metricsListener.OnCount(m.Name, m.Enabled)
@@ -263,6 +271,7 @@ func (uc *Client) sync() {
 				uc.impressionListener.OnImpression(ie)
 			}
 		case <-uc.close:
+			close(uc.update)
 			close(uc.closed)
 			return
 		}
diff --git a/debuglistener.go b/debuglistener.go
index f4a0f21..401fd39 100644
--- a/debuglistener.go
+++ b/debuglistener.go
@@ -24,6 +24,11 @@ func (l DebugListener) OnReady() {
 	fmt.Printf("READY\n")
 }
 
+// OnUpdate prints to the console when the repository is updated.
+func (l DebugListener) OnUpdate() {
+	fmt.Printf("UPDATE\n")
+}
+
 // OnCount prints to the console when the feature is queried.
 func (l DebugListener) OnCount(name string, enabled bool) {
 	fmt.Printf("Counted '%s'  as enabled? %v\n", name, enabled)
diff --git a/nooplistener.go b/nooplistener.go
index 1c54a28..d51f391 100644
--- a/nooplistener.go
+++ b/nooplistener.go
@@ -15,6 +15,10 @@ func (l NoopListener) OnWarning(warning error) {
 func (l NoopListener) OnReady() {
 }
 
+// The repository was refreshed.
+func (l NoopListener) OnUpdate() {
+}
+
 // The feature is queried.
 func (l NoopListener) OnCount(name string, enabled bool) {
 }
diff --git a/repository.go b/repository.go
index b28c76e..d92f25a 100644
--- a/repository.go
+++ b/repository.go
@@ -3,6 +3,7 @@ package unleash
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"math"
 	"net/http"
@@ -15,6 +16,10 @@ import (
 
 var SEGMENT_CLIENT_SPEC_VERSION = "4.3.1"
 
+var (
+	errNoChange = errors.New("no change")
+)
+
 type repository struct {
 	repositoryChannels
 	sync.RWMutex
@@ -64,15 +69,30 @@ func newRepository(options repositoryOptions, channels repositoryChannels) *repo
 }
 
 func (r *repository) fetchAndReportError() {
-	err := r.fetch()
+	var (
+		isUnchanged bool
+		err         error
+	)
+
+	err = r.fetch()
+
+	// Extract unchanged error from error
+	if err != nil {
+		isUnchanged = errors.Is(err, errNoChange)
+		if isUnchanged {
+			err = nil
+		}
+	}
+
 	if err != nil {
 		if urlErr, ok := err.(*url.Error); !(ok && urlErr.Err == context.Canceled) {
 			r.err(err)
 		}
-	}
-	if !r.isReady && err == nil {
+	} else if !r.isReady {
 		r.isReady = true
 		r.ready <- true
+	} else if !isUnchanged {
+		r.update <- true
 	}
 }
 
@@ -147,7 +167,7 @@ func (r *repository) fetch() error {
 	defer resp.Body.Close()
 
 	if resp.StatusCode == http.StatusNotModified {
-		return nil
+		return errNoChange
 	}
 	if err := r.statusIsOK(resp); err != nil {
 		return err
diff --git a/unleash.go b/unleash.go
index d0635c2..a058328 100644
--- a/unleash.go
+++ b/unleash.go
@@ -33,6 +33,10 @@ type RepositoryListener interface {
 	// OnReady is called when the client has loaded the feature toggles from
 	// the Unleash server.
 	OnReady()
+
+	// OnUpdate is called each time the client has loaded the features toggles from
+	// the Unleash server after the initial request.
+	OnUpdate()
 }
 
 // ImpressionListener defines an interface that can be implemented in order to receive
diff --git a/unleash_mock.go b/unleash_mock.go
index 2d440e3..bfda1e7 100644
--- a/unleash_mock.go
+++ b/unleash_mock.go
@@ -29,6 +29,10 @@ func (l *MockedListener) OnReady() {
 	l.Called()
 }
 
+func (l *MockedListener) OnUpdate() {
+	l.Called()
+}
+
 func (l *MockedListener) OnCount(name string, enabled bool) {
 	l.Called(name, enabled)
 }


Test patch:
diff --git a/repository_test.go b/repository_test.go
index 7f26fc9..a0810e6 100644
--- a/repository_test.go
+++ b/repository_test.go
@@ -84,6 +84,72 @@ func TestRepository_GetFeaturesFail(t *testing.T) {
 	client.Close()
 }
 
+func TestRepository_OnUpdateCalledWhenFeaturesChangeOnly(t *testing.T) {
+	assert := assert.New(t)
+	featuresCalls := make(chan int, 10)
+	var sendStatus304 int32
+	prevStatus := 0
+	srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		switch req.Method + " " + req.URL.Path {
+		case "POST /client/register":
+		case "GET /client/features":
+			status304 := atomic.LoadInt32(&sendStatus304) == 1
+			status := 0
+			if status304 {
+				status = 304
+				rw.WriteHeader(304)
+			} else {
+				status = 200
+				rw.WriteHeader(200)
+				writeJSON(rw, api.FeatureResponse{})
+			}
+			if status != prevStatus {
+				featuresCalls <- status
+				prevStatus = status
+			}
+		case "POST /client/metrics":
+		default:
+			t.Fatalf("Unexpected request: %+v", req)
+		}
+	}))
+	defer srv.Close()
+
+	update := make(chan bool)
+	mockListener := &MockedListener{}
+	mockListener.On("OnUpdate").Run(func(args mock.Arguments) { update <- true }).Return()
+	mockListener.On("OnReady").Run(func(args mock.Arguments) {}).Return()
+	mockListener.On("OnRegistered", mock.AnythingOfType("ClientData"))
+	mockListener.On("OnSent", mock.AnythingOfType("MetricsData")).Return()
+	client, err := NewClient(
+		WithUrl(srv.URL),
+		WithAppName(mockAppName),
+		WithInstanceId(mockInstanceId),
+		WithListener(mockListener),
+		WithRefreshInterval(time.Millisecond),
+	)
+	assert.Nil(err, "client should not return an error")
+
+	assert.Equal(200, <-featuresCalls)
+
+	select {
+	case <-update:
+	case <-time.NewTimer(time.Second).C:
+		t.Fatal("client did not call OnUpdate")
+	}
+
+	atomic.StoreInt32(&sendStatus304, 1)
+	assert.Equal(304, <-featuresCalls)
+
+	select {
+	case <-update:
+		t.Fatal("client called OnUpdate but it shouldn't have")
+	case <-time.NewTimer(time.Second).C:
+	}
+
+	close(update)
+	client.Close()
+}
+
 func TestRepository_ParseAPIResponse(t *testing.T) {
 	assert := assert.New(t)
 	data := []byte(`{