Skip to content

Dynamic Configuration

Dynamic configuration means your application reads settings from st8 at runtime — not once at startup, but on each request (or on a short polling interval). This lets you change behavior without redeploying.

The simplest approach: fetch the relevant config document on every request.

package config
import (
"context"
"encoding/json"
"time"
st8 "github.com/geeper-io/st8/client"
)
type FeatureFlags struct {
DarkMode bool `json:"dark_mode"`
NewCheckout bool `json:"new_checkout"`
MaxPageSize int `json:"max_page_size"`
}
type Client struct {
st8 st8.Client
scope st8.Scope
}
func NewClient(serverURL, token string) *Client {
return &Client{
st8: st8.NewHTTP(serverURL, st8.WithToken(token), st8.WithTimeout(2*time.Second)),
scope: st8.Scope{Namespace: "myapp/prod", Branch: "main"},
}
}
func (c *Client) FeatureFlags(ctx context.Context) (FeatureFlags, error) {
result, err := c.st8.Get(ctx, c.scope, 0, "")
if err != nil {
return FeatureFlags{}, err
}
var flags FeatureFlags
if raw, ok := result.Objects["feature_flags"]; ok {
_ = json.Unmarshal([]byte(raw), &flags)
}
return flags, nil
}
// In your HTTP handler
func (h *Handler) Checkout(w http.ResponseWriter, r *http.Request) {
flags, err := h.config.FeatureFlags(r.Context())
if err != nil {
// fall back to safe defaults — never block the request
flags = FeatureFlags{MaxPageSize: 100}
}
if !flags.NewCheckout {
h.legacyCheckout(w, r)
return
}
h.newCheckout(w, r)
}

Fetching on every request adds latency. A smarter approach: poll st8 in the background and serve from an in-memory cache.

package config
import (
"context"
"encoding/json"
"sync"
"time"
st8 "github.com/geeper-io/st8/client"
)
type Config struct {
FeatureFlags FeatureFlags `json:"feature_flags"`
RateLimits RateLimits `json:"rate_limits"`
}
type cachedConfig struct {
mu sync.RWMutex
value Config
lastRev int64
st8 st8.Client
scope st8.Scope
}
// Get returns the current config from cache — no network call.
func (c *cachedConfig) Get() Config {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
// Refresh fetches the latest config from st8 if the revision has changed.
func (c *cachedConfig) Refresh(ctx context.Context) error {
result, err := c.st8.Get(ctx, c.scope, 0, "")
if err != nil {
return err
}
if result.Revision == c.lastRev {
return nil // nothing changed
}
var cfg Config
for key, raw := range result.Objects {
switch key {
case "feature_flags":
_ = json.Unmarshal([]byte(raw), &cfg.FeatureFlags)
case "rate_limits":
_ = json.Unmarshal([]byte(raw), &cfg.RateLimits)
}
}
c.mu.Lock()
c.value = cfg
c.lastRev = result.Revision
c.mu.Unlock()
return nil
}
// StartPolling polls st8 every interval and refreshes the cache.
func (c *cachedConfig) StartPolling(ctx context.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = c.Refresh(ctx)
}
}
}()
}
// Startup
cfg := &cachedConfig{
st8: st8.NewHTTP("https://st8.internal", st8.WithToken(token)),
scope: st8.Scope{Namespace: "myapp/prod", Branch: "main"},
}
if err := cfg.Refresh(ctx); err != nil {
log.Printf("warning: could not load initial config: %v", err)
}
cfg.StartPolling(ctx, 5*time.Second)
// In handler — no network calls
flags := cfg.Get().FeatureFlags
Terminal window
# Update a single document
cat > feature_flags.json <<'EOF'
{"dark_mode": true, "new_checkout": true, "max_page_size": 200}
EOF
st8ctl apply -f feature_flags.json \
--namespace myapp/prod \
--message "increase page size limit"
# Verify in production within seconds (no deploy)