Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-cats-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/flagship-go": minor
---

Add opt-in TTL and LRU response caching to the Go provider.
30 changes: 27 additions & 3 deletions sdks/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package main
import (
"context"
"log"
"time"

flagship "github.com/cloudflare/flagship/sdks/go"
"github.com/open-feature/go-sdk/openfeature"
Expand All @@ -30,6 +31,7 @@ func main() {
AppID: "your-app-id",
AccountID: "your-account-id",
AuthToken: "your-token",
CacheTTL: 30 * time.Second, // cache evaluations per context for 30s (off by default)
})
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -74,9 +76,11 @@ provider, err := flagship.NewProvider(flagship.Options{
return http.Header{"Authorization": []string{"Bearer rotated-token"}}, nil
},

Timeout: 5 * time.Second,
Retries: 1,
RetryDelay: time.Second,
Timeout: 5 * time.Second,
Retries: 1,
RetryDelay: time.Second,
CacheTTL: 30 * time.Second,
CacheMaxSize: 1000,

Logging: true,
})
Expand All @@ -96,10 +100,30 @@ provider, err := flagship.NewProvider(flagship.Options{
| `Retries` | Retry attempts on transient errors; defaults to 1 and is capped at 10. |
| `DisableRetries` | Disables retries when set to true. |
| `RetryDelay` | Delay between retries; defaults to 1 second and is capped at 30 seconds. |
| `CacheTTL` | Cache TTL; enables response caching when greater than 0. |
| `CacheMaxSize` | Maximum cached entries; defaults to 1000 when `CacheTTL` is set. |
| `Logging` | Enables provider debug/error logs; off by default. |
| `Logger` | Optional `slog`-compatible logger. |
| `Hooks` | Provider-level OpenFeature hooks. |

## Response Caching

The provider can cache evaluations to avoid a network round-trip for repeated flag/context pairs. Caching is **off by default** and enabled by setting `CacheTTL`:

```go
provider, err := flagship.NewProvider(flagship.Options{
AppID: "your-app-id",
AccountID: "your-account-id",
AuthToken: "your-token",
CacheTTL: 30 * time.Second, // values may be up to this stale
CacheMaxSize: 1000, // LRU-evicted beyond this many entries
})
```

Each entry is keyed by flag key, flag type, and the full evaluation context, so distinct contexts never share a value. Cache hits resolve with `reason == openfeature.CachedReason`. Disabled flags, errors, and type mismatches are never cached. Because freshness is TTL-based, a flag change in Flagship takes effect after the entry expires.

The cache is per-provider instance, guarded by a mutex for concurrent use, and cleared on `Shutdown`.

## Evaluation Context

Context attributes are sent as URL query parameters. Supported values are `string`, numeric types, `bool`, and `time.Time`. `nil` values are skipped. Maps, slices, structs, and other complex values return `INVALID_CONTEXT` through OpenFeature and do not trigger an HTTP request.
Expand Down
91 changes: 91 additions & 0 deletions sdks/go/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package flagship

import (
"container/list"
"sync"
"time"
)

type responseCache struct {
mu sync.Mutex
ttl time.Duration
maxSize int
items map[string]*list.Element
order *list.List
}

type responseCacheEntry struct {
key string
response EvaluationResponse
expiresAt time.Time
}

func newResponseCache(ttl time.Duration, maxSize int) *responseCache {
if maxSize <= 0 {
maxSize = defaultCacheSize
}
return &responseCache{
ttl: ttl,
maxSize: maxSize,
items: make(map[string]*list.Element),
order: list.New(),
}
}

func (c *responseCache) get(key string) (EvaluationResponse, bool) {
c.mu.Lock()
defer c.mu.Unlock()

element, ok := c.items[key]
if !ok {
return EvaluationResponse{}, false
}

entry := element.Value.(*responseCacheEntry)
if !entry.expiresAt.After(time.Now()) {
c.removeElement(element)
return EvaluationResponse{}, false
}

c.order.MoveToFront(element)
return entry.response, true
}

func (c *responseCache) set(key string, response EvaluationResponse) {
c.mu.Lock()
defer c.mu.Unlock()

if element, ok := c.items[key]; ok {
entry := element.Value.(*responseCacheEntry)
entry.response = response
entry.expiresAt = time.Now().Add(c.ttl)
c.order.MoveToFront(element)
return
}

entry := &responseCacheEntry{key: key, response: response, expiresAt: time.Now().Add(c.ttl)}
element := c.order.PushFront(entry)
c.items[key] = element

for len(c.items) > c.maxSize {
oldest := c.order.Back()
if oldest == nil {
return
}
c.removeElement(oldest)
}
}

func (c *responseCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()

c.items = make(map[string]*list.Element)
c.order.Init()
}

func (c *responseCache) removeElement(element *list.Element) {
c.order.Remove(element)
entry := element.Value.(*responseCacheEntry)
delete(c.items, entry.key)
}
2 changes: 2 additions & 0 deletions sdks/go/examples/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"context"
"log"
"time"

flagship "github.com/cloudflare/flagship/sdks/go"
"github.com/open-feature/go-sdk/openfeature"
Expand All @@ -15,6 +16,7 @@ func main() {
AppID: "your-app-id",
AccountID: "your-account-id",
AuthToken: "your-token",
CacheTTL: 30 * time.Second, // cache evaluations per context for 30s (off by default)
})
if err != nil {
log.Fatal(err)
Expand Down
63 changes: 58 additions & 5 deletions sdks/go/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,36 @@ type FlagshipServerProvider = ServerProvider
type ServerProvider struct {
client *FlagshipClient
hooks []openfeature.Hook
cache *responseCache

logging bool
logger Logger
}

const (
flagTypeBoolean = "boolean"
flagTypeString = "string"
flagTypeFloat = "float"
flagTypeInt = "integer"
flagTypeObject = "object"
)

// NewProvider constructs a Flagship OpenFeature provider.
func NewProvider(options Options) (*ServerProvider, error) {
client, err := NewClient(options)
if err != nil {
return nil, err
}

var cache *responseCache
if options.CacheTTL > 0 {
cache = newResponseCache(options.CacheTTL, options.CacheMaxSize)
}

return &ServerProvider{
client: client,
hooks: append([]openfeature.Hook(nil), options.Hooks...),
cache: cache,
logging: options.Logging,
logger: resolveLogger(options.Logger),
}, nil
Expand Down Expand Up @@ -68,6 +84,9 @@ func (p *ServerProvider) InitWithContext(context.Context, openfeature.Evaluation

// Shutdown releases provider resources.
func (p *ServerProvider) Shutdown() {
if p.cache != nil {
p.cache.clear()
}
}

// ShutdownWithContext releases provider resources.
Expand All @@ -78,31 +97,31 @@ func (p *ServerProvider) ShutdownWithContext(context.Context) error {

// BooleanEvaluation evaluates a boolean flag.
func (p *ServerProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, flatCtx openfeature.FlattenedContext) openfeature.BoolResolutionDetail {
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toBool)
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeBoolean, toBool)
return openfeature.BoolResolutionDetail{Value: value, ProviderResolutionDetail: detail}
}

// StringEvaluation evaluates a string flag.
func (p *ServerProvider) StringEvaluation(ctx context.Context, flag string, defaultValue string, flatCtx openfeature.FlattenedContext) openfeature.StringResolutionDetail {
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toString)
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeString, toString)
return openfeature.StringResolutionDetail{Value: value, ProviderResolutionDetail: detail}
}

// FloatEvaluation evaluates a float flag.
func (p *ServerProvider) FloatEvaluation(ctx context.Context, flag string, defaultValue float64, flatCtx openfeature.FlattenedContext) openfeature.FloatResolutionDetail {
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toFloat64)
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeFloat, toFloat64)
return openfeature.FloatResolutionDetail{Value: value, ProviderResolutionDetail: detail}
}

// IntEvaluation evaluates an integer flag.
func (p *ServerProvider) IntEvaluation(ctx context.Context, flag string, defaultValue int64, flatCtx openfeature.FlattenedContext) openfeature.IntResolutionDetail {
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toInt64)
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeInt, toInt64)
return openfeature.IntResolutionDetail{Value: value, ProviderResolutionDetail: detail}
}

// ObjectEvaluation evaluates an object flag.
func (p *ServerProvider) ObjectEvaluation(ctx context.Context, flag string, defaultValue any, flatCtx openfeature.FlattenedContext) openfeature.InterfaceResolutionDetail {
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, toObject)
value, detail := resolveTyped(ctx, p, flag, defaultValue, flatCtx, flagTypeObject, toObject)
return openfeature.InterfaceResolutionDetail{Value: value, ProviderResolutionDetail: detail}
}

Expand All @@ -112,12 +131,31 @@ func resolveTyped[T any](
flag string,
defaultValue T,
flatCtx openfeature.FlattenedContext,
expectedType string,
convert func(any) (T, error),
) (T, openfeature.ProviderResolutionDetail) {
if p.logging {
p.logger.DebugContext(ctx, "Evaluating Flagship flag", "flag", flag)
}

var cacheKey string
if p.cache != nil {
key, err := buildCacheKey(flag, expectedType, flatCtx)
if err == nil {
if cached, ok := p.cache.get(key); ok {
value, err := convert(cached.Value)
if err == nil {
return value, openfeature.ProviderResolutionDetail{
Reason: openfeature.CachedReason,
Variant: cached.Variant,
FlagMetadata: openfeature.FlagMetadata{},
}
}
}
cacheKey = key
}
}

result, err := p.client.EvaluateFlat(ctx, flag, flatCtx)
if err != nil {
if p.logging {
Expand Down Expand Up @@ -152,6 +190,9 @@ func resolveTyped[T any](
if p.logging {
p.logger.DebugContext(ctx, "Flagship flag resolved", "flag", flag, "value", value, "reason", result.Reason, "variant", result.Variant)
}
if p.cache != nil && cacheKey != "" {
p.cache.set(cacheKey, result)
}

return value, openfeature.ProviderResolutionDetail{
Reason: mapReason(result.Reason),
Expand All @@ -160,6 +201,18 @@ func resolveTyped[T any](
}
}

func buildCacheKey(flagKey string, expectedType string, flatCtx openfeature.FlattenedContext) (string, error) {
params, err := contextToQueryParams(flatCtx)
if err != nil {
return "", err
}
encoded, err := json.Marshal([]string{flagKey, expectedType, params.Encode()})
if err != nil {
return "", err
}
return string(encoded), nil
}

func mapReason(reason EvaluationReason) openfeature.Reason {
switch reason {
case ReasonTargetingMatch:
Expand Down
Loading
Loading