Skip to content

Go SDK Reference

TL;DR: Run go get github.com/canopy-labs/featureflip-go/v2, call client.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.

View source on GitHub

  • Go 1.25+
Terminal window
go get github.com/canopy-labs/featureflip-go/v2

Import in your code:

import "github.com/canopy-labs/featureflip-go/v2"

All public types and functions are in the featureflip package.

The client uses the functional options pattern. All options have sensible defaults.

OptionDefaultDescription
WithBaseURL(url)"https://eval.featureflip.io"Base URL of the Evaluation API
WithStreaming(bool)trueEnable SSE for real-time flag updates
WithPollInterval(d)30sPolling interval (used when streaming is disabled)
WithFlushInterval(d)30sInterval between event flush batches
WithFlushBatchSize(n)100Maximum number of events per flush batch
WithInitTimeout(d)10sMaximum time to wait for initial flag fetch
WithConnectTimeout(d)5sTimeout for establishing HTTP connections
WithReadTimeout(d)10sTimeout for reading HTTP responses
WithWrapper(name, version)noneFor libraries that wrap the SDK: a name and version sent after the SDK’s own User-Agent token
WithSnapshotStore(store)noneStart from the last saved configuration when the API is unreachable. See Starting during an outage
WithMaxSnapshotAge(d)24hOldest snapshot the client will start from. Whole seconds, at least one second
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),
)

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.

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 variable
client, err := featureflip.Get("")
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.

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, Get still returns an initialized client that serves your defaults, with or without a store. StartupInfo() says why. Check client.StartupInfo().Source if 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. Get blocks, so a listener you add right after it returns can miss that first change. Read StartupInfo() 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, Get returns after twice the init timeout, serving defaults.
  • The option is read on the first Get for 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.
  • WithMaxSnapshotAge takes a time.Duration but has whole-second granularity, and Get returns 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:

docker-compose.yml
services:
api:
volumes:
- featureflip-snapshot:/var/lib/featureflip
volumes:
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.

client.StartupInfo() reports where the flag data came from. It returns a StartupInfo struct:

FieldValuesMeaning
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
AgeSecondsint64Age 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)
}

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.

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.

ctx := featureflip.EvaluationContext{UserID: "u-123"}
enabled := client.BoolVariation("dark-mode", ctx, false)

Signature:

func (c *Client) BoolVariation(key string, ctx EvaluationContext, defaultValue bool) bool
plan := client.StringVariation("pricing-tier", ctx, "free")

Signature:

func (c *Client) StringVariation(key string, ctx EvaluationContext, defaultValue string) string
limit := client.Float64Variation("rate-limit", ctx, 100.0)

Signature:

func (c *Client) Float64Variation(key string, ctx EvaluationContext, defaultValue float64) float64

Note: JSON numbers are always deserialized as float64 in Go. Use Float64Variation for all numeric flags.

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) any

The returned value is the Go representation of the JSON value (maps, slices, strings, float64, bool, nil).

Returns an EvaluationDetail struct with the evaluated value and metadata about the decision.

detail := client.VariationDetail("dark-mode", ctx, false)
fmt.Println(detail.Value) // true
fmt.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) EvaluationDetail
type EvaluationDetail struct {
Value any
Variation string
Reason EvaluationReason
RuleID string
}
FieldTypeDescription
ValueanyThe evaluated flag value
VariationstringThe key of the matched variation
ReasonEvaluationReasonWhy this value was returned
RuleIDstringThe ID of the matched targeting rule (empty if not applicable)

The EvaluationReason type is a string with the following constants:

ConstantValueDescription
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

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",
},
}
FieldTypeDescription
UserIDstringUnique user identifier, used for percentage rollouts
Attributesmap[string]anyCustom attributes for targeting rules

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.

Records an identify event for user association and analytics.

client.Identify(featureflip.EvaluationContext{
UserID: "u-123",
})

Signature:

func (c *Client) Identify(ctx EvaluationContext)

Forces all buffered events to be sent to the server immediately.

client.Flush()

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()

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() error

Always defer Close() after creating a client:

client, err := featureflip.Get("sdk-key")
if err != nil {
log.Fatal(err)
}
defer client.Close()

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) // true
client.StringVariation("pricing-tier", ctx, "free") // "enterprise"

Signature:

func ForTesting(overrides map[string]any) *Client

Supported 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.

TypeDescription
ClientMain client for flag evaluation and event tracking
OptionFunctional option for configuring the client (func(*config))
EvaluationContextUser attributes for flag evaluation
EvaluationDetailDetailed result of a flag evaluation
EvaluationReasonString type describing why a value was returned