Skip to content

Commit 2700b8a

Browse files
authored
feat(v2): add IsFeatureEnabled (#454)
Check if an experimental feature is enabled via environment variable. The feature name must be the suffix, e.g., FOO for GOOGLE_SDK_GO_EXPERIMENTAL_FOO.
1 parent 789a65a commit 2700b8a

File tree

2 files changed

+237
-0
lines changed

2 files changed

+237
-0
lines changed

v2/feature.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright 2025, Google Inc.
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions are
6+
// met:
7+
//
8+
// * Redistributions of source code must retain the above copyright
9+
// notice, this list of conditions and the following disclaimer.
10+
// * Redistributions in binary form must reproduce the above
11+
// copyright notice, this list of conditions and the following disclaimer
12+
// in the documentation and/or other materials provided with the
13+
// distribution.
14+
// * Neither the name of Google Inc. nor the names of its
15+
// contributors may be used to endorse or promote products derived from
16+
// this software without specific prior written permission.
17+
//
18+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
30+
package gax
31+
32+
import (
33+
"os"
34+
"strings"
35+
"sync"
36+
)
37+
38+
var (
39+
// featureEnabledOnce caches results for IsFeatureEnabled.
40+
featureEnabledOnce sync.Once
41+
featureEnabledStore map[string]bool
42+
)
43+
44+
// IsFeatureEnabled checks if an experimental feature is enabled via
45+
// environment variable. The environment variable must be prefixed with
46+
// "GOOGLE_SDK_GO_EXPERIMENTAL_". The feature name passed to this
47+
// function must be the suffix (e.g., "FOO" for "GOOGLE_SDK_GO_EXPERIMENTAL_FOO").
48+
// To enable the feature, the environment variable's value must be "true",
49+
// case-insensitive. The result for each name is cached on the first call.
50+
func IsFeatureEnabled(name string) bool {
51+
featureEnabledOnce.Do(func() {
52+
featureEnabledStore = make(map[string]bool)
53+
for _, env := range os.Environ() {
54+
if strings.HasPrefix(env, "GOOGLE_SDK_GO_EXPERIMENTAL_") {
55+
// Parse "KEY=VALUE"
56+
kv := strings.SplitN(env, "=", 2)
57+
if len(kv) == 2 && strings.ToLower(kv[1]) == "true" {
58+
key := strings.TrimPrefix(kv[0], "GOOGLE_SDK_GO_EXPERIMENTAL_")
59+
featureEnabledStore[key] = true
60+
}
61+
}
62+
}
63+
})
64+
return featureEnabledStore[name]
65+
}
66+
67+
// TestOnlyResetIsFeatureEnabled is for testing purposes only. It resets the cached
68+
// feature flags, allowing environment variables to be re-read on the next call to IsFeatureEnabled.
69+
// This function is not thread-safe; if another goroutine reads a feature after this
70+
// function is called but before the `featureEnabledOnce` is re-initialized by IsFeatureEnabled,
71+
// it may see an inconsistent state.
72+
func TestOnlyResetIsFeatureEnabled() {
73+
featureEnabledOnce = sync.Once{}
74+
featureEnabledStore = nil
75+
}

v2/feature_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Copyright 2025, Google Inc.
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions are
6+
// met:
7+
//
8+
// * Redistributions of source code must retain the above copyright
9+
// notice, this list of conditions and the following disclaimer.
10+
// * Redistributions in binary form must reproduce the above
11+
// copyright notice, this list of conditions and the following disclaimer
12+
// in the documentation and/or other materials provided with the
13+
// distribution.
14+
// * Neither the name of Google Inc. nor the names of its
15+
// contributors may be used to endorse or promote products derived from
16+
// this software without specific prior written permission.
17+
//
18+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19+
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20+
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21+
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22+
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23+
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24+
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26+
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
30+
package gax
31+
32+
import (
33+
"os"
34+
"testing"
35+
)
36+
37+
func TestIsFeatureEnabled(t *testing.T) {
38+
tests := []struct {
39+
name string
40+
envVar string
41+
envValue string
42+
expected bool
43+
expectedCache bool
44+
}{
45+
{
46+
name: "EnabledFeature",
47+
envVar: "GOOGLE_SDK_GO_EXPERIMENTAL_TRACING",
48+
envValue: "true",
49+
expected: true,
50+
expectedCache: true,
51+
},
52+
{
53+
name: "DisabledFeature",
54+
envVar: "GOOGLE_SDK_GO_EXPERIMENTAL_ANOTHER",
55+
envValue: "false",
56+
expected: false,
57+
expectedCache: false,
58+
},
59+
{
60+
name: "MissingFeature",
61+
envVar: "GOOGLE_SDK_GO_EXPERIMENTAL_MISSING",
62+
envValue: "",
63+
expected: false,
64+
expectedCache: false,
65+
},
66+
{
67+
name: "CaseInsensitiveTrue",
68+
envVar: "GOOGLE_SDK_GO_EXPERIMENTAL_MIXED_CASE",
69+
envValue: "True",
70+
expected: true,
71+
expectedCache: true,
72+
},
73+
{
74+
name: "CaseInsensitiveTrue",
75+
envVar: "GOOGLE_SDK_GO_EXPERIMENTAL_UPPER_CASE",
76+
envValue: "TRUE",
77+
expected: true,
78+
expectedCache: true,
79+
},
80+
{
81+
name: "OtherValue",
82+
envVar: "GOOGLE_SDK_GO_EXPERIMENTAL_INVALID",
83+
envValue: "1",
84+
expected: false,
85+
expectedCache: false,
86+
},
87+
}
88+
89+
for _, tt := range tests {
90+
t.Run(tt.name, func(t *testing.T) {
91+
// Reset the global state for each test to ensure isolation
92+
TestOnlyResetIsFeatureEnabled()
93+
94+
if tt.envValue != "" {
95+
os.Setenv(tt.envVar, tt.envValue)
96+
defer os.Unsetenv(tt.envVar)
97+
}
98+
99+
if got := IsFeatureEnabled(tt.envVar[len("GOOGLE_SDK_GO_EXPERIMENTAL_"):]); got != tt.expected {
100+
t.Errorf("IsFeatureEnabled() = %v, want %v", got, tt.expected)
101+
}
102+
103+
// Verify caching behavior after the first call
104+
if tt.expectedCache && featureEnabledStore[tt.envVar[len("GOOGLE_SDK_GO_EXPERIMENTAL_"):]] != true {
105+
t.Errorf("Feature %s not correctly cached as true", tt.envVar)
106+
} else if !tt.expectedCache && featureEnabledStore[tt.envVar[len("GOOGLE_SDK_GO_EXPERIMENTAL_"):]] == true {
107+
t.Errorf("Feature %s incorrectly cached as true", tt.envVar)
108+
}
109+
})
110+
}
111+
112+
// Test that subsequent calls to IsFeatureEnabled do not re-read environment variables
113+
t.Run("CachingPreventsReread", func(t *testing.T) {
114+
TestOnlyResetIsFeatureEnabled()
115+
116+
// Set an environment variable for the first call
117+
os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_CACHED_FEATURE", "true")
118+
defer os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_CACHED_FEATURE")
119+
120+
// First call, should read from env and cache
121+
if !IsFeatureEnabled("CACHED_FEATURE") {
122+
t.Fatalf("Expected CACHED_FEATURE to be enabled on first call")
123+
}
124+
125+
// Unset the environment variable after the first call
126+
os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_CACHED_FEATURE")
127+
128+
// Second call, should use cached value and still be true
129+
if !IsFeatureEnabled("CACHED_FEATURE") {
130+
t.Errorf("Expected CACHED_FEATURE to remain enabled due to caching")
131+
}
132+
// Check a new feature that was never set, should be false
133+
if IsFeatureEnabled("NEW_FEATURE_AFTER_CACHE") {
134+
t.Errorf("Expected NEW_FEATURE_AFTER_CACHE to be false as it was set after init")
135+
}
136+
})
137+
138+
// Test with multiple environment variables set
139+
t.Run("MultipleEnvVars", func(t *testing.T) {
140+
TestOnlyResetIsFeatureEnabled()
141+
142+
os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_FEATURE1", "true")
143+
os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_FEATURE2", "false")
144+
os.Setenv("GOOGLE_SDK_GO_EXPERIMENTAL_FEATURE3", "true")
145+
defer os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_FEATURE1")
146+
defer os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_FEATURE2")
147+
defer os.Unsetenv("GOOGLE_SDK_GO_EXPERIMENTAL_FEATURE3")
148+
149+
if !IsFeatureEnabled("FEATURE1") {
150+
t.Errorf("Expected FEATURE1 to be enabled")
151+
}
152+
if IsFeatureEnabled("FEATURE2") {
153+
t.Errorf("Expected FEATURE2 to be disabled")
154+
}
155+
if !IsFeatureEnabled("FEATURE3") {
156+
t.Errorf("Expected FEATURE3 to be enabled")
157+
}
158+
if IsFeatureEnabled("NONEXISTENT_FEATURE") {
159+
t.Errorf("Expected NONEXISTENT_FEATURE to be disabled")
160+
}
161+
})
162+
}

0 commit comments

Comments
 (0)