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
10 changes: 9 additions & 1 deletion .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ jobs:
with:
version: v2.12.2

- name: Format
run: |
if [ -n "$(gofmt -s -l .)" ]; then
echo "The following files are not formatted correctly:"
gofmt -s -l .
exit 1
fi

- name: Test
run: go test -p 1 -v ./... -coverprofile="coverage.out"

Expand All @@ -40,7 +48,7 @@ jobs:

- name: Vulnerability Check
run: |
go install golang.org/x/vuln/cmd/govulncheck@3e6f44f962742443c11ae2261f02e0c917aeb2bc
go install golang.org/x/vuln/cmd/govulncheck@19b0bb6a272792b9afa8a6983c3e9b9a1816947f
govulncheck ./...

build-test:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,5 @@ jobs:

- name: Vulnerability Check
run: |
go install golang.org/x/vuln/cmd/govulncheck@3e6f44f962742443c11ae2261f02e0c917aeb2bc
go install golang.org/x/vuln/cmd/govulncheck@19b0bb6a272792b9afa8a6983c3e9b9a1816947f
govulncheck ./...
8 changes: 8 additions & 0 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,13 @@ jobs:
with:
version: v2.12.2

- name: Format
run: |
if [ -n "$(gofmt -s -l .)" ]; then
echo "The following files are not formatted correctly:"
gofmt -s -l .
exit 1
fi

- name: Test
run: go test -p 1 -v ./...
71 changes: 38 additions & 33 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,6 @@ public API surface down to the individual files that implement it. The package i
intentionally flat (`package client`, no internal sub-packages), so this document
exists to make the relationships between files explicit.

> **TODO / known gap**: README.md's [Hybrid Mode](README.md#hybrid-mode) section
> documents `client.GetSwitcher("FEATURE01").Remote().IsOn()`, but no `Remote()`
> method exists on `Switcher` in the current codebase (verified against
> `switcher.go`). Either implement `Switcher.Remote()` (a per-call override that
> forces `executionModeRemote`, bypassing `Options.Local`/Silent Mode) or update
> README.md to remove/replace the example. See [§2](#2-api-surface-map-feature--code)
> and [§6](#6-execution-modes-local-remote-silent).

## Table of Contents

- [1. Design Goals](#1-design-goals)
Expand Down Expand Up @@ -63,7 +55,7 @@ This maps README.md features to the files that implement them.
| Prepare/Execute pattern | `Switcher.Prepare` | `switcher.go` |
| Error notifications | `SubscribeNotifyError` | `client.go` |
| Throttling (stale-while-revalidate) | `Switcher.Throttle` | `switcher.go`, `execution_logger.go` |
| Hybrid mode (force remote) | `Switcher.Remote` *(documented in README; not yet present in code — see note below)* | `switcher.go` |
| Hybrid mode (force remote) | `Switcher.Remote` | `switcher.go` |
| Circuit breaker / Silent Mode | `Options.SilentMode` | `client_silent_mode.go` |
| Snapshot loading | `LoadSnapshot` | `client.go`, `snapshot.go` |
| Snapshot version check | `CheckSnapshot`, `SnapshotVersion` | `client.go`, `remote.go` |
Expand All @@ -80,39 +72,39 @@ This maps README.md features to the files that implement them.
```
┌─────────────────────────────┐
│ Application Code (user) │
└──────────────┬──────────────
└──────────────┬──────────────┘
│ package-level API
┌─────────────────────────────┐
│ client.go (facade layer) │ BuildContext / GetSwitcher /
│ defaultClient() singleton │ LoadSnapshot / CheckSnapshot ...
└──────────────┬──────────────
│ client.go (facade layer) │ BuildContext / GetSwitcher /
│ defaultClient() singleton │ LoadSnapshot / CheckSnapshot ...
└──────────────┬──────────────┘
│ delegates to
┌─────────────────────────────┐
│ *Client (core) │
│ context.go – configuration │
│ client.go – switcher cache,│
│ execution log, │
│ throttle tokens│
└───┬─────────┬─────────┬───────┘
┌─────────────────────────────────
│ *Client (core)
│ context.go – configuration
│ client.go – switcher cache,
│ execution log,
│ throttle tokens
└───┬─────────┬─────────┬─────────
│ │ │
┌────────────────┘ │ └────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ switcher.go │ │ remote.go │ │ snapshot.go /
│ Switcher (fluent │ │ HTTP transport, │ │ resolver.go
│ API + execution │◄───┤ auth, criteria, │ │ snapshot state,
│ orchestration) │ │ snapshot fetch │ │ local evaluation
└─────────┬───────────┘ └─────────┬─────────┘ └──────────┬─────────┘
┌───────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ execution_logger.go│ │ client_silent_ │ │ local_strategies.go│
│ (throttle cache/ │ │ mode.go │ │ (per-strategy │
│ logging) │ │ client_auto_ │ │ criteria engine) │
│ │ │ renew.go │ │ │
└───────────────────┘ └───────────────────┘ └────────────────────┘
│ switcher.go │ │ remote.go │ │ snapshot.go / │
│ Switcher (fluent │ │ HTTP transport, │ │ resolver.go │
│ API + execution │◄───┤ auth, criteria, │ │ snapshot state, │
│ orchestration) │ │ snapshot fetch │ │ local evaluation │
└─────────┬─────────┘ └─────────┬─────────┘ └──────────┬─────────┘
┌─────────────────────┐ ┌───────────────────┐ ┌────────────────────┐
│ execution_logger.go │ │ client_silent_ │ │ local_strategies.go│
│ (throttle cache/ │ │ mode.go │ │ (per-strategy │
│ logging) │ │ client_auto_ │ │ criteria engine) │
│ │ │ renew.go │ │ │
└─────────────────────┘ └───────────────────┘ └────────────────────┘

Cross-cutting: mock.go (test overrides), errors.go (typed errors),
result.go (ResultDetail), snapshot_watcher.go / snapshot_auto_updater.go (background jobs)
Expand Down Expand Up @@ -206,6 +198,19 @@ This makes Silent Mode a **circuit breaker with local-snapshot fallback** rather
a simple retry: local snapshot data must be self-sufficient (no Relay dependency) for
switchers evaluated in this mode — see the `RestrictRelay` check in `resolver.go`.

**Hybrid Mode override** (`Switcher.Remote(force ...bool)`, `switcher.go`): a per-call
opt-out from local execution. Setting `forceRemote = true` on a `Switcher` (the
default when calling `Remote()` with no arguments; `Remote(false)` clears it) changes
`resolveExecutionMode`'s first branch from `Options.Local` to
`Options.Local && !forceRemote`, so a forced switcher always falls through to the
`Validate` → token → `executionModeRemote` (or `executionModeSilentLocal`, if a
silent-mode window is active) path even while the client is otherwise configured for
Local Mode. `Validate()` requires `Options.Local == true` whenever `forceRemote` is
set — calling `Remote()` while local mode is disabled has no effect on mode
selection (the client is already remote-only) but does yield a
`"something went wrong: local mode is not enabled"` error from `Validate`, `Prepare`,
`IsOn`, and `IsOnWithDetails`.

## 7. Snapshot Lifecycle

```
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
.PHONY: test fmt cover cover-html lint lint-install godoc-install docs vulncheck vulncheck-install

GOLANGCI_LINT_VERSION=v2.12.2
GOVULNCHECK_VERSION=v1.5.0
GOVULNCHECK_VERSION=v1.6.0

test-clean:
go clean -testcache

test:
go test -p 1 -v ./...

fmt-check:
gofmt -s -l .

fmt:
gofmt -s -w .

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ A Go SDK for Switcher API
[![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](https://img.shields.io/badge/go-1.25%2B-blue.svg)
![Status](https://img.shields.io/badge/status-under_development-orange.svg)
![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)
[![Slack: Switcher-HQ](https://img.shields.io/badge/slack-@switcher/hq-blue.svg?logo=slack)](https://switcher-hq.slack.com/)

Expand Down Expand Up @@ -377,13 +376,18 @@ fmt.Println(logged.Response.Metadata["cached"])
```

#### Hybrid Mode

When `ContextOptions.Local` is enabled, evaluations are resolved against the local snapshot by default. Chain `.Remote()` on a specific Switcher to force that call to always use the remote API instead, without disabling Local Mode for the rest of the client.

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

`Remote()` requires `ContextOptions.Local` to be `true` — otherwise `Validate`/`IsOn`/`IsOnWithDetails` return an error. Pass `Remote(false)` to explicitly clear the override and fall back to the client's normal `Local`/Silent Mode behavior.

#### Circuit Breaker: Silent Mode

This feature allows you to specify how long the client SDK should attempt to restore connectivity in case of remote API failures.
Expand Down
27 changes: 26 additions & 1 deletion switcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Switcher struct {
entries []criteriaEntry
throttlePeriod time.Duration
nextRefreshAt time.Time
forceRemote bool
mu sync.RWMutex
}

Expand All @@ -34,6 +35,11 @@ const (
// Returns an error describing missing fields when validation fails.
func (s *Switcher) Validate() error {
ctx := s.client.Context()

if s.forceRemote && !ctx.Options.Local {
return fmt.Errorf("something went wrong: local mode is not enabled")
}

missingFields := make([]string, 0, 3)

if strings.TrimSpace(ctx.URL) == "" {
Expand Down Expand Up @@ -136,6 +142,24 @@ func (s *Switcher) Throttle(period time.Duration) *Switcher {
return s
}

// Remote forces this Switcher to always use the remote API for evaluation, bypassing
// Options.Local (Hybrid Mode). When called without arguments the override defaults to true;
// passing Remote(false) clears the override so Options.Local is honored normally.
//
// The remote override requires Options.Local to be enabled; otherwise Validate (and therefore
// Prepare/IsOn/IsOnWithDetails) returns an error.
func (s *Switcher) Remote(force ...bool) *Switcher {
s.mu.Lock()
defer s.mu.Unlock()

s.forceRemote = true
if len(force) > 0 {
s.forceRemote = force[0]
}

return s
}

// Prepare validates the switcher can be executed and ensures an auth token is present.
// If key is non-empty it will be set on the Switcher. Useful when preparing before execution.
func (s *Switcher) Prepare(key string) error {
Expand Down Expand Up @@ -255,7 +279,7 @@ func (s *Switcher) execute(execution *Switcher, showDetails bool) (ResultDetail,
}

func (s *Switcher) resolveExecutionMode() (executionMode, error) {
if s.client.Context().Options.Local {
if s.client.Context().Options.Local && !s.forceRemote {
return executionModeLocal, nil
}

Expand Down Expand Up @@ -322,6 +346,7 @@ func (s *Switcher) snapshotForExecution() *Switcher {
entries: clonedEntries,
throttlePeriod: s.throttlePeriod,
nextRefreshAt: s.nextRefreshAt,
forceRemote: s.forceRemote,
}
}

Expand Down
116 changes: 116 additions & 0 deletions switcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSwitcherValidate(t *testing.T) {
Expand Down Expand Up @@ -137,3 +138,118 @@ func TestSwitcherMustOrDefault(t *testing.T) {
assert.Equal(t, ResultDetail{Result: true, Reason: "Success", Metadata: map[string]any{"env": "prod"}}, gotD)
})
}

func TestSwitcherRemoteHybrid(t *testing.T) {
t.Run("should call the remote API even when local mode is enabled and a snapshot is loaded", func(t *testing.T) {
server := newRemoteTestServer(t, remoteTestHandlers{
authStatus: http.StatusOK,
authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()},
criteriaStatus: http.StatusOK,
// Local snapshot has FF2FOR2022 activated (true) with no strategies; the remote
// mock returns false so we can prove the remote path (not local) was used.
criteriaBody: map[string]any{"result": false, "reason": "remote"},
})
defer server.Close()

client := NewClient(Context{
Domain: "My Domain",
URL: server.URL,
APIKey: "[YOUR_API_KEY]",
Component: "MyApp",
Options: ContextOptions{
Local: true,
SnapshotLocation: snapshotFixtureDir(),
},
})

_, loadErr := client.LoadSnapshot(nil)
require.NoError(t, loadErr)

// sanity check: plain local evaluation returns true and never hits the server.
localResult, localErr := client.GetSwitcher("FF2FOR2022").IsOn()
require.NoError(t, localErr)
assert.True(t, localResult)

remoteResult, remoteErr := client.GetSwitcher("FF2FOR2022").Remote().IsOn()
require.NoError(t, remoteErr)
assert.False(t, remoteResult)
})

t.Run("should return an error from Validate/IsOn when local mode is not enabled", func(t *testing.T) {
client := NewClient(Context{
Domain: "My Domain",
URL: "https://api.switcherapi.com",
APIKey: "[YOUR_API_KEY]",
Component: "MyApp",
})

switcher := client.GetSwitcher("FEATURE_LOGIN_V2").Remote()

err := switcher.Validate()
assert.EqualError(t, err, "something went wrong: local mode is not enabled")

_, isOnErr := switcher.IsOn()
assert.EqualError(t, isOnErr, "something went wrong: local mode is not enabled")
})

t.Run("should behave as a no-op override when Remote(false) is used", func(t *testing.T) {
server := newRemoteTestServer(t, remoteTestHandlers{
authStatus: http.StatusOK,
authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()},
criteriaStatus: http.StatusOK,
criteriaBody: map[string]any{"result": false, "reason": "remote"},
})
defer server.Close()

client := NewClient(Context{
Domain: "My Domain",
URL: server.URL,
APIKey: "[YOUR_API_KEY]",
Component: "MyApp",
Options: ContextOptions{
Local: true,
SnapshotLocation: snapshotFixtureDir(),
},
})

_, loadErr := client.LoadSnapshot(nil)
require.NoError(t, loadErr)

result, err := client.GetSwitcher("FF2FOR2022").Remote(false).IsOn()
require.NoError(t, err)
assert.True(t, result)
})

t.Run("should compose with Check*, Throttle and IsOnWithDetails chains", func(t *testing.T) {
server := newRemoteTestServer(t, remoteTestHandlers{
authStatus: http.StatusOK,
authBody: map[string]any{"token": "[token]", "exp": time.Now().Add(time.Hour).Unix()},
criteriaStatus: http.StatusOK,
criteriaBody: map[string]any{"result": true, "reason": "Success"},
})
defer server.Close()

client := NewClient(Context{
Domain: "My Domain",
URL: server.URL,
APIKey: "[YOUR_API_KEY]",
Component: "MyApp",
Options: ContextOptions{
Local: true,
SnapshotLocation: snapshotFixtureDir(),
},
})

_, loadErr := client.LoadSnapshot(nil)
require.NoError(t, loadErr)

result, err := client.GetSwitcher("FF2FOR2022").
Remote().
CheckValue("USER_1").
Throttle(time.Second).
IsOnWithDetails()

require.NoError(t, err)
assert.Equal(t, ResultDetail{Result: true, Reason: "Success", Metadata: map[string]any{}}, result)
})
}