greenflags

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 19, 2026 License: MIT Imports: 10 Imported by: 0

README

greenflags-go

Official Go SDK for consuming GreenFlags feature flags from any Go service: HTTP servers, gRPC services, CLIs, workers.

Zero dependencies — standard library only. Built to minimize billable requests: one network call fetches the whole environment; every read after that is served from memory. Safe for concurrent use across goroutines.

Status: v0.1.0. Full changelog in CHANGELOG.md.

go get github.com/greenflags-dev/greenflags-go

Table of Contents


Why it exists

GreenFlags exposes a read endpoint (GET /v1/flags) where every 2xx response counts as a billable read. A naive integration that fetches a flag on every request handler can generate thousands of unnecessary requests and burn through your quota for no reason.

greenflags-go solves this with a snapshot + cache model:

  1. One call (Refresh) fetches every flag in the project + environment tied to your API token.
  2. That response is stored in memory.
  3. Every read after that (IsEnabled, BoolFlag, StringFlag, …) is local — zero additional requests.

There is intentionally no method to fetch a single flag over the network — that would break the billing model.

Features

  • ✅ Zero dependencies — net/http and the standard library, nothing else.
  • ✅ Snapshot + in-memory cache — billing-safe by design.
  • ✅ Concurrency-safe — share one *Client across goroutines (sync.RWMutex inside).
  • context.Context on every network call — timeouts and cancellation the Go way.
  • ✅ Opt-in polling on a background goroutine — you decide if and how often it refreshes.
  • ✅ Fail-open — a failed refresh keeps the last good snapshot; typed accessors return your defaults.
  • ✅ Client-side geofence evaluation — the end-user's location never leaves your process.
  • ✅ Injectable *http.Client — custom timeouts, proxies, or httptest mocking.

Requirements

  • Go 1.22+.
  • A GreenFlags API token, generated from the dashboard for a specific project + environment. The token determines which flags the SDK sees. See the API docs for the full contract.

Quick Start

package main

import (
	"context"

	greenflags "github.com/greenflags-dev/greenflags-go"
)

func main() {
	flags := greenflags.NewClient(greenflags.Options{
		URL:      "https://app.greenflags.dev",
		APIToken: "gf_your_token_here",
	})

	if err := flags.Refresh(context.Background()); err != nil {
		// fail-open: keep going with defaults
	}

	if flags.IsEnabled("new-checkout") {
		// ship it
	}
}

Usage Guide

flags := greenflags.NewClient(greenflags.Options{URL: url, APIToken: token})

// 1. Fetch the initial snapshot (required before reading real flag values)
if err := flags.Refresh(ctx); err != nil { /* log it */ }

// 2. Read flags — always from memory, never hits the network
enabled := flags.IsEnabled("my-feature")             // bool sugar
theme   := flags.StringFlag("theme", "light")        // string with default
limit   := flags.NumberFlag("rate-limit", 100)       // float64 with default
conf    := flags.JSONFlag("config", nil)             // map[string]any with default
value, ok := flags.GetFlag("anything")               // raw value + existence

// 3. List everything available
all      := flags.AllFlags()   // []Flag
snapshot := flags.Snapshot()   // map[string]Flag

// 4. Subscribe to updates (fires on every successful Refresh)
unsubscribe := flags.Subscribe(func(snapshot map[string]greenflags.Flag) {
	// react to changes
})
defer unsubscribe()

// 5. Opt-in polling — without this, the SDK NEVER fetches data on its own
flags.StartPolling(ctx, 60*time.Second) // every tick = 1 billable request
defer flags.StopPolling()
Ground rules
  • Typed accessors (BoolFlag, StringFlag, NumberFlag, JSONFlag) never panic or error — they return your default when the flag is missing or holds a different type.
  • Refresh can fail (*greenflags.Error: network error, invalid token, quota exceeded) — the previous snapshot is kept either way.
  • Create one client per process and share it — don't build a client (or call Refresh) per request. Refresh once at startup and use StartPolling only if you need near-live data.
  • JSON numbers arrive as float64 (standard encoding/json behavior) — that's what NumberFlag returns.

API Reference

NewClient(Options) *Client
Option Type Description
URL string API base URL, trailing slash optional.
APIToken string Environment-scoped token (gf_...).
Coordinates *Coordinates Optional end-user location for geofence evaluation.
HTTPClient *http.Client Optional override; defaults to a 10s-timeout client.
Methods
Method Signature Description
Refresh (ctx) error 1 request to GET /v1/flags. Replaces the snapshot and notifies subscribers.
Snapshot () map[string]Flag Copy of the evaluated snapshot. Local read.
AllFlags () []Flag Every evaluated flag. Local read.
GetFlag (key) (any, bool) Evaluated value + whether the flag exists.
IsEnabled (key) bool true only when the flag exists and evaluates to true.
BoolFlag / StringFlag / NumberFlag / JSONFlag (key, def) T Typed accessors with defaults. Never error.
Subscribe (fn) (unsubscribe func()) Listener on every successful Refresh.
StartPolling (ctx, interval) Background Refresh loop. Opt-in. Fail-open.
StopPolling () Stops the loop; snapshot preserved.
SetCoordinates (*Coordinates) Sets/clears the location for geofence evaluation. No network.

Geofence

Some flags carry an optional geofence (latitude/longitude/radius, configured in the dashboard). With coordinates set, each geofenced flag is evaluated locally — coordinates never leave the process:

  • Inside the radius (on-edge counts as inside): the flag's normal value.
  • Outside: the off value — false for boolean flags, nil for the rest.
  • No coordinates, or no geofence: the normal value, unaffected.

Fail-open, by design: a geofence is not a security boundary — any caller that omits coordinates sees the flag's normal value regardless of location.

flags.SetCoordinates(&greenflags.Coordinates{Latitude: 19.4326, Longitude: -99.1332})
flags.IsEnabled("store-promo") // evaluated against the geofence, if any
flags.SetCoordinates(nil)      // back to "ignore geofence"

Error Handling

Refresh returns a *greenflags.Error with Code, Message and HTTP Status:

var gfErr *greenflags.Error
if err := flags.Refresh(ctx); errors.As(err, &gfErr) {
	log.Printf("flags refresh failed: %s (%d)", gfErr.Code, gfErr.Status)
}

Codes that GET /v1/flags can actually return:

Code Status Cause
INVALID_TOKEN 401 Token missing, invalid, or revoked
QUOTA_EXCEEDED 429 Monthly read quota exhausted
BILLING_NO_SUBSCRIPTION 429 The workspace has no active subscription
BILLING_CANCELED 429 Subscription canceled
BILLING_PAST_DUE 429 Payment past due
BILLING_TRIAL_EXPIRED 429 Trial expired
BILLING_LIMIT_REACHED 429 Billing limit reached
NETWORK_ERROR 0 The request failed before a response was received
PARSE_ERROR response status Body wasn't valid JSON, or was missing data.flags
REQUEST_ERROR response status Non-2xx response with no parseable error code

Read methods never return any of these — they're always local.

Billing Model

Every Refresh (manual or polling tick) is exactly one HTTP request, and every 2xx response counts as one billable read. All flag reads are 100% in memory. With N instances each polling, you pay N reads per tick — size your interval and instance count accordingly.

Handling the API token

Server-side Go: treat the token like a database password.

  • Read it from the environment (os.Getenv("GREENFLAGS_API_TOKEN")), never hardcode it.
  • Per-token monthly quotas (dashboard → API Tokens) cap the blast radius of a leaked CI log or misconfigured staging.

Development

cd sdks/go
go vet ./...
go test ./...   # 10 tests against httptest servers — no real network

Versioning

Semver via git tags (v0.1.0, …). While in v0.x, MINOR can include API changes; PATCH are fixes. Version-by-version detail in CHANGELOG.md.

Percentage rollout & variants

Server processes serve many users, so identity is per call — resolve rollout/variants for a specific end user with:

value, ok := client.GetFlagForUser("new-checkout", userID)
enabled := client.IsEnabledForUser("new-checkout", userID)

GetFlag (no user) keeps returning the raw value with the rollout/variant config attached (fail-open). Bucketing is deterministic and identical across every GreenFlags SDK and the server (docs/rollout-hash-spec.md).

Documentation

Overview

Package greenflags is the official Go SDK for GreenFlags feature flags.

Snapshot + cache model: one network call (Refresh) fetches every flag of the token's environment; every read after that is served from memory. See https://greenflags.dev/docs/ for the API reference.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssignVariant added in v0.3.0

func AssignVariant(flagKey, userKey string, variants []WeightedVariant) (string, bool)

AssignVariant assigns a user to a weighted variant: cumulative ranges over the 0-99 bucket, variants sorted by name (UTF-8 byte order — Go's native string comparison). It returns the variant name and true, or "" and false when the bucket falls beyond the total weight (base value applies).

func GeoDistanceMeters added in v0.3.0

func GeoDistanceMeters(a, b Coordinates) float64

GeoDistanceMeters returns the great-circle distance between a and b in meters (haversine formula). This is the exact calculation the SDK runs internally to evaluate geofenced flags, exposed so callers can show the live distance to a geofence without reimplementing it.

func IsIncludedInRollout added in v0.3.0

func IsIncludedInRollout(flagKey, userKey string, percentage int) bool

IsIncludedInRollout reports whether the user is inside the rollout percentage for the flag.

func RolloutBucket added in v0.3.0

func RolloutBucket(flagKey, userKey string) int

RolloutBucket returns the 0-99 bucket a user falls into for a given flag. Stable for the same flagKey + userKey pair across every GreenFlags SDK and the server.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client reads GreenFlags feature flags. It is safe for concurrent use: one Client can be shared across goroutines. Every read path goes through geofence evaluation — the raw cached snapshot is never exposed.

func NewClient

func NewClient(opts Options) *Client

NewClient builds a Client. It performs no network request — call Refresh to load the first snapshot.

func (*Client) AllFlags

func (c *Client) AllFlags() []Flag

AllFlags returns every evaluated flag. Local read.

func (*Client) BoolFlag

func (c *Client) BoolFlag(key string, def bool) bool

BoolFlag returns the flag's value as a bool, or def when the flag is missing or not a bool.

func (*Client) GetFlag

func (c *Client) GetFlag(key string) (any, bool)

GetFlag returns the evaluated value of key and whether the flag exists. Local read; never errors for missing flags.

func (*Client) GetFlagForUser added in v0.3.0

func (c *Client) GetFlagForUser(key, user string) (any, bool)

GetFlagForUser returns the evaluated value of key for a specific end user, resolving percentage rollout and variants with the given user key (see docs/rollout-hash-spec.md). Server processes serve many users, so identity is per call — there is no SetUser. Chain: geofence (client coordinates) → variants → rollout. Local read; never errors for missing flags.

func (*Client) IsEnabled

func (c *Client) IsEnabled(key string) bool

IsEnabled returns true only when key exists and currently evaluates to true.

func (*Client) IsEnabledForUser added in v0.3.0

func (c *Client) IsEnabledForUser(key, user string) bool

IsEnabledForUser returns true only when key exists and evaluates to true for the given user.

func (*Client) JSONFlag

func (c *Client) JSONFlag(key string, def map[string]any) map[string]any

JSONFlag returns the flag's value as a map, or def when the flag is missing or not a JSON object.

func (*Client) NumberFlag

func (c *Client) NumberFlag(key string, def float64) float64

NumberFlag returns the flag's value as a float64 (JSON numbers), or def when the flag is missing or not a number.

func (*Client) Refresh

func (c *Client) Refresh(ctx context.Context) error

Refresh performs one billable request (GET /v1/flags), replaces the local snapshot and notifies subscribers. On error the previous snapshot is kept.

func (*Client) SetCoordinates

func (c *Client) SetCoordinates(coords *Coordinates)

SetCoordinates sets (or clears, with nil) the end-user location used for geofence evaluation. No network request is made.

func (*Client) Snapshot

func (c *Client) Snapshot() map[string]Flag

Snapshot returns a copy of the evaluated snapshot, keyed by flag key. Local read — never hits the network.

func (*Client) StartPolling

func (c *Client) StartPolling(ctx context.Context, interval time.Duration)

StartPolling refreshes every interval on a background goroutine until StopPolling is called or ctx is canceled. Errors are swallowed: the previous snapshot stays available. Every tick is one billable request.

func (*Client) StopPolling

func (c *Client) StopPolling()

StopPolling stops the polling goroutine. The in-memory snapshot is kept.

func (*Client) StringFlag

func (c *Client) StringFlag(key string, def string) string

StringFlag returns the flag's value as a string, or def when the flag is missing or not a string.

func (*Client) Subscribe

func (c *Client) Subscribe(fn func(map[string]Flag)) (unsubscribe func())

Subscribe registers fn, called with the evaluated snapshot after every successful Refresh. It returns an unsubscribe function.

type Coordinates

type Coordinates struct {
	Latitude  float64
	Longitude float64
}

Coordinates is a geographic point in decimal degrees.

type Error

type Error struct {
	Code    string
	Message string
	Status  int
}

Error is returned on network or API failures. Code mirrors the API error codes (e.g. INVALID_TOKEN, QUOTA_EXCEEDED) plus the client-side NETWORK_ERROR and PARSE_ERROR. Status is the HTTP status (0 for network failures).

func (*Error) Error

func (e *Error) Error() string

type Flag

type Flag struct {
	Key      string        `json:"key"`
	Type     FlagType      `json:"type"`
	Value    any           `json:"value"`
	Geofence *Geofence     `json:"geofence,omitempty"`
	Rollout  *Rollout      `json:"rollout,omitempty"`
	Variants []FlagVariant `json:"variants,omitempty"`
}

Flag is a feature flag as served by GET /v1/flags. Value holds bool, string, float64, map[string]any or nil depending on Type (and geofence evaluation).

type FlagType

type FlagType string

FlagType is the value type of a flag: "boolean", "string", "number" or "json".

const (
	FlagTypeBoolean FlagType = "boolean"
	FlagTypeString  FlagType = "string"
	FlagTypeNumber  FlagType = "number"
	FlagTypeJSON    FlagType = "json"
)

type FlagVariant added in v0.3.0

type FlagVariant struct {
	Name   string `json:"name"`
	Weight int    `json:"weight"`
	Value  any    `json:"value"`
}

FlagVariant is a weighted variant of a multivariate flag.

type Geofence

type Geofence struct {
	Latitude     float64 `json:"latitude"`
	Longitude    float64 `json:"longitude"`
	RadiusMeters float64 `json:"radiusMeters"`
}

Geofence is the geographic scope of a flag value: inside the radius the flag keeps its value, outside it evaluates to its off value.

type Options

type Options struct {
	// URL is the API base URL (e.g. https://app.greenflags.dev), trailing
	// slash optional.
	URL string
	// APIToken is the environment-scoped token (gf_...) created in the
	// dashboard. It determines which flags the client sees.
	APIToken string
	// Coordinates optionally sets the end-user location used for geofence
	// evaluation (see SetCoordinates).
	Coordinates *Coordinates
	// HTTPClient optionally overrides the HTTP client (custom timeouts,
	// proxies, or mocking). Defaults to a client with a 10s timeout.
	HTTPClient *http.Client
}

Options configures a Client. URL and APIToken are required.

type Rollout added in v0.3.0

type Rollout struct {
	Percentage int `json:"percentage"`
}

Rollout is a percentage rollout rule: Percentage% of users (bucketed deterministically per docs/rollout-hash-spec.md) receive the flag's value.

type WeightedVariant added in v0.3.0

type WeightedVariant struct {
	Name   string
	Weight int
}

WeightedVariant is a (name, weight) pair for variant assignment.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL