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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ A Go SDK for Switcher API

[![Master CI](https://github.com/switcherapi/switcher-client-go/actions/workflows/master.yml/badge.svg)](https://github.com/switcherapi/switcher-client-go/actions/workflows/master.yml)
[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=switcherapi_switcher-client-go&metric=alert_status)](https://sonarcloud.io/dashboard?id=switcherapi_switcher-client-go)
![Known Vulnerabilities](https://snyk.io/test/github/switcherapi/switcher-client-go/badge.svg)
[![Go Report Card](https://goreportcard.com/badge/github.com/switcherapi/switcher-client-go)](https://goreportcard.com/report/github.com/switcherapi/switcher-client-go)
![Go](https://img.shields.io/badge/go-1.25%2B-blue.svg)
![Status](https://img.shields.io/badge/status-under_development-orange.svg)
Expand Down Expand Up @@ -325,13 +326,26 @@ client.SubscribeNotifyError(func(err error) {
## Advanced Features

#### Throttling

Throttle implements Stale-While-Revalidate behavior for feature flag evaluations, returning cached results while refreshing in the background. This is ideal for high-traffic scenarios where you want to minimize latency and avoid overwhelming the API with requests.

```go
_, err := client.GetSwitcher("FEATURE01").Throttle(time.Second).IsOn()
if err != nil {
panic(err)
}
```

Throttle reuses the latest cached execution for the same switcher key and inputs. It records that cached execution even when `ContextOptions.Logger` is `false`, and when `Freeze` is enabled the cached value stays in place until `client.ClearLogger()` is called.

```go
switcher := client.GetSwitcher("FEATURE01").Throttle(time.Second)
_, _ = switcher.IsOnWithDetails()

logged := client.GetExecution(switcher)
fmt.Println(logged.Response.Metadata["cached"])
```

#### Hybrid Mode
```go
_, err := client.GetSwitcher("FEATURE01").Remote().IsOn()
Expand Down
67 changes: 59 additions & 8 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type Client struct {
switchers map[string]*Switcher
snapshot *Snapshot

executionLogger *executionLogger
throttleTokens chan struct{}

snapshotWatcher *snapshotWatcher
snapshotAutoUpdater *snapshotAutoUpdater

Expand All @@ -31,9 +34,12 @@ type Client struct {
}

func NewClient(ctx Context) *Client {
defaulted := ctx.withDefaults()
return &Client{
context: ctx.withDefaults(),
context: defaulted,
switchers: make(map[string]*Switcher),
executionLogger: newExecutionLogger(),
throttleTokens: newThrottleTokens(defaulted.Options.ThrottleMaxWorkers),
snapshotWatcher: newSnapshotWatcher(),
snapshotAutoUpdater: newSnapshotAutoUpdater(),
}
Expand All @@ -49,6 +55,13 @@ func BuildContext(ctx Context) {
client.ScheduleSnapshotAutoUpdate(0, nil)
}

func (c *Client) Context() Context {
c.mu.RLock()
defer c.mu.RUnlock()

return c.context
}

func GetSwitcher(key string) *Switcher {
return defaultClient().GetSwitcher(key)
}
Expand Down Expand Up @@ -87,13 +100,6 @@ func (c *Client) GetSwitcher(key string) *Switcher {
return switcher
}

func (c *Client) Context() Context {
c.mu.RLock()
defer c.mu.RUnlock()

return c.context
}

func LoadSnapshot(options *LoadSnapshotOptions) (int, error) {
return defaultClient().LoadSnapshot(options)
}
Expand Down Expand Up @@ -174,6 +180,27 @@ func (c *Client) CheckSnapshot() (bool, error) {
return true, nil
}

func GetExecution(switcher *Switcher) ExecutionEntry {
return defaultClient().GetExecution(switcher)
}

func (c *Client) GetExecution(switcher *Switcher) ExecutionEntry {
if switcher == nil {
return ExecutionEntry{}
}

execution := switcher.snapshotForExecution()
return c.executionLogger.get(execution.key, execution.entries)
}

func ClearLogger() {
defaultClient().ClearLogger()
}

func (c *Client) ClearLogger() {
c.executionLogger.clear()
}

func SubscribeNotifyError(callback func(error)) {
defaultClient().SubscribeNotifyError(callback)
}
Expand All @@ -195,6 +222,22 @@ func (c *Client) notifyError(err error) {
}
}

func (c *Client) runBackgroundTask(task func()) {
if c.throttleTokens == nil {
go task()
return
}

go func() {
c.throttleTokens <- struct{}{}
defer func() {
<-c.throttleTokens
}()

task()
}()
}

func defaultClient() *Client {
if client := globalClient.Load(); client != nil {
return client
Expand All @@ -210,3 +253,11 @@ func defaultClient() *Client {

return globalClient.Load()
}

func newThrottleTokens(maxWorkers int) chan struct{} {
if maxWorkers <= 0 {
return nil
}

return make(chan struct{}, maxWorkers)
}
148 changes: 148 additions & 0 deletions execution_logger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package client

import (
"maps"
"sync"
)

type ExecutionInput struct {
Strategy string
Input string
}

type ExecutionEntry struct {
Key string
Inputs []ExecutionInput
Response ResultDetail
}

type executionLogger struct {
mu sync.RWMutex
entries []ExecutionEntry
}

func newExecutionLogger() *executionLogger {
return &executionLogger{
entries: make([]ExecutionEntry, 0),
}
}

func (l *executionLogger) add(key string, inputs []criteriaEntry, response ResultDetail) {
l.mu.Lock()
defer l.mu.Unlock()

for i := range l.entries {
if executionEntryMatches(l.entries[i], key, inputs) {
l.entries = append(l.entries[:i], l.entries[i+1:]...)
break
}
}

l.entries = append(l.entries, ExecutionEntry{
Key: key,
Inputs: executionInputsFromCriteria(inputs),
Response: cachedResultDetail(response),
})
}

func (l *executionLogger) get(key string, inputs []criteriaEntry) ExecutionEntry {
l.mu.RLock()
defer l.mu.RUnlock()

for _, entry := range l.entries {
if executionEntryMatches(entry, key, inputs) {
return cloneExecutionEntry(entry)
}
}

return ExecutionEntry{}
}

func (l *executionLogger) clear() {
l.mu.Lock()
defer l.mu.Unlock()

l.entries = l.entries[:0]
}

func executionEntryMatches(entry ExecutionEntry, key string, inputs []criteriaEntry) bool {
return entry.Key == key && executionInputsMatch(entry.Inputs, inputs)
}

func executionInputsMatch(logged []ExecutionInput, current []criteriaEntry) bool {
if len(logged) == 0 {
return len(current) == 0
}

if len(current) == 0 {
return false
}

for _, loggedInput := range logged {
found := false
for _, currentInput := range current {
if currentInput.Strategy == loggedInput.Strategy && currentInput.Input == loggedInput.Input {
found = true
break
}
}

if !found {
return false
}
}

return true
}

func executionInputsFromCriteria(inputs []criteriaEntry) []ExecutionInput {
if len(inputs) == 0 {
return nil
}

converted := make([]ExecutionInput, len(inputs))
for i, input := range inputs {
converted[i] = ExecutionInput(input)
}

return converted
}

func cloneExecutionEntry(entry ExecutionEntry) ExecutionEntry {
return ExecutionEntry{
Key: entry.Key,
Inputs: cloneExecutionInputs(entry.Inputs),
Response: cloneResultDetail(entry.Response),
}
}

func cloneExecutionInputs(inputs []ExecutionInput) []ExecutionInput {
if len(inputs) == 0 {
return nil
}

cloned := make([]ExecutionInput, len(inputs))
copy(cloned, inputs)
return cloned
}

func cloneResultDetail(result ResultDetail) ResultDetail {
return ResultDetail{
Result: result.Result,
Reason: result.Reason,
Metadata: cloneMetadata(result.Metadata),
}
}

func cachedResultDetail(result ResultDetail) ResultDetail {
cached := cloneResultDetail(result)
cached.Metadata["cached"] = true
return cached
}

func cloneMetadata(metadata map[string]any) map[string]any {
cloned := make(map[string]any, len(metadata))
maps.Copy(cloned, metadata)

return cloned
}
Loading
Loading