Repository: Unleash/unleash-client-go
Pull request #156 (2023-11-29 13:56:28)

feat: obey http status and backoff on 429, 50x

This follows the same pattern as Java and .NET.  each 429, 50x error will increment error count, and reset skips to error, then for each trigger reduce skips by 1 until you reach 0, then perform the action again. on success reduce error count by 1 and reset skips to error count.

On 401,403,404 will increment skips/errors to max at once and return an error saying you might've configured the client wrong


Patch:
diff --git a/.github/workflows/build-prs.yml b/.github/workflows/build-prs.yml
new file mode 100644
index 0000000..64bb9e1
--- /dev/null
+++ b/.github/workflows/build-prs.yml
@@ -0,0 +1,46 @@
+name: Build PRs
+
+on:
+  pull_request:
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        version: [1.13, 1.15, 1.16]
+    steps:
+      - uses: actions/checkout@v2
+        name: Checkout code
+      - uses: actions/checkout@v2
+        name: Checkout client specifications
+        with:
+          repository: Unleash/client-specification
+          ref: refs/tags/v5.1.0
+          path: testdata/client-specification
+      - uses: actions/setup-go@v2
+        name: Setup go
+        with:
+          go-version: ${{ matrix.version }}
+      - name: Get deps
+        run: go get -t -v ./...
+      - name: Go vet
+        run: go vet ./...
+      - name: Run spec tests
+        run: go test -v ./... -tags='norace'
+      - name: Run all tests with race detection
+        timeout-minutes: 1
+        run: go test -race -covermode atomic -coverprofile=profile.cov -v ./... -tags='!norace'
+      - name: Send coverage
+        uses: shogo82148/actions-goveralls@v1
+        with:
+          path-to-profile: profile.cov
+          flag-name: Go-${{ matrix.version }}
+          parallel: true
+  finish:
+    needs: build
+    runs-on: ubuntu-latest
+    steps:
+      - uses: shogo82148/actions-goveralls@v1
+        with:
+          parallel-finished: true
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index d9f3be2..e68f2af 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,6 +1,9 @@
 name: Build
 
-on: [push]
+on:
+  push:
+    branches:
+      - main
 
 jobs:
   build:
diff --git a/api/feature.go b/api/feature.go
index 8bd8c04..2cea17b 100644
--- a/api/feature.go
+++ b/api/feature.go
@@ -26,6 +26,9 @@ type Feature struct {
 	// Name is the name of the feature toggle.
 	Name string `json:"name"`
 
+	// Type is the type of the feature toggle.
+	Type string `json:"type"`
+
 	// Description is a description of the feature toggle.
 	Description string `json:"description"`
 
@@ -89,6 +92,7 @@ func (vc VariantCollection) GetVariant(ctx *context.Context) *Variant {
 			variant = &v.Variant
 		}
 		variant.Enabled = true
+		variant.FeatureEnabled = true
 		return variant
 	}
 	return DISABLED_VARIANT
diff --git a/api/variant.go b/api/variant.go
index bf8b229..8211dd2 100644
--- a/api/variant.go
+++ b/api/variant.go
@@ -3,8 +3,9 @@ package api
 import "github.com/Unleash/unleash-client-go/v4/context"
 
 var DISABLED_VARIANT = &Variant{
-	Name:    "disabled",
-	Enabled: false,
+	Name:           "disabled",
+	Enabled:        false,
+	FeatureEnabled: false,
 }
 
 type Payload struct {
@@ -26,8 +27,11 @@ type Variant struct {
 	Name string `json:"name"`
 	// Payload is the value of the variant payload
 	Payload Payload `json:"payload"`
-	// Enabled indicates whether the feature which is extend by this variant was enabled or not.
+	// Enabled indicates whether the variant is enabled. This is only false when
+	// it's a default variant.
 	Enabled bool `json:"enabled"`
+	// FeatureEnabled indicates whether the Feature for this variant is enabled.
+	FeatureEnabled bool `json:"featureEnabled"`
 }
 
 type VariantInternal struct {
@@ -81,7 +85,10 @@ func (o Override) matchValue(ctx *context.Context) bool {
 	return false
 }
 
-// Get default variant if no variant is found
+// Get default variant if feature is not found or if the feature is disabled.
+//
+// Rather than checking against this particular variant you should be checking
+// the returned variant's Enabled and FeatureEnabled properties.
 func GetDefaultVariant() *Variant {
 	return DISABLED_VARIANT
 }
diff --git a/client.go b/client.go
index a2a6c57..b757de6 100644
--- a/client.go
+++ b/client.go
@@ -31,6 +31,14 @@ var defaultStrategies = []strategy.Strategy{
 	*s.NewFlexibleRolloutStrategy(),
 }
 
+// disabledVariantFeatureEnabled is similar to api.DISABLED_VARIANT but we want
+// to discourage public usage so it's internal until there's a need to expose it.
+var disabledVariantFeatureEnabled = &api.Variant{
+	Name:           "disabled",
+	Enabled:        false,
+	FeatureEnabled: true,
+}
+
 // Client is a structure representing an API client of an Unleash server.
 type Client struct {
 	errorChannels
@@ -381,7 +389,7 @@ func (uc *Client) isParentDependencySatisfied(feature *api.Feature, context cont
 func (uc *Client) GetVariant(feature string, options ...VariantOption) *api.Variant {
 	variant := uc.getVariantWithoutMetrics(feature, options...)
 	defer func() {
-		uc.metrics.countVariants(feature, variant.Enabled, variant.Name)
+		uc.metrics.countVariants(feature, variant.FeatureEnabled, variant.Name)
 	}()
 	return variant
 }
@@ -436,7 +444,7 @@ func (uc *Client) getVariantWithoutMetrics(feature string, options ...VariantOpt
 	}
 
 	if len(f.Variants) == 0 {
-		return defaultVariant
+		return disabledVariantFeatureEnabled
 	}
 
 	return api.VariantCollection{
diff --git a/metrics.go b/metrics.go
index 04a426a..94eb178 100644
--- a/metrics.go
+++ b/metrics.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"math"
 	"net/http"
 	"net/url"
 	"sync"
@@ -66,6 +67,9 @@ type metrics struct {
 	closed   chan struct{}
 	ctx      context.Context
 	cancel   func()
+	maxSkips float64
+	errors   float64
+	skips    float64
 }
 
 func newMetrics(options metricsOptions, channels metricsChannels) *metrics {
@@ -75,6 +79,9 @@ func newMetrics(options metricsOptions, channels metricsChannels) *metrics {
 		started:         time.Now(),
 		close:           make(chan struct{}),
 		closed:          make(chan struct{}),
+		maxSkips:        10,
+		errors:          0,
+		skips:           0,
 	}
 	ctx, cancel := context.WithCancel(context.Background())
 	m.ctx = ctx
@@ -111,7 +118,11 @@ func (m *metrics) sync() {
 	for {
 		select {
 		case <-m.ticker.C:
-			m.sendMetrics()
+			if m.skips == 0 {
+				m.sendMetrics()
+			} else {
+				m.decrementSkip()
+			}
 		case <-m.close:
 			close(m.closed)
 			return
@@ -136,7 +147,24 @@ func (m *metrics) registerInstance() {
 
 	m.registered <- payload
 }
+func (m *metrics) backoff() {
+	m.errors = math.Min(m.maxSkips, m.errors+1)
+	m.skips = m.errors
+}
+
+func (m *metrics) configurationError() {
+	m.errors = m.maxSkips
+	m.skips = m.errors
+}
 
+func (m *metrics) successfulPost() {
+	m.errors = math.Max(0, m.errors-1)
+	m.skips = m.errors
+}
+
+func (m *metrics) decrementSkip() {
+	m.skips = math.Max(0, m.skips-1)
+}
 func (m *metrics) sendMetrics() {
 	m.bucketMu.Lock()
 	bucket := m.resetBucket()
@@ -160,6 +188,11 @@ func (m *metrics) sendMetrics() {
 	defer resp.Body.Close()
 
 	if resp.StatusCode < http.StatusOK || resp.StatusCode > http.StatusMultipleChoices {
+		if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound {
+			m.configurationError()
+		} else if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError {
+			m.backoff()
+		}
 		m.warn(fmt.Errorf("%s return %d", u.String(), resp.StatusCode))
 		// The post failed, re-add the metrics we attempted to send so
 		// they are included in the next post.
@@ -174,6 +207,7 @@ func (m *metrics) sendMetrics() {
 		m.bucket.Start = bucket.Start
 		m.bucketMu.Unlock()
 	} else {
+		m.successfulPost()
 		m.sent <- payload
 	}
 }
diff --git a/repository.go b/repository.go
index 31e915f..7921f38 100644
--- a/repository.go
+++ b/repository.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"math"
 	"net/http"
 	"net/url"
 	"sync"
@@ -26,6 +27,9 @@ type repository struct {
 	isReady       bool
 	refreshTicker *time.Ticker
 	segments      map[int][]api.Constraint
+	errors        float64
+	maxSkips      float64
+	skips         float64
 }
 
 func newRepository(options repositoryOptions, channels repositoryChannels) *repository {
@@ -36,6 +40,9 @@ func newRepository(options repositoryOptions, channels repositoryChannels) *repo
 		closed:             make(chan struct{}),
 		refreshTicker:      time.NewTicker(options.refreshInterval),
 		segments:           map[int][]api.Constraint{},
+		errors:             0,
+		maxSkips:           10,
+		skips:              0,
 	}
 	ctx, cancel := context.WithCancel(context.Background())
 	repo.ctx = ctx
@@ -80,11 +87,33 @@ func (r *repository) sync() {
 			close(r.closed)
 			return
 		case <-r.refreshTicker.C:
-			r.fetchAndReportError()
+			if r.skips == 0 {
+				r.fetchAndReportError()
+			} else {
+				r.decrementSkips()
+			}
 		}
 	}
 }
 
+func (r *repository) backoff() {
+	r.errors = math.Min(r.maxSkips, r.errors+1)
+	r.skips = r.errors
+}
+
+func (r *repository) successfulFetch() {
+	r.errors = math.Max(0, r.errors-1)
+	r.skips = r.errors
+}
+
+func (r *repository) decrementSkips() {
+	r.skips = math.Max(0, r.skips-1)
+}
+func (r *repository) configurationError() {
+	r.errors = r.maxSkips
+	r.skips = r.errors
+}
+
 func (r *repository) fetch() error {
 	u, _ := r.options.url.Parse(getFetchURLPath(r.options.projectName))
 
@@ -119,7 +148,7 @@ func (r *repository) fetch() error {
 	if resp.StatusCode == http.StatusNotModified {
 		return nil
 	}
-	if err := statusIsOK(resp); err != nil {
+	if err := r.statusIsOK(resp); err != nil {
 		return err
 	}
 
@@ -133,14 +162,21 @@ func (r *repository) fetch() error {
 	r.etag = resp.Header.Get("Etag")
 	r.segments = featureResp.SegmentsMap()
 	r.options.storage.Reset(featureResp.FeatureMap(), true)
+	r.successfulFetch()
 	r.Unlock()
 	return nil
 }
 
-func statusIsOK(resp *http.Response) error {
+func (r *repository) statusIsOK(resp *http.Response) error {
 	s := resp.StatusCode
-	if 200 <= s && s < 300 {
+	if http.StatusOK <= s && s < http.StatusMultipleChoices {
 		return nil
+	} else if s == http.StatusUnauthorized || s == http.StatusForbidden || s == http.StatusNotFound {
+		r.configurationError()
+		return fmt.Errorf("%s %s returned status code %d your SDK is most likely misconfigured, backing off to maximum (%f times our interval)", resp.Request.Method, resp.Request.URL, s, r.maxSkips)
+	} else if s == http.StatusTooManyRequests || s >= http.StatusInternalServerError {
+		r.backoff()
+		return fmt.Errorf("%s %s returned status code %d, backing off (%f times our interval)", resp.Request.Method, resp.Request.URL, s, r.errors)
 	}
 
 	return fmt.Errorf("%s %s returned status code %d", resp.Request.Method, resp.Request.URL, s)
diff --git a/utils.go b/utils.go
index d79d3d5..7021925 100644
--- a/utils.go
+++ b/utils.go
@@ -66,7 +66,7 @@ func every(slice interface{}, condition func(interface{}) bool) bool {
 		return false
 	}
 
-	if (sliceValue.Len() == 0) {
+	if sliceValue.Len() == 0 {
 		return false
 	}
 


Test patch:
diff --git a/client_test.go b/client_test.go
index ba5331c..8950ce6 100644
--- a/client_test.go
+++ b/client_test.go
@@ -740,6 +740,8 @@ func TestClient_VariantShouldRespectConstraint(t *testing.T) {
 
 	assert.True(variant.Enabled)
 
+	assert.True(variant.FeatureEnabled)
+
 	variantFromResolver := client.GetVariant(feature, WithVariantContext(context.Context{
 		Properties: map[string]string{"custom-id": "custom-ctx", "semver": "3.2.2", "age": "18", "domain": "unleashtest"},
 	}), WithVariantResolver(func(featureName string) *api.Feature {
@@ -753,6 +755,8 @@ func TestClient_VariantShouldRespectConstraint(t *testing.T) {
 
 	assert.True(variantFromResolver.Enabled)
 
+	assert.True(variantFromResolver.FeatureEnabled)
+
 	assert.True(gock.IsDone(), "there should be no more mocks")
 }
 
@@ -855,6 +859,8 @@ func TestClient_VariantShouldFailWhenSegmentConstraintsDontMatch(t *testing.T) {
 
 	assert.False(variant.Enabled)
 
+	assert.False(variant.FeatureEnabled)
+
 	variantFromResolver := client.GetVariant(feature, WithVariantContext(context.Context{
 		Properties: map[string]string{"custom-id": "custom-ctx", "semver": "3.2.2", "age": "18", "domain": "unleashtest"},
 	}), WithVariantResolver(func(featureName string) *api.Feature {
@@ -868,6 +874,8 @@ func TestClient_VariantShouldFailWhenSegmentConstraintsDontMatch(t *testing.T) {
 
 	assert.False(variantFromResolver.Enabled)
 
+	assert.False(variantFromResolver.FeatureEnabled)
+
 	assert.True(gock.IsDone(), "there should be no more mocks")
 }
 
@@ -953,6 +961,8 @@ func TestClient_ShouldFavorStrategyVariantOverFeatureVariant(t *testing.T) {
 
 	assert.True(strategyVariant.Enabled)
 
+	assert.True(strategyVariant.FeatureEnabled)
+
 	assert.Equal("strategyVariantName", strategyVariant.Name)
 
 	assert.True(gock.IsDone(), "there should be no more mocks")
@@ -1051,7 +1061,86 @@ func TestClient_ShouldReturnOldVariantForNonMatchingStrategyVariant(t *testing.T
 
 	assert.True(strategyVariant.Enabled)
 
+	assert.True(strategyVariant.FeatureEnabled)
+
 	assert.Equal("willBeSelected", strategyVariant.Name)
 
 	assert.True(gock.IsDone(), "there should be no more mocks")
 }
+
+func TestClient_VariantFromEnabledFeatureWithNoVariants(t *testing.T) {
+	assert := assert.New(t)
+	defer gock.OffAll()
+
+	gock.New(mockerServer).
+		Post("/client/register").
+		MatchHeader("UNLEASH-APPNAME", mockAppName).
+		MatchHeader("UNLEASH-INSTANCEID", mockInstanceId).
+		Reply(200)
+
+	feature := "feature-no-variants"
+	features := []api.Feature{
+		{
+			Name:        feature,
+			Description: "feature-desc",
+			Enabled:     true,
+			CreatedAt:   time.Date(1974, time.May, 19, 1, 2, 3, 4, time.UTC),
+			Strategy:    "default-strategy",
+			Strategies: []api.Strategy{
+				{
+					Id:   1,
+					Name: "default",
+				},
+			},
+		},
+	}
+
+	gock.New(mockerServer).
+		Get("/client/features").
+		Reply(200).
+		JSON(api.FeatureResponse{
+			Features: features,
+			Segments: []api.Segment{},
+		})
+
+	mockListener := &MockedListener{}
+	mockListener.On("OnReady").Return()
+	mockListener.On("OnRegistered", mock.AnythingOfType("ClientData"))
+	mockListener.On("OnCount", feature, true).Return()
+	mockListener.On("OnError").Return()
+
+	client, err := NewClient(
+		WithUrl(mockerServer),
+		WithAppName(mockAppName),
+		WithInstanceId(mockInstanceId),
+		WithListener(mockListener),
+	)
+
+	assert.NoError(err)
+	client.WaitForReady()
+
+	variant := client.GetVariant(feature, WithVariantContext(context.Context{}))
+
+	assert.False(variant.Enabled)
+
+	assert.True(variant.FeatureEnabled)
+
+	assert.Equal(disabledVariantFeatureEnabled, variant)
+
+	variantFromResolver := client.GetVariant(feature, WithVariantContext(context.Context{}), WithVariantResolver(func(featureName string) *api.Feature {
+		if featureName == features[0].Name {
+			return &features[0]
+		} else {
+			t.Fatalf("the feature name passed %s was not the expected one %s", featureName, features[0].Name)
+			return nil
+		}
+	}))
+
+	assert.False(variantFromResolver.Enabled)
+
+	assert.True(variantFromResolver.FeatureEnabled)
+
+	assert.Equal(disabledVariantFeatureEnabled, variantFromResolver)
+
+	assert.True(gock.IsDone(), "there should be no more mocks")
+}
diff --git a/metrics_test.go b/metrics_test.go
index 689d6c5..82fca5d 100644
--- a/metrics_test.go
+++ b/metrics_test.go
@@ -328,14 +328,101 @@ func TestMetrics_ShouldNotCountMetricsForParentToggles(t *testing.T) {
 		WithInstanceId(mockInstanceId),
 		WithListener(mockListener),
 	)
-
+	assert.Nil(err, "client should not return an error")
 	client.WaitForReady()
 	client.IsEnabled("child")
 
 	assert.EqualValues(client.metrics.bucket.Toggles["child"].Yes, 1)
 	assert.EqualValues(client.metrics.bucket.Toggles["parent"].Yes, 0)
-	client.Close()
+	err = client.Close()
 
 	assert.Nil(err, "client should not return an error")
 	assert.True(gock.IsDone(), "there should be no more mocks")
 }
+
+func TestMetrics_ShouldBackoffOn500(t *testing.T) {
+	assert := assert.New(t)
+	defer gock.OffAll()
+
+	gock.New(mockerServer).
+		Post("/client/register").
+		Reply(200)
+	gock.New(mockerServer).
+		Post("/client/metrics").
+		Persist().
+		Reply(500)
+	gock.New(mockerServer).
+		Get("/client/features").
+		Reply(200).
+		JSON(api.FeatureResponse{})
+	mockListener := &MockedListener{}
+	mockListener.On("OnReady").Return()
+	mockListener.On("OnRegistered", mock.AnythingOfType("ClientData")).Return()
+	mockListener.On("OnCount", "foo", false).Return()
+	mockListener.On("OnCount", "bar", false).Return()
+	mockListener.On("OnCount", "baz", false).Return()
+	mockListener.On("OnWarning", mock.MatchedBy(func(e error) bool {
+		return strings.HasSuffix(e.Error(), "http://foo.com/client/metrics return 500")
+	})).Return()
+	mockListener.On("OnError", mock.Anything).Return()
+
+	client, err := NewClient(
+		WithUrl(mockerServer),
+		WithMetricsInterval(50*time.Millisecond),
+		WithAppName(mockAppName),
+		WithInstanceId(mockInstanceId),
+		WithListener(mockListener),
+	)
+	assert.Nil(err, "client should not return an error")
+
+	client.WaitForReady()
+	client.IsEnabled("foo")
+	client.IsEnabled("bar")
+	client.IsEnabled("baz")
+
+	time.Sleep(320 * time.Millisecond)
+	err = client.Close()
+	assert.Equal(float64(3), client.metrics.errors)
+	assert.Nil(err, "Client should close without a problem")
+
+}
+
+func TestMetrics_ErrorCountShouldDecreaseIfSuccessful(t *testing.T) {
+	assert := assert.New(t)
+	defer gock.OffAll()
+
+	gock.New(mockerServer).
+		Post("/client/register").
+		Reply(200)
+	gock.New(mockerServer).
+		Post("/client/metrics").
+		Times(2).
+		Reply(500)
+	gock.New(mockerServer).
+		Get("/client/features").
+		Reply(200).
+		JSON(api.FeatureResponse{})
+	gock.New(mockerServer).
+		Post("/client/metrics").
+		Persist().
+		Reply(200)
+
+	client, err := NewClient(
+		WithUrl(mockerServer),
+		WithMetricsInterval(50*time.Millisecond),
+		WithAppName(mockAppName),
+		WithInstanceId(mockInstanceId),
+	)
+	assert.Nil(err, "client should not return an error")
+
+	client.WaitForReady()
+	client.IsEnabled("foo")
+	client.IsEnabled("bar")
+	client.IsEnabled("baz")
+	time.Sleep(360 * time.Millisecond)
+	client.IsEnabled("foo")
+	time.Sleep(100 * time.Millisecond)
+	err = client.Close()
+	assert.Equal(float64(0), client.metrics.errors)
+	assert.Nil(err, "Client should close without a problem")
+}
diff --git a/repository_test.go b/repository_test.go
index dcdbaeb..2854730 100644
--- a/repository_test.go
+++ b/repository_test.go
@@ -3,6 +3,7 @@ package unleash
 import (
 	"bytes"
 	"encoding/json"
+	"gopkg.in/h2non/gock.v1"
 	"net/http"
 	"net/http/httptest"
 	"strings"
@@ -127,3 +128,62 @@ func TestRepository_ParseAPIResponse(t *testing.T) {
 	assert.Equal(2, len(response.Features))
 	assert.Equal(0, len(response.Segments))
 }
+
+
+func TestRepository_backs_off_on_http_statuses(t *testing.T) {
+	a := assert.New(t)
+	testCases := []struct {
+		statusCode int
+		errorCount float64
+	}{
+		{ 401, 10},
+		{ 403, 10},
+		{ 404, 10},
+		{ 429, 1},
+		{ 500, 1},
+		{ 502, 1},
+		{ 503, 1},
+	}
+	defer gock.Off()
+	for _, tc := range testCases {
+		gock.New(mockerServer).
+			Get("/client/features").
+			Reply(tc.statusCode)
+		client, err := NewClient(
+			WithUrl(mockerServer),
+			WithAppName(mockAppName),
+			WithDisableMetrics(true),
+			WithInstanceId(mockInstanceId),
+			WithRefreshInterval(time.Millisecond * 15),
+		)
+		a.Nil(err)
+		time.Sleep(20 * time.Millisecond)
+		err = client.Close()
+		a.Equal(tc.errorCount, client.repository.errors)
+		a.Nil(err)
+	}
+}
+func TestRepository_back_offs_are_gradually_reduced_on_success(t *testing.T) {
+	a := assert.New(t)
+	defer gock.Off()
+	gock.New(mockerServer).
+	    Get("/client/features").
+	    Times(4).
+		Reply(429)
+	gock.New(mockerServer).
+		Get("/client/features").
+		Reply(200).
+		BodyString(`{ "version": 2, "features": []}`)
+	client, err := NewClient(
+		WithUrl(mockerServer),
+		WithAppName(mockAppName),
+		WithDisableMetrics(true),
+		WithInstanceId(mockInstanceId),
+		WithRefreshInterval(time.Millisecond * 10),
+	)
+	a.Nil(err)
+	client.WaitForReady()
+	err = client.Close()
+	a.Equal(float64(3), client.repository.errors) // 4 failures, and then one success, should reduce error count to 3
+	a.Nil(err)
+}
\ No newline at end of file
diff --git a/spec_test.go b/spec_test.go
index 3355c77..7fb566a 100644
--- a/spec_test.go
+++ b/spec_test.go
@@ -1,3 +1,4 @@
+//go:build norace
 // +build norace
 
 package unleash
@@ -36,11 +37,18 @@ type TestCase struct {
 	ExpectedResult bool            `json:"expectedResult"`
 }
 
+type expectedVariantResult struct {
+	api.Variant
+	// SpecFeatureEnabled represents the spec's feature_enabled field which has a
+	// different JSON field name than api.Variant
+	SpecFeatureEnabled bool `json:"feature_enabled"`
+}
+
 type VariantTestCase struct {
-	Description    string          `json:"description"`
-	Context        context.Context `json:"context"`
-	ToggleName     string          `json:"toggleName"`
-	ExpectedResult *api.Variant    `json:"expectedResult"`
+	Description    string                 `json:"description"`
+	Context        context.Context        `json:"context"`
+	ToggleName     string                 `json:"toggleName"`
+	ExpectedResult *expectedVariantResult `json:"expectedResult"`
 }
 
 type Runner interface {
@@ -88,7 +96,9 @@ func (vtc VariantTestCase) RunWithClient(client *Client) func(*testing.T) {
 		result := client.GetVariant(vtc.ToggleName, WithVariantContext(vtc.Context))
 		wg.Wait()
 		assert.Equal(t, vtc.ExpectedResult.Enabled, result.Enabled)
-		assert.Equal(t, vtc.ExpectedResult, client.GetVariant(vtc.ToggleName))
+		// copy over the FeatureEnabled field with different JSON tag
+		vtc.ExpectedResult.FeatureEnabled = vtc.ExpectedResult.SpecFeatureEnabled
+		assert.Equal(t, &vtc.ExpectedResult.Variant, result)
 	}
 }
 
diff --git a/unleash_test.go b/unleash_test.go
index 1bf07a0..1e96730 100644
--- a/unleash_test.go
+++ b/unleash_test.go
@@ -48,14 +48,17 @@ func Test_withVariants(t *testing.T) {
 		t.Fail()
 	}
 
-	feature := unleash.GetVariant("Demo")
-	if feature.Enabled == false {
+	variant := unleash.GetVariant("Demo")
+	if variant.Enabled == false {
+		t.Fatalf("Expected variant to be enabled")
+	}
+	if variant.FeatureEnabled == false {
 		t.Fatalf("Expected feature to be enabled")
 	}
-	if feature.Name != "small" && feature.Name != "medium" {
+	if variant.Name != "small" && variant.Name != "medium" {
 		t.Fatalf("Expected one of the variant names")
 	}
-	if feature.Payload.Value != "35" && feature.Payload.Value != "55" {
+	if variant.Payload.Value != "35" && variant.Payload.Value != "55" {
 		t.Fatalf("Expected one of the variant payloads")
 	}
 	err = unleash.Close()
diff --git a/utils_test.go b/utils_test.go
index 204269b..58bca64 100644
--- a/utils_test.go
+++ b/utils_test.go
@@ -116,4 +116,3 @@ func TestContains(t *testing.T) {
 		}
 	})
 }
-