SDKs
Go SDK
On this page
Install
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) andgolang.org/x/text(Unicode normalisation for rollouts). Everything else, Ed25519 included, is the standard library. - Package
engineis 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.
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.
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
keyfor 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.
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| Method | Returns |
|---|---|
BoolVariation(key, ctx, fallback bool) bool | the flag's boolean value |
StringVariation(key, ctx, fallback string) string | the flag's string value |
Float64Variation(key, ctx, fallback float64) float64 | the flag's number value |
JSONVariation(key, ctx, fallback any) any | plain 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) Detail | the 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
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)
}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.| Field | Meaning |
|---|---|
Value | The value served. |
Reason | rule_match, default (no rule matched), off (flag disabled), flag_not_found, error. Constants: nanoflags.ReasonRuleMatch and so on. |
RuleIndex · RuleID | The matching rule (zero-based index, -1 for none). |
InRollout · Bucket | Pointers, set for percentage rollouts (bucket 0 to 99.99). |
Revision | The configuration revision the result came from. |
Trace | []TraceStep with Kind, Title, Detail. |
Error | Why 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.
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)
}
})| Field | Meaning |
|---|---|
Revision · PreviousRevision | The new revision and the one it replaced (-1 for the first). |
Checksum | Checksum of the new configuration. |
Source | network, store or bootstrap |
Transport | live (pushed), stream (fetched after a server-sent-events notice) or poll |
Latency · HasLatency | Database 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,OnErrorandOnStatuseach return an unsubscribe function. - Fallbacks: while no socket can be opened, server-sent events (
GET /v1/stream), and polling ofGET /v1/bundlewithETagevery 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
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 finishedWaitForReady(ctx)returns once the first load has finished:nilwhen 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.
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))FileStorewrites atomically with mode0600, so a restart can evaluate before it reaches the edge. The persisted copy is verified again on load.WithStoretakes anyStore(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.
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/keysonce, 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: trueloads keys from that edge; pinned keys are stronger.WithoutVerification()turns it off. - The canonical string and headers are described in Signatures.
OpenFeature provider
go get github.com/motazalmaroai/nanoflags-sdk/go/ofprovider@v0.1.0import (
"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/ofproviderv0.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),ERRORwithFLAG_NOT_FOUND,PROVIDER_NOT_READY,TYPE_MISMATCHorGENERAL. The variant is the rule id; flag metadata carriesrevision. - Events:
PROVIDER_READY,PROVIDER_STALE,PROVIDER_ERROR, andPROVIDER_CONFIGURATION_CHANGEDwith the keys of the flags that changed. - Options:
WithCloseClient()closes the NanoFlags client on shutdown;WithInitTimeout(d)bounds initialisation.
Options
| Option | Default |
|---|---|
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 · WithBootstrapJSON | none |
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
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
- Every evaluation returns the fallback. Check the error from
WaitForReadyand registerOnError. Codeunauthorizedmeans the key was rejected; with a browser key (nf_pub_…) only client-exposed flags are delivered. - Wrong argument order. Go variations take
(key, ctx, fallback). The Python and JavaScript SDKs take the fallback before the context. - Behind a proxy. The default transport honours
HTTPS_PROXY; passWithHTTPClientfor custom TLS or proxies. If upgrades are blocked, the client uses server-sent events and polling, andTransport()showsstreamorpoll. 403"error code: 1010" from your own HTTP calls. The edge refuses some default library User-Agents. The SDK sendsnanoflags-go/<version>, so this only affects code that calls the edge directly (for example OFREP requests or scripts): set a descriptiveUser-Agentheader there.- Code
signature. A bundle did not verify and was not used. With a customWithBaseURL, pin that edge's keys. - Code
engine. The configuration needs a newer evaluation engine thanSupportedEngineVersion: upgrade the module. - Code
environment_deleted. The edge closed the socket with4004. The client stops reconnecting, keeps its last configuration asStatusStale; replace the key.