Skip to content

Commit 16f9cda

Browse files
authored
feat(feature-flags): support quota limiting for feature flags (#85)
* tests * helper method * bump version + changelog
1 parent ec95a60 commit 16f9cda

File tree

5 files changed

+170
-59
lines changed

5 files changed

+170
-59
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 1.3.2
2+
3+
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.1...v1.3.2)
4+
15
## 1.3.1
26

37
* [Full Changelog](https://github.com/PostHog/posthog-go/compare/v1.3.0...v1.3.1)

featureflags.go

+21-6
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ type DecideRequestData struct {
106106
type DecideResponse struct {
107107
FeatureFlags map[string]interface{} `json:"featureFlags"`
108108
FeatureFlagPayloads map[string]string `json:"featureFlagPayloads"`
109+
QuotaLimited *[]string `json:"quota_limited"`
109110
}
110111

111112
type InconclusiveMatchError struct {
@@ -175,14 +176,28 @@ func (poller *FeatureFlagsPoller) fetchNewFeatureFlags() {
175176
headers := [][2]string{{"Authorization", "Bearer " + personalApiKey + ""}}
176177
res, cancel, err := poller.localEvaluationFlags(headers)
177178
defer cancel()
178-
if err != nil || res.StatusCode != http.StatusOK {
179-
if err != nil {
180-
poller.Errorf("Unable to fetch feature flags: %s", err)
181-
} else {
182-
poller.Errorf("Unable to fetch feature flags, status: %s", res.Status)
183-
}
179+
if err != nil {
180+
poller.Errorf("Unable to fetch feature flags: %s", err)
184181
return
185182
}
183+
184+
// Handle quota limit response (HTTP 402)
185+
if res.StatusCode == http.StatusPaymentRequired {
186+
// Clear existing flags when quota limited
187+
poller.mutex.Lock()
188+
poller.featureFlags = []FeatureFlag{}
189+
poller.cohorts = map[string]PropertyGroup{}
190+
poller.groups = map[string]string{}
191+
poller.mutex.Unlock()
192+
poller.Errorf("[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts")
193+
return
194+
}
195+
196+
if res.StatusCode != http.StatusOK {
197+
poller.Errorf("Unable to fetch feature flags, status: %s", res.Status)
198+
return
199+
}
200+
186201
defer res.Body.Close()
187202
resBody, err := ioutil.ReadAll(res.Body)
188203
if err != nil {

posthog.go

+26-1
Original file line numberDiff line numberDiff line change
@@ -723,13 +723,30 @@ func (c *client) makeRemoteConfigRequest(flagKey string) (string, error) {
723723
}
724724
return responseData, nil
725725
}
726-
726+
727+
// isFeatureFlagsQuotaLimited checks if feature flags are quota limited in the decide response
728+
func (c *client) isFeatureFlagsQuotaLimited(decideResponse *DecideResponse) bool {
729+
if decideResponse.QuotaLimited != nil {
730+
for _, limitedFeature := range *decideResponse.QuotaLimited {
731+
if limitedFeature == "feature_flags" {
732+
c.Errorf("[FEATURE FLAGS] PostHog feature flags quota limited. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts")
733+
return true
734+
}
735+
}
736+
}
737+
return false
738+
}
739+
727740
func (c *client) getFeatureFlagFromDecide(key string, distinctId string, groups Groups, personProperties Properties, groupProperties map[string]Properties) (interface{}, error) {
728741
decideResponse, err := c.makeDecideRequest(distinctId, groups, personProperties, groupProperties)
729742
if err != nil {
730743
return nil, err
731744
}
732745

746+
if c.isFeatureFlagsQuotaLimited(decideResponse) {
747+
return false, nil
748+
}
749+
733750
if value, ok := decideResponse.FeatureFlags[key]; ok {
734751
return value, nil
735752
}
@@ -743,6 +760,10 @@ func (c *client) getFeatureFlagPayloadFromDecide(key string, distinctId string,
743760
return "", err
744761
}
745762

763+
if c.isFeatureFlagsQuotaLimited(decideResponse) {
764+
return "", nil
765+
}
766+
746767
if value, ok := decideResponse.FeatureFlagPayloads[key]; ok {
747768
return value, nil
748769
}
@@ -756,5 +777,9 @@ func (c *client) getAllFeatureFlagsFromDecide(distinctId string, groups Groups,
756777
return nil, err
757778
}
758779

780+
if c.isFeatureFlagsQuotaLimited(decideResponse) {
781+
return map[string]interface{}{}, nil
782+
}
783+
759784
return decideResponse.FeatureFlags, nil
760785
}

posthog_test.go

+118-51
Original file line numberDiff line numberDiff line change
@@ -256,57 +256,6 @@ func TestCaptureNoProperties(t *testing.T) {
256256
})
257257
}
258258

259-
func ExampleHistoricalMigrationCapture() {
260-
body, server := mockServer()
261-
defer server.Close()
262-
263-
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
264-
Endpoint: server.URL,
265-
BatchSize: 1,
266-
now: mockTime,
267-
HistoricalMigration: true,
268-
})
269-
defer client.Close()
270-
271-
client.Enqueue(Capture{
272-
Event: "Download",
273-
DistinctId: "123456",
274-
Properties: Properties{
275-
"application": "PostHog Go",
276-
"version": "1.0.0",
277-
"platform": "macos", // :)
278-
},
279-
SendFeatureFlags: false,
280-
})
281-
282-
fmt.Printf("%s\n", <-body)
283-
// Output:
284-
// {
285-
// "api_key": "Csyjlnlun3OzyNJAafdlv",
286-
// "batch": [
287-
// {
288-
// "distinct_id": "123456",
289-
// "event": "Download",
290-
// "library": "posthog-go",
291-
// "library_version": "1.0.0",
292-
// "properties": {
293-
// "$lib": "posthog-go",
294-
// "$lib_version": "1.0.0",
295-
// "application": "PostHog Go",
296-
// "platform": "macos",
297-
// "version": "1.0.0"
298-
// },
299-
// "send_feature_flags": false,
300-
// "timestamp": "2009-11-10T23:00:00Z",
301-
// "type": "capture",
302-
// "uuid": ""
303-
// }
304-
// ],
305-
// "historical_migration": true
306-
// }
307-
308-
}
309-
310259
func TestEnqueue(t *testing.T) {
311260
tests := map[string]struct {
312261
ref string
@@ -1780,3 +1729,121 @@ func TestCaptureSendFlags(t *testing.T) {
17801729
t.Fatal(err)
17811730
}
17821731
}
1732+
1733+
func TestFeatureFlagQuotaLimits(t *testing.T) {
1734+
t.Run("decide endpoint quota limited", func(t *testing.T) {
1735+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1736+
if strings.HasPrefix(r.URL.Path, "/decide") {
1737+
w.WriteHeader(http.StatusOK)
1738+
w.Write([]byte(`{
1739+
"featureFlags": {"test-flag": true},
1740+
"featureFlagPayloads": {"test-flag": "test-payload"},
1741+
"quota_limited": ["feature_flags"]
1742+
}`))
1743+
}
1744+
}))
1745+
defer server.Close()
1746+
1747+
client, _ := NewWithConfig("test-api-key", Config{
1748+
Endpoint: server.URL,
1749+
})
1750+
defer client.Close()
1751+
1752+
// Test GetFeatureFlag
1753+
value, err := client.GetFeatureFlag(FeatureFlagPayload{
1754+
Key: "test-flag",
1755+
DistinctId: "user123",
1756+
})
1757+
if err != nil {
1758+
t.Error("Expected no error, got", err)
1759+
}
1760+
if value != false {
1761+
t.Error("Expected false when quota limited, got", value)
1762+
}
1763+
1764+
// Test GetFeatureFlagPayload
1765+
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{
1766+
Key: "test-flag",
1767+
DistinctId: "user123",
1768+
})
1769+
if err != nil {
1770+
t.Error("Expected no error, got", err)
1771+
}
1772+
if payload != "" {
1773+
t.Error("Expected empty string when quota limited, got", payload)
1774+
}
1775+
1776+
// Test GetAllFlags
1777+
flags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
1778+
DistinctId: "user123",
1779+
})
1780+
if err != nil {
1781+
t.Error("Expected no error, got", err)
1782+
}
1783+
if len(flags) != 0 {
1784+
t.Error("Expected empty map when quota limited, got", flags)
1785+
}
1786+
})
1787+
1788+
t.Run("local evaluation endpoint quota limited", func(t *testing.T) {
1789+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1790+
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
1791+
w.WriteHeader(http.StatusPaymentRequired)
1792+
w.Write([]byte(`{
1793+
"type": "quota_limited",
1794+
"detail": "You have exceeded your feature flag request quota",
1795+
"code": "payment_required"
1796+
}`))
1797+
} else if strings.HasPrefix(r.URL.Path, "/decide") {
1798+
// Mock the decide endpoint as well since it's used as fallback
1799+
w.WriteHeader(http.StatusOK)
1800+
w.Write([]byte(`{
1801+
"featureFlags": {},
1802+
"featureFlagPayloads": {}
1803+
}`))
1804+
}
1805+
}))
1806+
defer server.Close()
1807+
1808+
client, _ := NewWithConfig("test-api-key", Config{
1809+
PersonalApiKey: "test-personal-key",
1810+
Endpoint: server.URL,
1811+
})
1812+
defer client.Close()
1813+
1814+
// Test GetFeatureFlag
1815+
value, err := client.GetFeatureFlag(FeatureFlagPayload{
1816+
Key: "test-flag",
1817+
DistinctId: "user123",
1818+
})
1819+
if err != nil {
1820+
t.Error("Expected no error, got", err)
1821+
}
1822+
if value != false {
1823+
t.Error("Expected false when quota limited, got", value)
1824+
}
1825+
1826+
// Test GetFeatureFlagPayload
1827+
payload, err := client.GetFeatureFlagPayload(FeatureFlagPayload{
1828+
Key: "test-flag",
1829+
DistinctId: "user123",
1830+
})
1831+
if err != nil {
1832+
t.Error("Expected no error, got", err)
1833+
}
1834+
if payload != "" {
1835+
t.Error("Expected empty string when quota limited, got", payload)
1836+
}
1837+
1838+
// Test GetAllFlags
1839+
flags, err := client.GetAllFlags(FeatureFlagPayloadNoKey{
1840+
DistinctId: "user123",
1841+
})
1842+
if err != nil {
1843+
t.Error("Expected no error, got", err)
1844+
}
1845+
if len(flags) != 0 {
1846+
t.Error("Expected empty map when quota limited, got", flags)
1847+
}
1848+
})
1849+
}

version.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package posthog
33
import "flag"
44

55
// Version of the client.
6-
const Version = "1.3.1"
6+
const Version = "1.3.2"
77

88
// make tests easier by using a constant version
99
func getVersion() string {

0 commit comments

Comments
 (0)