Skip to content
NanoFlags
Start free

SDKs

Go SDK

The Go SDK evaluates flags locally, without I/O, against an in-memory configuration that it keeps current in the background over the live connection. Evaluations never fail and never block, and the client is safe for concurrent use.

Install

sh
go get github.com/motazalmaroai/nanoflags-sdk/go@v0.1.0
  • Module github.com/motazalmaroai/nanoflags-sdk/go v0.1.0. Go 1.22 or newer.
  • Dependencies: github.com/coder/websocket (live updates) and golang.org/x/text (Unicode normalisation for rollouts). Everything else, Ed25519 included, is the standard library.
  • Package engine is a port of the NanoFlags engine. It is tested against the shared fixtures generated from the reference engine (21,998 evaluation cases) plus extra Go cases, and gives the same results as the JavaScript and Python SDKs.
  • Apache-2.0. Source in the nanoflags-sdk repository.

Quickstart

Copy a server key (nf_srv_…) from Settings → Keys in the dashboard and keep it in an environment variable.

main.go
package main

import (
	"context"
	"log"
	"os"
	"time"

	nanoflags "github.com/motazalmaroai/nanoflags-sdk/go"
)

func main() {
	client, err := nanoflags.New(os.Getenv("NANOFLAGS_SDK_KEY"), nanoflags.WithService("checkout-api"))
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := client.WaitForReady(ctx); err != nil {
		log.Printf("flags not loaded yet (%v): evaluations return their fallbacks", err)
	}

	user := nanoflags.Context{"key": "user-42", "plan": "pro", "country": "SA"}
	if client.BoolVariation("new-checkout", user, false) {
		// ...
	}
}

Create one client per SDK key at start-up and share it between goroutines. The base URL defaults to the hosted edge, https://edge.nanoflags.io. Runnable examples are in go/examples.

Contexts

nanoflags.Context is a map[string]any. Values may be strings, booleans, numbers, nil, slices and maps. "key" is the stable identity used for percentage rollouts, usually a user or account id. Every other attribute is matched by your targeting rules.

go
ctx := nanoflags.Context{"key": account.ID, "plan": account.Plan, "beta": true, "seats": 12}
ranking := client.StringVariation("search-ranking", ctx, "relevance-v2")
  • The context never leaves the process to make a decision. See telemetry for what diagnostics send.
  • Pass the same key for the same user everywhere: the same key lands in the same rollout bucket in Go, Python, JavaScript and the dashboard.

Typed variations

Note the argument order: flag key, context, then the fallback.

go
enabled := client.BoolVariation("new-checkout", user, false)
accent  := client.StringVariation("accent", user, "blue")
limit   := client.Float64Variation("rate-limit", user, 100)
layout  := client.JSONVariation("layout", user, map[string]any{"cols": 3})

all := client.AllFlags(user) // map[string]any, records no telemetry
MethodReturns
BoolVariation(key, ctx, fallback bool) boolthe flag's boolean value
StringVariation(key, ctx, fallback string) stringthe flag's string value
Float64Variation(key, ctx, fallback float64) float64the flag's number value
JSONVariation(key, ctx, fallback any) anyplain Go data: nil, bool, float64, string, []any, map[string]any
…VariationDetail(key, ctx, fallback)the value plus a Detail, see Details and reasons
Evaluate(key, ctx, fallback any) Detailthe engine result without a type check
AllFlags(ctx) · AllFlagsDetail(ctx)every flag of the configuration

You get your fallback when no configuration has loaded yet, for an unknown flag (or, with a browser key, one that is not client-exposed), or when the flag serves another type than requested. A disabled flag is not a fallback case: it returns the flag's configured off value.

Details and reasons

go
on, detail := client.BoolVariationDetail("new-checkout", user, false)
log.Printf("%v reason=%s rule=%s revision=%d", on, detail.Reason, detail.RuleID, detail.Revision)

for _, step := range detail.Trace { // the same steps the dashboard shows
	log.Printf("%-10s %s — %s", step.Kind, step.Title, step.Detail)
}
trace
config     Configuration loaded — r63
rule       Rule 01 matched — plan = pro
allocation Allocation evaluated — 25% rollout · key inside allocation (bucket 12.34)
result     Returned true — Inside the 25% allocation.
FieldMeaning
ValueThe value served.
Reasonrule_match, default (no rule matched), off (flag disabled), flag_not_found, error. Constants: nanoflags.ReasonRuleMatch and so on.
RuleIndex · RuleIDThe matching rule (zero-based index, -1 for none).
InRollout · BucketPointers, set for percentage rollouts (bucket 0 to 99.99).
RevisionThe configuration revision the result came from.
Trace[]TraceStep with Kind, Title, Detail.
ErrorWhy Reason is error (evaluation error or type mismatch).

Live updates and OnChange

The client opens one WebSocket to GET /v1/live. The edge pushes each published revision, already signed; the client verifies it and swaps it in atomically. A pushed revision is never replaced by an older HTTP answer.

go
stop := client.OnChange(func(e nanoflags.ChangeEvent) {
	if e.HasLatency {
		log.Printf("now on r%d via %s, %v after commit", e.Revision, e.Transport, e.Latency)
	}
})
defer stop()

client.OnError(func(err error) {
	var nf *nanoflags.Error
	if errors.As(err, &nf) {
		log.Printf("nanoflags %s: %v", nf.Code, err)
	}
})
FieldMeaning
Revision · PreviousRevisionThe new revision and the one it replaced (-1 for the first).
ChecksumChecksum of the new configuration.
Sourcenetwork, store or bootstrap
Transportlive (pushed), stream (fetched after a server-sent-events notice) or poll
Latency · HasLatencyDatabase commit to this event, for pushed revisions that carry their commit time. It compares the database's clock with this machine's.
  • Callbacks run in order on one goroutine of the client: keep them short. OnChange, OnError and OnStatus each return an unsubscribe function.
  • Fallbacks: while no socket can be opened, server-sent events (GET /v1/stream), and polling of GET /v1/bundle with ETag every 30 s (every 5 minutes as a safety net while the socket is healthy). Reconnects back off from 1 s up to 5 minutes, with jitter. See Transports for the protocol.

Readiness and status

go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := client.WaitForReady(ctx); errors.Is(err, nanoflags.ErrNotReady) {
	// no configuration yet: evaluations return their fallbacks
}

client.Status()    // nanoflags.StatusNotReady | StatusReady | StatusStale
client.Revision()  // revision in use
client.Transport() // "live" | "stream" | "poll"
<-client.Ready()   // closed once the first load has finished
  • WaitForReady(ctx) returns once the first load has finished: nil when a configuration is in use, otherwise why not (a *nanoflags.Error, ErrNotReady, or the context's error).
  • StatusReady: the configuration is known to be current. StatusStale: a configuration is in use but is not known to be current (edge unreachable, restored from the store, environment deleted). StatusNotReady: nothing is loaded.
  • Don't block requests on readiness: wait once at start-up with a short timeout, and let your fallbacks cover the rest.

Last good config and bootstrap

Failed requests keep the current configuration (StatusStale). A bundle that fails verification, or that needs a newer engine, never replaces the one in use.

go
client, err := nanoflags.New(sdkKey,
	nanoflags.WithStore(nanoflags.FileStore("/var/cache/app/flags.json")), // survives restarts
)

// Or start from a configuration you ship:
data, _ := os.ReadFile("flags-bootstrap.json")
client, err = nanoflags.New(sdkKey, nanoflags.WithBootstrapJSON(data))
  • FileStore writes atomically with mode 0600, so a restart can evaluate before it reaches the edge. The persisted copy is verified again on load.
  • WithStore takes any Store (Load(ctx) / Save(ctx, data)). WithBootstrap(*engine.Bundle) takes a parsed bundle.

Signature verification

With the default base URL, every bundle's Ed25519 signature is verified against the production keys embedded in the module (OfficialSigningKeys) before it is used: over the live socket, over polling and from the store. Unsigned bundles are refused. The signed bytes are hashed exactly as the edge served them.

go
require := true
client, err := nanoflags.New(sdkKey,
	nanoflags.WithBaseURL("https://flags.internal.example"),
	nanoflags.WithVerification(nanoflags.VerifyOptions{
		PublicKeys: []nanoflags.JWK{{Kty: "OKP", Crv: "Ed25519", X: "…", Kid: "k1"}},
		Require:    &require,
	}),
)
  • A bundle signed with an unknown key id (a key rotation) makes the client load GET /v1/keys once, at most once a minute.
  • Bundles are pinned to the first environment they were signed for.
  • On another edge, verification is off unless you configure it. FetchKeys: true loads keys from that edge; pinned keys are stronger. WithoutVerification() turns it off.
  • The canonical string and headers are described in Signatures.

OpenFeature provider

sh
go get github.com/motazalmaroai/nanoflags-sdk/go/ofprovider@v0.1.0
go
import (
	"github.com/open-feature/go-sdk/openfeature"
	nanoflags "github.com/motazalmaroai/nanoflags-sdk/go"
	"github.com/motazalmaroai/nanoflags-sdk/go/ofprovider"
)

client, err := nanoflags.New(os.Getenv("NANOFLAGS_SDK_KEY"))
if err != nil {
	log.Fatal(err)
}
_ = openfeature.SetProviderAndWait(ofprovider.New(client, ofprovider.WithCloseClient()))

of := openfeature.NewDefaultClient()
on, _ := of.BooleanValue(ctx, "new-checkout", false,
	openfeature.NewEvaluationContext("user-42", map[string]any{"plan": "pro"}))
  • A separate module (github.com/motazalmaroai/nanoflags-sdk/go/ofprovider v0.1.0, Go 1.24, OpenFeature Go SDK v1.17), so the SDK itself does not depend on OpenFeature.
  • Evaluation stays local in the wrapped client. The targeting key becomes key.
  • Reasons: DISABLED, DEFAULT, TARGETING_MATCH, SPLIT (rollouts), ERROR with FLAG_NOT_FOUND, PROVIDER_NOT_READY, TYPE_MISMATCH or GENERAL. The variant is the rule id; flag metadata carries revision.
  • Events: PROVIDER_READY, PROVIDER_STALE, PROVIDER_ERROR, and PROVIDER_CONFIGURATION_CHANGED with the keys of the flags that changed.
  • Options: WithCloseClient() closes the NanoFlags client on shutdown; WithInitTimeout(d) bounds initialisation.

Options

OptionDefault
WithBaseURL(url)https://edge.nanoflags.io
WithService(name)"unknown", the name this process reports under
WithHTTPClient(*http.Client)default transport (honours HTTPS_PROXY)
WithPollInterval(d) · WithLivePollInterval(d)30 s / 5 min (0 disables polling)
WithLive(bool) · WithStreaming(bool)true / true
WithVerification(VerifyOptions) · WithoutVerification()hosted edge: embedded keys, signatures required; other edges: off
WithStore · WithBootstrap · WithBootstrapJSONnone
WithEvents(EventsOptions)10 % of evaluations, flushed every 10 s; service report every 60 s. Disabled: true turns it off.
WithLogger(*slog.Logger)discard

Error codes

Background failures reach OnError as *nanoflags.Error with a Code: network, unauthorized, http, invalid_bundle, store, config, signature, engine, environment_deleted. A failed update never changes the configuration in use. The meanings match the JavaScript SDK.

Telemetry

A sampled event carries the names of the attributes the flag's rules use, mapped to their type ({"plan": "string"}), never their values by default. EventsOptions.Redact replaces this.

Closing and shutdown

go
client, err := nanoflags.New(sdkKey)
if err != nil {
	log.Fatal(err)
}
defer client.Close() // idempotent; waits for every goroutine

// Before exiting a short job, send queued telemetry:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = client.Flush(ctx)
  • Close() stops the live connection, polling and telemetry, and waits for every goroutine. It is safe to call more than once.
  • After Close(), evaluations keep working on the last configuration.
  • Refresh(ctx) revalidates now; Flush(ctx) sends queued telemetry now.

Troubleshooting

  1. Every evaluation returns the fallback. Check the error from WaitForReady and register OnError. Code unauthorized means the key was rejected; with a browser key (nf_pub_…) only client-exposed flags are delivered.
  2. Wrong argument order. Go variations take (key, ctx, fallback). The Python and JavaScript SDKs take the fallback before the context.
  3. Behind a proxy. The default transport honours HTTPS_PROXY; pass WithHTTPClient for custom TLS or proxies. If upgrades are blocked, the client uses server-sent events and polling, and Transport() shows stream or poll.
  4. 403 "error code: 1010" from your own HTTP calls. The edge refuses some default library User-Agents. The SDK sends nanoflags-go/<version>, so this only affects code that calls the edge directly (for example OFREP requests or scripts): set a descriptive User-Agent header there.
  5. Code signature. A bundle did not verify and was not used. With a custom WithBaseURL, pin that edge's keys.
  6. Code engine. The configuration needs a newer evaluation engine than SupportedEngineVersion: upgrade the module.
  7. Code environment_deleted. The edge closed the socket with 4004. The client stops reconnecting, keeps its last configuration as StatusStale; replace the key.
Other languages
Python has a native SDK too: see Python SDK. Java, .NET, PHP, Ruby, Swift, Kotlin and other languages can read NanoFlags flags through an OpenFeature OFREP provider.