Go SDK Reference
TL;DR: Run
go get github.com/canopy-labs/featureflip-go/v2, callclient.BoolVariation("flag-key", ctx, false), and you’re evaluating flags from any Go service.
The featureflip Go package provides a goroutine-safe client for evaluating feature flags. It requires Go 1.25+, supports real-time updates via SSE streaming, and uses the standard library’s net/http for all network communication with no external dependencies.
Requirements
Section titled “Requirements”- Go 1.25+
Installation
Section titled “Installation”go get github.com/canopy-labs/featureflip-go/v2Import in your code:
import "github.com/canopy-labs/featureflip-go/v2"All public types and functions are in the featureflip package.
Configuration
Section titled “Configuration”The client uses the functional options pattern. All options have sensible defaults.
| Option | Default | Description |
|---|---|---|
WithBaseURL(url) | "https://eval.featureflip.io" | Base URL of the Evaluation API |
WithStreaming(bool) | true | Enable SSE for real-time flag updates |
WithPollInterval(d) | 30s | Polling interval (used when streaming is disabled) |
WithFlushInterval(d) | 30s | Interval between event flush batches |
WithFlushBatchSize(n) | 100 | Maximum number of events per flush batch |
WithInitTimeout(d) | 10s | Maximum time to wait for initial flag fetch |
WithConnectTimeout(d) | 5s | Timeout for establishing HTTP connections |
WithReadTimeout(d) | 10s | Timeout for reading HTTP responses |
WithWrapper(name, version) | none | For libraries that wrap the SDK: a name and version sent after the SDK’s own User-Agent token |
WithSnapshotStore(store) | none | Start from the last saved configuration when the API is unreachable. See Starting during an outage |
WithMaxSnapshotAge(d) | 24h | Oldest snapshot the client will start from. Whole seconds, at least one second |
Example configuration
Section titled “Example configuration”client, err := featureflip.Get("sdk-dev-abc123", featureflip.WithBaseURL("https://eval.featureflip.io"), featureflip.WithStreaming(true), featureflip.WithPollInterval(60 * time.Second), featureflip.WithFlushInterval(15 * time.Second), featureflip.WithInitTimeout(30 * time.Second),)Initialization
Section titled “Initialization”Create a client by calling Get with an SDK key and optional configuration:
client, err := featureflip.Get("sdk-dev-abc123")if err != nil { log.Fatalf("Failed to initialize: %v", err)}defer client.Close()Get blocks until the initial flag configuration is fetched (up to the init timeout). It returns an error if the SDK key is empty and FEATUREFLIP_SDK_KEY is not set, or if an option value is invalid. A failed or timed-out initial fetch is not an error: Get returns a client that serves your defaults while the data source keeps retrying, and it picks up the real configuration as soon as the API is reachable.
SDK key resolution
Section titled “SDK key resolution”If the sdkKey parameter is empty, the client falls back to the FEATUREFLIP_SDK_KEY environment variable.
// SDK key read from FEATUREFLIP_SDK_KEY environment variableclient, err := featureflip.Get("")Initialized()
Section titled “Initialized()”ready := client.Initialized()Returns true for any client Get returned, and false after Close(), because a closed handle serves the caller’s default from every accessor. It does not mean flag data loaded. A failed or timed-out initial fetch still gives an initialized client that serves your defaults, so use StartupInfo() to see where the data came from.
Starting during an outage
Section titled “Starting during an outage”When the Evaluation API is unreachable at startup, a new process serves your defaults until it connects. Pass WithSnapshotStore to let it start from the last configuration it saved instead. The option is off unless you set it. For how this fits with the rest of the failure behavior, see Reliability.
client, err := featureflip.Get("sdk-dev-abc123", featureflip.WithSnapshotStore(featureflip.NewFileSnapshotStore("/var/lib/featureflip/snapshot.json")), featureflip.WithMaxSnapshotAge(6 * time.Hour),)How it behaves:
- The snapshot is read only when the initial fetch fails or times out. A successful fetch always wins, and the snapshot is never consulted afterward.
- When the fetch fails and no usable snapshot exists,
Getstill returns an initialized client that serves your defaults, with or without a store.StartupInfo()says why. Checkclient.StartupInfo().Sourceif you want to fail fast. - After a start from a snapshot the client keeps retrying the live API and replaces the snapshot’s values as soon as it connects. Listeners already registered at that moment fire for what changed between the two.
Getblocks, so a listener you add right after it returns can miss that first change. ReadStartupInfo()after registering if you need to know. - The client writes the full configuration after each full update it applies (the first fetch, a poll, a stream resync). Once it is connected live, single-flag updates write it too. Writes run on a background goroutine and never block evaluation.
- While the client stays connected and healthy, it refreshes the stored copy at most every 5 minutes even when nothing changed, so a quiet configuration does not age past the maximum age. Changes are written right away.
- The store read is bounded by the init timeout. If the fetch times out and then the store read hangs too,
Getreturns after twice the init timeout, serving defaults. - The option is read on the first
Getfor an SDK key. Later calls with the same key reuse the existing client and log a warning if they pass a different store instance or maximum age. WithMaxSnapshotAgetakes atime.Durationbut has whole-second granularity, andGetreturns an error for anything under one second.
A snapshot contains your targeting rules, including any customer attribute values used in them. On POSIX systems FileSnapshotStore creates the file readable by its owner only (mode 0600). It does not call fsync before replacing the file, so a power loss can leave an empty file. The client reads that as snapshot-invalid, logs a warning and serves defaults until the next write.
Container disks are usually ephemeral, so a file written inside the image or container layer is gone after a redeploy. Point the path at a mounted volume:
services: api: volumes: - featureflip-snapshot:/var/lib/featureflipvolumes: featureflip-snapshot:On Kubernetes, mount a PersistentVolumeClaim, or an emptyDir if you only need the file to survive container restarts within a pod, at the same path.
FileSnapshotStore won’t create the parent directory, so it must already exist. Check the owner too. Docker creates a new named volume owned by root unless the image already has that directory, so a process running as a non-root user fails every write and logs a warning each time. Create the directory in your Dockerfile and hand it to that user, for example RUN mkdir -p /var/lib/featureflip && chown app /var/lib/featureflip.
StartupInfo()
Section titled “StartupInfo()”client.StartupInfo() reports where the flag data came from. It returns a StartupInfo struct:
| Field | Values | Meaning |
|---|---|---|
Source | "live", "snapshot", "none" | live: the initial fetch succeeded, or the client has since connected after starting from snapshot or none. snapshot: the client started from the store. none: no data yet, so evaluations return your defaults |
Reason | "snapshot", "snapshot-expired", "snapshot-invalid", or "" | Set when a snapshot was considered, and cleared once the client is live. snapshot: it was used. snapshot-expired: it was older than the maximum age. snapshot-invalid: the stored text could not be used. That covers a garbage, empty or cut-off file, a snapshot written for a different SDK key or base URL, and a timestamp more than 60 seconds in the future. An empty store sets no reason |
AgeSeconds | int64 | Age in whole seconds of the snapshot that was used or rejected for age. Read it only when Reason is snapshot or snapshot-expired |
Source is a StartupSource and Reason is a SnapshotReason. Both are string types with exported constants: StartupLive, StartupSnapshot, StartupNone, SnapshotReasonSnapshot, SnapshotReasonExpired and SnapshotReasonInvalid.
info := client.StartupInfo()if info.Source != featureflip.StartupLive { log.Printf("flags did not load from the API: source=%s reason=%q age=%ds", info.Source, info.Reason, info.AgeSeconds)}Custom stores
Section titled “Custom stores”To share one snapshot across processes, implement SnapshotStore. It stores text: Get() returns the string last stored and whether there was one, and Put(text) replaces it. The client calls both from its own goroutines, never from the goroutine that evaluates flags. This sketch uses Redis:
type redisSnapshotStore struct { rdb *redis.Client key string}
func (s *redisSnapshotStore) Get() (string, bool, error) { text, err := s.rdb.Get(context.Background(), s.key).Result() if errors.Is(err, redis.Nil) { return "", false, nil } return text, err == nil, err}
func (s *redisSnapshotStore) Put(text string) error { return s.rdb.Set(context.Background(), s.key, text, 0).Err()}
client, err := featureflip.Get("sdk-dev-abc123", featureflip.WithSnapshotStore(&redisSnapshotStore{rdb: rdb, key: "featureflip:snapshot:production"}),)The store never needs to parse the text. The SDK wraps the configuration in its own envelope, with a timestamp and a hash of the SDK key and base URL, and checks all of it on read. Text written for a different environment is rejected as snapshot-invalid and never served, which is why one store instance can safely be shared by clients for several SDK keys. Still give each environment its own key or path. Two environments sharing one slot overwrite each other’s snapshot.
A store that returns an error or panics is treated as having no snapshot, or as a failed write, and the client logs a warning.
Evaluating Flags
Section titled “Evaluating Flags”All evaluation methods accept a flag key, an evaluation context, and a default value. If the flag is not found or an error occurs, the default value is returned.
BoolVariation()
Section titled “BoolVariation()”ctx := featureflip.EvaluationContext{UserID: "u-123"}enabled := client.BoolVariation("dark-mode", ctx, false)Signature:
func (c *Client) BoolVariation(key string, ctx EvaluationContext, defaultValue bool) boolStringVariation()
Section titled “StringVariation()”plan := client.StringVariation("pricing-tier", ctx, "free")Signature:
func (c *Client) StringVariation(key string, ctx EvaluationContext, defaultValue string) stringFloat64Variation()
Section titled “Float64Variation()”limit := client.Float64Variation("rate-limit", ctx, 100.0)Signature:
func (c *Client) Float64Variation(key string, ctx EvaluationContext, defaultValue float64) float64Note: JSON numbers are always deserialized as float64 in Go. Use Float64Variation for all numeric flags.
JSONVariation()
Section titled “JSONVariation()”Returns the raw deserialized value for JSON flags:
config := client.JSONVariation("banner-config", ctx, map[string]any{ "text": "Welcome", "color": "#000",})Signature:
func (c *Client) JSONVariation(key string, ctx EvaluationContext, defaultValue any) anyThe returned value is the Go representation of the JSON value (maps, slices, strings, float64, bool, nil).
Evaluation Details
Section titled “Evaluation Details”VariationDetail()
Section titled “VariationDetail()”Returns an EvaluationDetail struct with the evaluated value and metadata about the decision.
detail := client.VariationDetail("dark-mode", ctx, false)
fmt.Println(detail.Value) // truefmt.Println(detail.Reason) // "RuleMatch"fmt.Println(detail.Variation) // "true-variation"fmt.Println(detail.RuleID) // "rule-abc"Signature:
func (c *Client) VariationDetail(key string, ctx EvaluationContext, defaultValue any) EvaluationDetailEvaluationDetail
Section titled “EvaluationDetail”type EvaluationDetail struct { Value any Variation string Reason EvaluationReason RuleID string}| Field | Type | Description |
|---|---|---|
Value | any | The evaluated flag value |
Variation | string | The key of the matched variation |
Reason | EvaluationReason | Why this value was returned |
RuleID | string | The ID of the matched targeting rule (empty if not applicable) |
EvaluationReason
Section titled “EvaluationReason”The EvaluationReason type is a string with the following constants:
| Constant | Value | Description |
|---|---|---|
ReasonRuleMatch | "RuleMatch" | A targeting rule matched the context |
ReasonFallthrough | "Fallthrough" | No rules matched; the fallthrough variation was served |
ReasonFlagDisabled | "FlagDisabled" | The flag is disabled; the off variation was served |
ReasonFlagNotFound | "FlagNotFound" | The flag key does not exist |
ReasonError | "Error" | An error occurred during evaluation |
Evaluation Context
Section titled “Evaluation Context”The EvaluationContext struct provides user attributes for flag evaluation. Custom attributes feed into targeting rules, and UserID anchors percentage rollouts:
ctx := featureflip.EvaluationContext{ UserID: "u-123", Attributes: map[string]any{ "email": "user@example.com", "country": "US", "plan": "pro", },}Fields
Section titled “Fields”| Field | Type | Description |
|---|---|---|
UserID | string | Unique user identifier, used for percentage rollouts |
Attributes | map[string]any | Custom attributes for targeting rules |
Event Tracking
Section titled “Event Tracking”Track()
Section titled “Track()”Records a custom analytics event.
metadata := map[string]any{ "plan": "pro", "amount": 29.99,}client.Track("purchase-completed", ctx, metadata)Signature:
func (c *Client) Track(eventKey string, ctx EvaluationContext, metadata map[string]any)Events are batched and flushed automatically based on WithFlushInterval and WithFlushBatchSize.
Identify()
Section titled “Identify()”Records an identify event for user association and analytics.
client.Identify(featureflip.EvaluationContext{ UserID: "u-123",})Signature:
func (c *Client) Identify(ctx EvaluationContext)Flush()
Section titled “Flush()”Forces all buffered events to be sent to the server immediately.
client.Flush()Flag Change Notifications
Section titled “Flag Change Notifications”OnUpdate()
Section titled “OnUpdate()”Subscribes to flag-configuration updates. The listener gets the keys of the flags each change touched, batched into one call however many flags that was. Needs SDK 2.8.0 or newer.
unsubscribe := client.OnUpdate(func(keys []string) { log.Printf("flags changed: %v", keys) cache.Invalidate(keys)})defer unsubscribe()A flag is reported when it is added, removed or modified. Two more cases count as well, because each one moves a flag’s evaluated value without touching its own configuration: an edit to a segment that the flag’s targeting rules reference, and a change to a flag it depends on through a prerequisite.
The initial flag load doesn’t fire the listener, and neither does a poll or reconnect that changed nothing. The listener is never called with an empty slice, and each listener receives its own copy of it.
Listeners run on the SDK’s streaming or polling goroutine, so keep them short. A slow listener holds up flag delivery, and on the streaming path it can stall the connection. Do the minimum here and hand anything heavier to a channel or a worker goroutine.
A panic in a listener is recovered and logged. It doesn’t stop flag delivery or the other listeners.
The returned func unsubscribes, and calling it twice is harmless. Closing the client handle that registered a listener drops it too, so a handle that goes away can’t leave a listener firing on the shared client. A nil listener, or one registered on a closed handle, gets back a func that does nothing.
Signature:
type FlagUpdateListener func(flagKeys []string)
func (c *Client) OnUpdate(listener FlagUpdateListener) func()Cleanup
Section titled “Cleanup”Close()
Section titled “Close()”Shuts down the client, stops background goroutines (streaming or polling), and flushes remaining events. Returns nil (the error return is reserved for future use). Safe to call multiple times.
err := client.Close()Signature:
func (c *Client) Close() errorAlways defer Close() after creating a client:
client, err := featureflip.Get("sdk-key")if err != nil { log.Fatal(err)}defer client.Close()Testing
Section titled “Testing”ForTesting()
Section titled “ForTesting()”Creates a client pre-populated with flag overrides. No network calls or background goroutines are started.
client := featureflip.ForTesting(map[string]any{ "dark-mode": true, "pricing-tier": "enterprise", "rate-limit": 500.0,})defer client.Close()
ctx := featureflip.EvaluationContext{UserID: "test-user"}client.BoolVariation("dark-mode", ctx, false) // trueclient.StringVariation("pricing-tier", ctx, "free") // "enterprise"Signature:
func ForTesting(overrides map[string]any) *ClientSupported value types: bool, string, float64/int, and any JSON-serializable type. The client is fully initialized immediately and returns override values regardless of the evaluation context.
Types Summary
Section titled “Types Summary”| Type | Description |
|---|---|
Client | Main client for flag evaluation and event tracking |
Option | Functional option for configuring the client (func(*config)) |
EvaluationContext | User attributes for flag evaluation |
EvaluationDetail | Detailed result of a flag evaluation |
EvaluationReason | String type describing why a value was returned |
See also
Section titled “See also”- Go Quickstart — get started in under 5 minutes
- Feature flags in Go walks through the SDK inside a Go HTTP service
- Targeting & Segments — control which users see which variation
- Rollout Strategies — gradual percentage-based rollouts
- Evaluation API — REST API reference
- SDK Overview — compare server-side and client-side SDKs