From bd51bad34a66ddea6b4db5bb884626bb9554e0c3 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:23:03 -0700 Subject: [PATCH] feat: added AutoRenewToken remote option --- .github/workflows/master.yml | 16 +- .github/workflows/sonar.yml | 10 +- .github/workflows/staging.yml | 6 +- ARCHITECTURE.md | 402 ++++++++++++++++++++++++++++++++++ README.md | 2 + client.go | 3 + client_auto_renew.go | 119 ++++++++++ client_auto_renew_test.go | 137 ++++++++++++ client_silent_mode.go | 2 + client_snapshot.go | 1 + context.go | 1 + remote.go | 46 +++- remote_test.go | 164 ++++++++++++++ 13 files changed, 882 insertions(+), 27 deletions(-) create mode 100644 ARCHITECTURE.md create mode 100644 client_auto_renew.go create mode 100644 client_auto_renew_test.go diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 98f5523..e8fece8 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -12,14 +12,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Set up Go 1.26.4 - uses: actions/setup-go@v6 + - name: Set up Go 1.26.5 + uses: actions/setup-go@v7 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Build run: go build -v ./... @@ -33,7 +33,7 @@ jobs: run: go test -p 1 -v ./... -coverprofile="coverage.out" - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.2.0 + uses: sonarsource/sonarqube-scan-action@v8.2.1 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' @@ -49,14 +49,14 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - go-version: ['1.25.10', '1.26.4'] + go-version: ['1.25.10', '1.26.5'] steps: - name: Git checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Go ${{ matrix.go-version }} - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: ${{ matrix.go-version }} diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index c861a2d..f013444 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -28,15 +28,15 @@ jobs: core.setOutput('base_ref', pr.data.base.ref); core.setOutput('head_sha', pr.data.head.sha); - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: ${{ steps.pr.outputs.head_sha }} fetch-depth: 0 - - name: Set up Go 1.26.4 - uses: actions/setup-go@v6 + - name: Set up Go 1.26.5 + uses: actions/setup-go@v7 with: - go-version: '1.26.4' + go-version: '1.26.5' - name: Build run: go build -v ./... @@ -50,7 +50,7 @@ jobs: run: go test -p 1 -v ./... -coverprofile="coverage.out" - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v8.2.0 + uses: sonarsource/sonarqube-scan-action@v8.2.1 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 614ec7f..4c90427 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -7,7 +7,7 @@ on: go: description: 'Go version' required: true - default: '1.26.4' + default: '1.26.5' os: description: 'Operating System (ubuntu-20.04, ubuntu-latest, windows-latest)' required: true @@ -24,12 +24,12 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version: ${{ github.event.inputs.go }} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c0108f9 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,402 @@ +# Architecture + +This document describes the design of the **Switcher Client SDK for Go**, from the +public API surface down to the individual files that implement it. The package is +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) +- [2. API Surface Map (Feature → Code)](#2-api-surface-map-feature--code) +- [3. High-Level Architecture](#3-high-level-architecture) +- [4. Core Components](#4-core-components) +- [5. Execution Flow (`IsOn` / `IsOnWithDetails`)](#5-execution-flow-ison--isonwithdetails) +- [6. Execution Modes: Local, Remote, Silent](#6-execution-modes-local-remote-silent) +- [7. Snapshot Lifecycle](#7-snapshot-lifecycle) +- [8. Authentication & Token Auto-Renewal](#8-authentication--token-auto-renewal) +- [9. Throttling (Stale-While-Revalidate)](#9-throttling-stale-while-revalidate) +- [10. Mocking / Test Support](#10-mocking--test-support) +- [11. Error Model](#11-error-model) +- [12. Concurrency Model](#12-concurrency-model) +- [13. File-by-File Reference](#13-file-by-file-reference) +- [14. Design Patterns Used](#14-design-patterns-used) +- [15. Extension Points](#15-extension-points) + +--- + +## 1. Design Goals + +- **Package-level ergonomics with instance-based core**: `client.GetSwitcher(...)` and + friends are thin wrappers around a `*Client` instance, so simple apps can use the + package API directly while tests/multi-tenant apps can create isolated `*Client` + instances via `NewClient`. +- **Zero-latency option**: full feature evaluation against an in-memory/on-disk + snapshot, no network required (`Options.Local`). +- **Resilience by default**: remote failures can automatically fall back to a local + snapshot ("Silent Mode" / circuit breaker) instead of failing requests. +- **Safe concurrency**: the `Client` is shared, mutable state (snapshot, auth token, + switcher cache, mocks, execution log) is guarded by targeted mutexes rather than one + global lock. +- **Testability**: SDK behavior can be mocked per-client without global state leakage + between tests. + +## 2. API Surface Map (Feature → Code) + +This maps README.md features to the files that implement them. + +| README Feature | Public Entry Points | Primary Files | +|---|---|---| +| Client bootstrap (`BuildContext`, `NewClient`) | `client.BuildContext`, `client.NewClient` | `client.go`, `context.go` | +| Basic feature flag check | `GetSwitcher(key).IsOn()` | `client.go`, `switcher.go` | +| Detailed response (`IsOnWithDetails`) | `Switcher.IsOnWithDetails` | `switcher.go`, `result.go` | +| Must-variant with default (`IsOnOrDefault`) | `Switcher.IsOnOrDefault`, `IsOnWithDetailsOrDefault` | `switcher.go` | +| Strategy-based flags (`Check*` fluent chain) | `Switcher.Check`, `CheckValue`, `CheckNumeric`, `CheckDate`, `CheckTime`, `CheckPayload`, `CheckNetwork`, `CheckRegex` | `switcher.go` (API), `local_strategies.go` (evaluation) | +| 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` | +| 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` | +| Snapshot auto-update | `ScheduleSnapshotAutoUpdate`, `TerminateSnapshotAutoUpdate` | `snapshot_auto_updater.go` | +| Snapshot file watch | `WatchSnapshot`, `UnwatchSnapshot` | `snapshot_watcher.go` | +| Switcher configuration validation | `CheckSwitchers` | `client.go`, `remote.go` (remote path), `resolver.go` (local path) | +| Execution log inspection | `GetExecution`, `ClearLogger` | `client.go`, `execution_logger.go` | +| Built-in mocking | `Client.Assume`, `Client.Forget` | `mock.go` | +| Remote transport / certs | `Options.Remote` | `remote.go` | +| Auth token auto-renewal | `Options.Remote.AutoRenewToken` | `client_auto_renew.go`, `remote.go` | + +## 3. High-Level Architecture + +``` + ┌─────────────────────────────┐ + │ Application Code (user) │ + └──────────────┬───────────────┘ + │ package-level API + ▼ + ┌─────────────────────────────┐ + │ 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│ + └───┬─────────┬─────────┬───────┘ + │ │ │ + ┌────────────────┘ │ └────────────────┐ + ▼ ▼ ▼ + ┌───────────────────┐ ┌───────────────────┐ ┌────────────────────┐ + │ 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) +``` + +Everything funnels through **`*Client`**, which is the aggregate root holding +configuration (`Context`), cached `*Switcher` instances, the current `*Snapshot`, +auth token state, the execution/throttle log, and background workers (snapshot +watcher, snapshot auto-updater, token auto-renewer). + +## 4. Core Components + +| Type | File | Responsibility | +|---|---|---| +| `Client` | `client.go` | Aggregate root: owns context, switcher cache, snapshot, mocks, execution logger, throttle token pool, auth state, background workers, HTTP client. | +| `Context` / `ContextOptions` / `RemoteOptions` | `context.go` | Immutable-ish configuration value objects with `withDefaults()` normalization. | +| `Switcher` | `switcher.go` | Fluent, per-key evaluator. Holds the strategy `entries` for one evaluation, throttle state, and orchestrates `submit → resolveExecutionMode → executeMode`. | +| `Snapshot` / `SnapshotDomain` / `SnapshotGroup` / `SnapshotConfig` / `SnapshotStrategy` / `SnapshotRelay` | `snapshot.go` | Value objects mirroring the Switcher-API domain data model, (de)serialized to/from JSON snapshot files. | +| `resolver.go` (unexported functions) | `resolver.go` | Pure local evaluation engine: walks `Snapshot → Domain → Group → Config → Strategies` and returns a `ResultDetail`. | +| `local_strategies.go` | `local_strategies.go` | Per-strategy-type predicate evaluation (VALUE, NUMERIC, DATE, TIME, PAYLOAD, NETWORK, REGEX) plus `Operation` constants. | +| `remote.go` | `remote.go` | All outbound HTTP calls: `/criteria/auth`, `/criteria`, `/criteria/switchers_check`, `/criteria/snapshot_check/{version}`, `/graphql`, `/check`. Builds the shared `*http.Client`/`*http.Transport` (TLS, timeouts, cert). | +| `client_silent_mode.go` | `client_silent_mode.go` | Circuit-breaker: decides when to force local evaluation after remote failures and manages the sentinel `SILENT` auth token. | +| `client_auto_renew.go` (`tokenAutoRenewer`) | `client_auto_renew.go` | Background timer that refreshes the auth token slightly before expiry. | +| `snapshot_watcher.go` (`snapshotWatcher`) | `snapshot_watcher.go` | Polls the snapshot file on disk for changes and hot-reloads it. | +| `snapshot_auto_updater.go` (`snapshotAutoUpdater`) | `snapshot_auto_updater.go` | Periodically calls `CheckSnapshot` on a ticker. | +| `execution_logger.go` (`executionLogger`) | `execution_logger.go` | Stores the last `ResultDetail` per (key, inputs) pair; backs both `GetExecution` and throttling's cached-result path. | +| `mock.go` (`MockAssumption`, `mockDefinition`) | `mock.go` | Per-client test mocking (`Assume`/`Forget`), with optional per-strategy `When(...)` conditions. | +| `errors.go` | `errors.go` | Typed error hierarchy (`RemoteError` family, `LocalCriteriaError`, `LocalSwitcherError`) so callers can `errors.As` on failure category. | +| `result.go` | `result.go` | `ResultDetail` — the canonical evaluation outcome (`Result`, `Reason`, `Metadata`). | +| `client_snapshot.go` | `client_snapshot.go` | Small `Client` snapshot-state helpers (`snapshotState`, `setSnapshot`, `shouldCheckSnapshot`, `loadSnapshotFromCurrentFile`, `stopBackgroundTasks`). | + +## 5. Execution Flow (`IsOn` / `IsOnWithDetails`) + +Both public methods funnel into `Switcher.submit(showDetails bool)` in `switcher.go`: + +``` +Switcher.IsOn() / IsOnWithDetails() + │ + ▼ + Switcher.submit(showDetails) + │ + ├─ 1. snapshotForExecution() // clone key+entries+throttle state (race-safe snapshot of *this* call) + │ + ├─ 2. client.mockedResult(execution) // mock.go — short-circuit if Client.Assume(key) was set + │ └─ found → log + return + │ + ├─ 3. tryCachedResult(execution) // only when Throttle(...) was set — execution_logger.go lookup + │ └─ hit → optionally scheduleBackgroundRefresh(), return cached ResultDetail + │ + └─ 4. execute(execution, showDetails) + │ + ├─ resolveExecutionMode() // Local | SilentLocal | Remote (see §6) + ├─ executeMode(mode, ...) // dispatch to executeLocal() or executeRemote() + └─ logResult(result) // execution_logger.go, only if Logger option or Throttle is set +``` + +`snapshotForExecution` matters: a `*Switcher` returned by `GetSwitcher` is a +long-lived, cached, mutable object (its `entries`/`throttlePeriod` can be changed by +further `Check*`/`Throttle` calls). Each evaluation call takes an immutable clone so +concurrent calls to the same cached `Switcher` don't race on `entries`. + +## 6. Execution Modes: Local, Remote, Silent + +`Switcher.resolveExecutionMode()` picks one of three modes: + +1. **`executionModeLocal`** — `Context.Options.Local == true`. Skips remote + validation entirely (no URL/APIKey/Component required) and evaluates directly + against the in-memory `*Snapshot` via `resolver.go`. +2. **`executionModeRemote`** — default when not in local mode: validates the + `Switcher`/`Context` (`Switcher.Validate`), obtains an auth token + (`Client.ensureToken`, `remote.go`), and calls the `/criteria` endpoint. +3. **`executionModeSilentLocal`** — entered automatically when `client_silent_mode.go` + detects the client is already in a silent-mode window (`shouldUseLocalSilentMode`) + — i.e., a prior remote failure caused a fallback and the `SilentMode` cool-down + period has not elapsed. Evaluates locally exactly like local mode. + +**Fallback on remote failure** (`executeRemote` → `fallbackToSilentMode` in +`client_silent_mode.go`): if `ensureToken` or `checkCriteria` fails and +`Options.SilentMode > 0`, the client: + - notifies the error callback (`notifyError`), + - stamps `authToken = "SILENT"` with an expiry `now + SilentMode` + (`updateSilentToken`), + - immediately serves the request from the local snapshot. + +While in that window, `shouldUseLocalSilentMode()` periodically probes +`/check` (`checkAPIHealth`) once the silent window has technically expired; if the +API is healthy again it clears the silent token and resumes remote mode, otherwise it +re-arms another silent window. + +This makes Silent Mode a **circuit breaker with local-snapshot fallback** rather than +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`. + +## 7. Snapshot Lifecycle + +``` +LoadSnapshot(options) (client.go) + │ + ├─ loadSnapshotFromCurrentFile() ─── snapshot.go: loadSnapshotFromFile() + │ reads /.json, or creates an empty + │ default snapshot (version 0) if the file/location doesn't exist yet + │ + ├─ shouldCheckSnapshot(FetchRemote)? (client_snapshot.go) + │ true when version == 0 AND (FetchRemote requested OR not in Local mode) + │ └─ CheckSnapshot() (client.go / remote.go) + │ ensureToken() → checkSnapshotVersion(token, currentVersion) + │ │ GET /criteria/snapshot_check/{version} + │ └─ if stale → resolveSnapshot(token) POST /graphql (domain query) + │ → saveSnapshotToFile() (snapshot.go, only if SnapshotLocation set) + │ → setSnapshot(...) (client_snapshot.go, in-memory swap) + │ + └─ WatchSnapshot requested? → snapshot_watcher.go starts polling the file +``` + +Two independent, composable mechanisms keep a local snapshot fresh: + +- **`ScheduleSnapshotAutoUpdate`** (`snapshot_auto_updater.go`): a ticker goroutine + that calls `Client.CheckSnapshot()` (pull-based, talks to the remote API). +- **`WatchSnapshot`** (`snapshot_watcher.go`): a ticker goroutine that `os.Stat`s the + snapshot *file* every 100ms and reloads it in-memory on change (push-based from an + external process writing the file, e.g. another SDK instance or a sidecar). + +Both are started/stopped independently and both are stopped together via +`Client.stopBackgroundTasks()` when `BuildContext` replaces the global client. + +## 8. Authentication & Token Auto-Renewal + +- `Client.ensureToken()` (`remote.go`) is the single lazy-auth entry point used by + every remote call. It returns the cached token if non-empty, non-`"SILENT"`, and + unexpired; otherwise it calls `authenticate()` (`POST /criteria/auth`) and caches + the result (`authToken`, `authTokenExp`, guarded by `authMu`). +- When `Options.Remote.AutoRenewToken` is true, a successful `ensureToken()` schedules + `tokenAutoRenewer.schedule()` (`client_auto_renew.go`): a `time.AfterFunc` fires + `autoRenewBuffer` (5s) before expiry and calls `authenticate()` again in the + background, re-scheduling itself — so foreground requests never pay synchronous + re-auth latency once warmed up. +- A **generation counter** in `tokenAutoRenewer` invalidates in-flight/scheduled + renewals when `stop()` is called (e.g., entering Silent Mode or replacing the + client), preventing a stale background renewal from clobbering newer state. + +## 9. Throttling (Stale-While-Revalidate) + +`Switcher.Throttle(period)` (`switcher.go`) opts a switcher into SWR semantics: + +1. First call has no cache entry → executes normally, then `logResult` unconditionally + stores the result in `executionLogger` (throttled switchers log even when + `Options.Logger` is false — see `Switcher.canLog()`). +2. Subsequent calls within the throttle window: `tryCachedResult` finds the entry in + `executionLogger` and returns it immediately. +3. Once `nextRefreshAt` has passed (`shouldScheduleRefresh`) and `Options.Freeze` is + not set, a background refresh is fired via `Client.runBackgroundTask` (bounded by + `throttleTokens`, a buffered channel sized by `Options.ThrottleMaxWorkers`) which + re-executes and re-logs, updating the cache for the *next* caller. +4. If `Options.Freeze` is true, the cached value is pinned until an explicit + `client.ClearLogger()`. + +Cached responses are tagged with `Metadata["cached"] = true` +(`execution_logger.go: cachedResultDetail`) so callers can distinguish fresh vs. +stale-served results (see README's `GetExecution(...).Response.Metadata["cached"]` +example). + +## 10. Mocking / Test Support + +`mock.go` implements client-scoped mocking so tests don't need a real snapshot or +remote API: + +- `Client.Assume(key)` creates a `mockDefinition` in `Client.mocks` (guarded by + `mockMu`, separate from the main `mu` used for context/snapshot/switchers) and + returns a `*MockAssumption` fluent builder (`True()`, `False()`, `When(strategy, + input)`, `WithMetadata(...)`, `Cleanup(t)`). +- `Switcher.submit()` checks `Client.mockedResult(execution)` **before** cache lookup + or real evaluation — mocks take precedence over everything, including Throttle. +- `mockDefinition.responseFor(entries)` supports conditional mocks: if a `When(...)` + condition's strategy input doesn't match what was actually supplied via + `Check*(...)`, the mock's boolean result is **inverted** and a descriptive mismatch + reason is generated (`mismatchMockReason`). This lets tests assert both the + "matching" and "non-matching" branches from a single `Assume(...).When(...)` setup. +- Mocks are strictly per-`Client` instance — no process-global mock registry — so + parallel tests using `client.NewClient(...)` don't interfere with each other. + +## 11. Error Model + +`errors.go` defines two error families surfaced by the SDK: + +- **Remote errors** — all embed `RemoteError` (base `message string` + `Error()`): + `RemoteAuthError`, `RemoteCriteriaError`, `RemoteSnapshotError`, + `RemoteSwitcherError`. Constructed via `newRemote*Error(...)` helpers in + `remote.go`/`errors.go`. Callers can `errors.As(err, &client.RemoteAuthError{})` + to branch on failure category (e.g., to distinguish "bad API key" from "network + unavailable"). +- **Local errors** — `LocalCriteriaError` (snapshot missing / config or key not + found during local evaluation, from `resolver.go`) and `LocalSwitcherError` + (`CheckSwitchers` found missing keys locally). +- `missingTokenError(token)` (`remote.go`) is a plain `errors.New` guard used after + every `ensureToken()` call site to convert an empty token into an explicit error + without a network round-trip. + +## 12. Concurrency Model + +`Client` uses **multiple fine-grained mutexes** instead of one lock, to avoid +serializing unrelated operations: + +| Mutex | Guards | +|---|---| +| `Client.mu` (`RWMutex`) | `context`, `switchers` map, `snapshot` | +| `Client.mockMu` (`RWMutex`) | `mocks` map | +| `Client.authMu` (`Mutex`) | `authToken`, `authTokenExp` | +| `Client.httpClientMu` (`Mutex`) | lazily-built `httpClient_` | +| `Client.notifyErrorMu` (`RWMutex`) | `notifyErrorCallback` | +| `Switcher.mu` (`RWMutex`) | `entries`, `throttlePeriod`, `nextRefreshAt` on a cached `*Switcher` | +| `tokenAutoRenewer.mu`, `snapshotWatcher.mu`, `snapshotAutoUpdater.mu` | each background worker's own timer/stop/done/generation state | + +Background work (throttle refresh) is dispatched through +`Client.runBackgroundTask`, which — when `Options.ThrottleMaxWorkers > 0` — bounds +concurrency using a buffered channel (`throttleTokens`) acting as a worker-pool +semaphore; with the default (`0`), refreshes just spawn an unbounded goroutine. + +The package-level API is backed by a single global `*Client` stored in an +`atomic.Pointer[Client]` (`globalClient` in `client.go`), swapped atomically by +`BuildContext` (old client's background tasks are stopped before being discarded). + +## 13. File-by-File Reference + +| File | Role | +|---|---| +| `client.go` | Public facade + `Client` struct + switcher cache + snapshot/switcher-check orchestration + global default-client singleton. | +| `context.go` | `Context`/`ContextOptions`/`RemoteOptions` config types and defaulting. | +| `switcher.go` | `Switcher` fluent API + evaluation orchestration (mock → cache → execute). | +| `resolver.go` | Pure local criteria engine (Snapshot → Domain → Group → Config → Strategies). | +| `local_strategies.go` | Strategy/Operation constants + per-strategy predicate implementations. | +| `remote.go` | HTTP client construction, auth, criteria/snapshot/switchers remote calls. | +| `remote_transport_test.go` | Tests for the TLS/transport configuration in `remote.go`. | +| `snapshot.go` | `Snapshot` data model + file (de)serialization. | +| `client_snapshot.go` | `Client` snapshot-state accessors used by `client.go`/watchers. | +| `client_silent_mode.go` | Circuit-breaker fallback logic + silent-mode token sentinel. | +| `client_auto_renew.go` | Background token auto-renewal (`tokenAutoRenewer`). | +| `snapshot_watcher.go` | File-polling snapshot hot-reload worker. | +| `snapshot_auto_updater.go` | Ticker-based periodic `CheckSnapshot` worker. | +| `execution_logger.go` | Cache of last `ResultDetail` per (key, inputs); backs throttling + `GetExecution`. | +| `mock.go` | Per-client test mocking (`Assume`/`Forget`/`MockAssumption`). | +| `result.go` | `ResultDetail` value type. | +| `errors.go` | Typed error hierarchy for remote/local failures. | +| `client_check_switchers_test.go`, `client_test.go`, `switcher_test.go`, `switcher_throttle_test.go`, `local_test.go`, `snapshot_test.go`, `snapshot_checker_test.go`, `snapshot_watcher_test.go`, `client_auto_renew_test.go`, `client_silent_mode_test.go`, `execution_logger_test.go`, `context_test.go`, `mock_test.go`, `remote_test.go` | Corresponding unit tests, generally one test file per production file above. | +| `testdata/` | Fixture snapshot JSON files used by tests. | + +## 14. Design Patterns Used + +- **Facade**: package-level functions in `client.go` (`GetSwitcher`, `LoadSnapshot`, + `CheckSnapshot`, ...) are thin facades over `*Client` methods, backed by a lazily + initialized singleton (`defaultClient()`). +- **Builder / Fluent Interface**: `Switcher.Check*` methods and `MockAssumption` + (`True/False/When/WithMetadata`) both return `*Switcher`/`*MockAssumption` for + chaining, mirroring the README's chained-strategy examples. +- **Strategy Pattern**: `local_strategies.go` dispatches evaluation by + `SnapshotStrategy.Strategy` to independent `process*Strategy` predicate functions, + keyed by `Operation` sub-dispatch. +- **State / Circuit Breaker**: `client_silent_mode.go` models three effective client + states (healthy-remote, silent/local-fallback, recovering) driven by + `authToken == "SILENT"` + expiry, transitioning via `checkAPIHealth`. +- **Decorator-like execution wrapping**: `Switcher.submit` layers mocking → + throttle-cache → real execution → logging around the same core "evaluate" + operation without those concerns knowing about each other. +- **Observer**: `SubscribeNotifyError`/`notifyError` is a single-callback observer + used to surface async/background errors (silent-mode fallback, throttle + background-refresh failures) to the caller without requiring a return value. +- **Object Pool via buffered channel**: `throttleTokens` implements a bounded + worker-pool for background throttle refreshes. +- **Value Objects with immutable-copy semantics**: `Switcher.snapshotForExecution`, + `cloneMetadata`, `cloneResultDetail`, `mockDefinition.clone` all defensively copy + mutable maps/slices before crossing a concurrency boundary, avoiding shared mutable + state across goroutines. + +## 15. Extension Points + +- **New strategy type**: add a `Strategy*` constant, a `process*Strategy` function in + `local_strategies.go`, and wire it into `processLocalStrategy`'s switch — plus a + `Check*` convenience method in `switcher.go` if it should be part of the fluent + chain. +- **New remote endpoint**: add a request/response struct and method in `remote.go` + following the existing `doJSONRequest` + typed-error pattern, then expose it via a + `Client` method (and optional package-level facade) in `client.go`. +- **New client-wide background worker**: follow the `stop`/`done` channel pattern used + by `snapshotWatcher`/`snapshotAutoUpdater` (start/stop idempotent, `Stop()` blocks + until the goroutine exits) and register it in `Client.stopBackgroundTasks()`. +- **New error category**: add a type embedding `RemoteError` (or a standalone local + error type) plus a `new*Error` constructor in `errors.go`, so callers can + `errors.As` on it. diff --git a/README.md b/README.md index f506960..049bc54 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ func main() { RegexMaxTimeLimit: 100 * time.Millisecond, Remote: client.RemoteOptions{ CertPath: "./certs/client.pem", + AutoRenewToken: true, ConnectTimeout: 300 * time.Millisecond, Timeout: 5 * time.Second, }, @@ -220,6 +221,7 @@ func main() { | Option | Type | Description | Default | |--------|------|-------------|---------| | `CertPath` | `string` | Path to a PEM bundle containing the client certificate and private key for secure API connections | `""` | +| `AutoRenewToken` | `bool` | Proactively renew the auth token in the background shortly before it expires, avoiding synchronous re-auth latency on foreground requests | `false` | | `ConnectTimeout` | `time.Duration` | Max time to establish a remote connection before failing fast | `300ms` | | `Timeout` | `time.Duration` | Max time for remote request/response and idle connection reuse | `5s` | diff --git a/client.go b/client.go index 8b78b48..c467640 100644 --- a/client.go +++ b/client.go @@ -35,6 +35,8 @@ type Client struct { authToken string authTokenExp int64 + autoRenewer *tokenAutoRenewer + httpClientMu sync.Mutex httpClient_ *http.Client @@ -55,6 +57,7 @@ func NewClient(ctx Context) *Client { throttleTokens: newThrottleTokens(defaulted.Options.ThrottleMaxWorkers), snapshotWatcher: newSnapshotWatcher(), snapshotAutoUpdater: newSnapshotAutoUpdater(), + autoRenewer: newTokenAutoRenewer(), } } diff --git a/client_auto_renew.go b/client_auto_renew.go new file mode 100644 index 0000000..9fbb1de --- /dev/null +++ b/client_auto_renew.go @@ -0,0 +1,119 @@ +package client + +import ( + "sync" + "time" +) + +// autoRenewBuffer is subtracted from the token's remaining lifetime before scheduling +// the next background renewal, so the renewal fires slightly ahead of expiration. +const autoRenewBuffer = 5 * time.Second + +// autoRenewMinDelay is the minimum delay used for a scheduled renewal, preventing +// tight refresh loops when a token's remaining lifetime is very short or already past. +const autoRenewMinDelay = 1 * time.Second + +// tokenAutoRenewer manages a background timer that proactively refreshes the client's +// auth token ahead of its expiration when RemoteOptions.AutoRenewToken is enabled. +// +// A generation counter guards against stale renewals: any scheduled or in-flight +// renewal from a previous generation is discarded rather than overwriting a newer +// token. +type tokenAutoRenewer struct { + mu sync.Mutex + timer *time.Timer + generation int +} + +func newTokenAutoRenewer() *tokenAutoRenewer { + return &tokenAutoRenewer{} +} + +// schedule arranges for a background renewal of client's auth token ahead of exp +// (a Unix timestamp in seconds or milliseconds, consistent with tokenExpired). +// Any previously scheduled renewal is cancelled. +func (r *tokenAutoRenewer) schedule(client *Client, exp int64) { + delay := autoRenewDelay(exp) + + r.mu.Lock() + r.generation++ + generation := r.generation + previous := r.timer + + timer := time.AfterFunc(delay, func() { + r.renew(client, generation) + }) + r.timer = timer + r.mu.Unlock() + + if previous != nil { + previous.Stop() + } +} + +// renew performs a background token refresh for the given generation. If the +// renewer has moved on to a newer generation (e.g. due to stop() or a newer +// schedule()) either before or after the network call, the result is discarded. +func (r *tokenAutoRenewer) renew(client *Client, generation int) { + if !r.isCurrentGeneration(generation) { + return + } + + token, exp, err := client.authenticate() + + if !r.isCurrentGeneration(generation) { + return + } + + if err != nil || token == "" { + r.stop() + return + } + + client.authMu.Lock() + client.authToken = token + client.authTokenExp = exp + client.authMu.Unlock() + + r.schedule(client, exp) +} + +// stop cancels any pending or future renewal, invalidating in-flight callbacks by +// bumping the generation counter. +func (r *tokenAutoRenewer) stop() { + r.mu.Lock() + r.generation++ + timer := r.timer + r.timer = nil + r.mu.Unlock() + + if timer != nil { + timer.Stop() + } +} + +func (r *tokenAutoRenewer) isCurrentGeneration(generation int) bool { + r.mu.Lock() + defer r.mu.Unlock() + + return generation == r.generation +} + +// autoRenewDelay computes how long to wait before renewing a token expiring at exp +// (a Unix timestamp in seconds or milliseconds), buffered so the renewal fires +// slightly ahead of expiration, floored at autoRenewMinDelay. +func autoRenewDelay(exp int64) time.Duration { + var expiration time.Time + if exp > 1_000_000_000_000 { + expiration = time.UnixMilli(exp) + } else { + expiration = time.Unix(exp, 0) + } + + delay := time.Until(expiration) - autoRenewBuffer + if delay < autoRenewMinDelay { + return autoRenewMinDelay + } + + return delay +} diff --git a/client_auto_renew_test.go b/client_auto_renew_test.go new file mode 100644 index 0000000..6e8466d --- /dev/null +++ b/client_auto_renew_test.go @@ -0,0 +1,137 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// These are rare, focused white-box tests covering internal generation/race semantics +// of tokenAutoRenewer that aren't practically observable (deterministically) via the +// public API. Please check the public API tests at remote_test.go (TestSwitcherRemoteAutoRenewToken). + +func TestAutoRenewDelay(t *testing.T) { + t.Run("should treat exp as Unix seconds when at or below the millisecond threshold", func(t *testing.T) { + exp := time.Now().Add(time.Hour).Unix() + + delay := autoRenewDelay(exp) + + expected := time.Until(time.Unix(exp, 0)) - autoRenewBuffer + assert.InDelta(t, expected.Seconds(), delay.Seconds(), 1) + }) + + t.Run("should treat exp as Unix milliseconds when above the millisecond threshold", func(t *testing.T) { + exp := time.Now().Add(time.Hour).UnixMilli() + assert.Greater(t, exp, int64(1_000_000_000_000)) + + delay := autoRenewDelay(exp) + + expected := time.Until(time.UnixMilli(exp)) - autoRenewBuffer + assert.InDelta(t, expected.Seconds(), delay.Seconds(), 1) + }) + + t.Run("should floor the delay at autoRenewMinDelay when exp is imminent or in the past", func(t *testing.T) { + exp := time.Now().Add(-time.Minute).Unix() + + delay := autoRenewDelay(exp) + + assert.Equal(t, autoRenewMinDelay, delay) + }) +} + +func TestTokenAutoRenewerGenerationSemantics(t *testing.T) { + t.Run("should skip auth for a stale generation renew callback", func(t *testing.T) { + var authRequests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + authRequests.Add(1) + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "token": "[new_token]", + "exp": time.Now().Add(time.Hour).Unix(), + }) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + }) + client.authToken = "[current_token]" + client.authTokenExp = time.Now().Add(time.Hour).Unix() + + renewer := newTokenAutoRenewer() + renewer.generation = 5 + staleGeneration := 4 + + renewer.renew(client, staleGeneration) + + assert.Equal(t, int32(0), authRequests.Load(), "expected no auth request for a stale generation") + assert.Equal(t, "[current_token]", client.authToken) + }) + + t.Run("should discard the renewal result when stop is called while the request is in flight", func(t *testing.T) { + authStarted := make(chan struct{}) + releaseAuth := make(chan struct{}) + var authRequests atomic.Int32 + + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + authRequests.Add(1) + close(authStarted) + select { + case <-releaseAuth: + case <-time.After(2 * time.Second): + } + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "token": "[new_token]", + "exp": time.Now().Add(time.Hour).Unix(), + }) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + }) + client.authToken = "[current_token]" + client.authTokenExp = time.Now().Add(time.Hour).Unix() + + renewer := newTokenAutoRenewer() + currentGeneration := renewer.generation + + done := make(chan struct{}) + go func() { + defer close(done) + renewer.renew(client, currentGeneration) + }() + + select { + case <-authStarted: + case <-time.After(1 * time.Second): + t.Fatal("expected auth request to start") + } + + renewer.stop() + close(releaseAuth) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("expected renew goroutine to finish") + } + + assert.Equal(t, int32(1), authRequests.Load()) + assert.Equal(t, "[current_token]", client.authToken, "expected the stale renewal result to be discarded") + assert.Nil(t, renewer.timer, "expected no new renewal to be scheduled after stop") + }) +} diff --git a/client_silent_mode.go b/client_silent_mode.go index f6bfe7f..1708ed0 100644 --- a/client_silent_mode.go +++ b/client_silent_mode.go @@ -49,6 +49,8 @@ func (c *Client) authState() (string, int64) { } func (c *Client) updateSilentToken() { + c.autoRenewer.stop() + c.authMu.Lock() defer c.authMu.Unlock() diff --git a/client_snapshot.go b/client_snapshot.go index 0a7e7c3..f0f5291 100644 --- a/client_snapshot.go +++ b/client_snapshot.go @@ -17,6 +17,7 @@ func (c *Client) setSnapshot(snapshot *Snapshot) { func (c *Client) stopBackgroundTasks() { c.TerminateSnapshotAutoUpdate() c.UnwatchSnapshot() + c.autoRenewer.stop() } func (c *Client) shouldCheckSnapshot(fetchRemote bool) bool { diff --git a/context.go b/context.go index 8871b02..ddd431e 100644 --- a/context.go +++ b/context.go @@ -18,6 +18,7 @@ const ( // RemoteOptions configures remote transport behavior, timeouts and certificate path. type RemoteOptions struct { CertPath string + AutoRenewToken bool ConnectTimeout time.Duration Timeout time.Duration } diff --git a/remote.go b/remote.go index d104aab..610dbf6 100644 --- a/remote.go +++ b/remote.go @@ -57,12 +57,39 @@ func (c *Client) authHeaders(token string) map[string]string { func (c *Client) ensureToken() (string, error) { c.authMu.Lock() - defer c.authMu.Unlock() if strings.TrimSpace(c.authToken) != "" && c.authToken != silentModeAuthToken && !tokenExpired(c.authTokenExp) { - return c.authToken, nil + token := c.authToken + c.authMu.Unlock() + return token, nil } + c.authMu.Unlock() + token, exp, err := c.authenticate() + if err != nil { + return "", err + } + + c.authMu.Lock() + c.authToken = token + c.authTokenExp = exp + c.authMu.Unlock() + + if token != "" { + ctx := c.Context() + if ctx.Options.Remote.AutoRenewToken { + c.autoRenewer.schedule(c, exp) + } + } + + return token, nil +} + +// authenticate performs the remote authentication request and returns the parsed +// token and expiration without mutating client state or acquiring authMu, so it can +// be reused by both the lazy foreground ensureToken path and the background +// token auto-renewer. +func (c *Client) authenticate() (string, int64, error) { ctx := c.Context() endpoint := strings.TrimRight(ctx.URL, "/") + "/criteria/auth" @@ -80,32 +107,29 @@ func (c *Client) ensureToken() (string, error) { }, ) if err != nil { - return "", newRemoteAuthError("[auth] remote unavailable") + return "", 0, newRemoteAuthError("[auth] remote unavailable") } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { - return "", newRemoteAuthError("invalid API key") + return "", 0, newRemoteAuthError("invalid API key") } var payload authResponse decoder := json.NewDecoder(response.Body) decoder.UseNumber() if err := decoder.Decode(&payload); err != nil { - return "", err + return "", 0, err } + exp := parseTokenExpiration(payload.Exp) if payload.Token == nil { - c.authToken = "" - c.authTokenExp = parseTokenExpiration(payload.Exp) - return "", nil + return "", exp, nil } - c.authToken = *payload.Token - c.authTokenExp = parseTokenExpiration(payload.Exp) - return c.authToken, nil + return *payload.Token, exp, nil } func (c *Client) checkCriteria(token string, switcher *Switcher, showDetails bool) (ResultDetail, error) { diff --git a/remote_test.go b/remote_test.go index 927991d..17b467d 100644 --- a/remote_test.go +++ b/remote_test.go @@ -2,8 +2,10 @@ package client import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" + "sync/atomic" "testing" "time" @@ -408,6 +410,168 @@ func TestSwitcherRemoteEvaluation(t *testing.T) { }) } +func TestSwitcherRemoteAutoRenewToken(t *testing.T) { + t.Run("should proactively renew the token in the background before it expires when AutoRenewToken is enabled", func(t *testing.T) { + var authRequests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + count := authRequests.Add(1) + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "token": fmt.Sprintf("[token-%d]", count), + "exp": time.Now().Add(150 * time.Millisecond).Unix(), + }) + }) + mux.HandleFunc("/criteria", func(writer http.ResponseWriter, request *http.Request) { + writeJSONResponse(t, writer, http.StatusOK, map[string]any{"result": true}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + Remote: RemoteOptions{AutoRenewToken: true}, + }, + }) + + got, err := client.GetSwitcher("MY_SWITCHER").IsOn() + assert.NoError(t, err) + assert.True(t, got) + assert.Equal(t, int32(1), authRequests.Load()) + + assert.Eventually(t, func() bool { + return authRequests.Load() >= 2 + }, 2*time.Second, 25*time.Millisecond, "expected a background renewal without another foreground call") + }) + + t.Run("should not schedule background renewal when AutoRenewToken is disabled (default)", func(t *testing.T) { + var authRequests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + count := authRequests.Add(1) + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "token": fmt.Sprintf("[token-%d]", count), + "exp": time.Now().Add(150 * time.Millisecond).Unix(), + }) + }) + mux.HandleFunc("/criteria", func(writer http.ResponseWriter, request *http.Request) { + writeJSONResponse(t, writer, http.StatusOK, map[string]any{"result": true}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + client := newRemoteTestClient(server.URL) + + got, err := client.GetSwitcher("MY_SWITCHER").IsOn() + assert.NoError(t, err) + assert.True(t, got) + assert.Equal(t, int32(1), authRequests.Load()) + + // no background renewal should happen even after the token expires + time.Sleep(1200 * time.Millisecond) + assert.Equal(t, int32(1), authRequests.Load(), "expected no background auth request to occur") + + // existing lazy behavior: only refreshed on next foreground call + got, err = client.GetSwitcher("MY_SWITCHER").IsOn() + assert.NoError(t, err) + assert.True(t, got) + assert.Equal(t, int32(2), authRequests.Load()) + }) + + t.Run("should stop background renewal on failure and still succeed via lazy re-auth on the next foreground call", func(t *testing.T) { + var authRequests atomic.Int32 + var failSecondAuthRequest atomic.Bool + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + count := authRequests.Add(1) + if count == 2 && failSecondAuthRequest.Load() { + writer.WriteHeader(http.StatusInternalServerError) + return + } + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "token": fmt.Sprintf("[token-%d]", count), + "exp": time.Now().Add(150 * time.Millisecond).Unix(), + }) + }) + mux.HandleFunc("/criteria", func(writer http.ResponseWriter, request *http.Request) { + writeJSONResponse(t, writer, http.StatusOK, map[string]any{"result": true}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + failSecondAuthRequest.Store(true) + + client := NewClient(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + Remote: RemoteOptions{AutoRenewToken: true}, + }, + }) + + got, err := client.GetSwitcher("MY_SWITCHER").IsOn() + assert.NoError(t, err) + assert.True(t, got) + + // wait for the background renewal attempt to fire and fail + assert.Eventually(t, func() bool { + return authRequests.Load() >= 2 + }, 2*time.Second, 25*time.Millisecond, "expected a background renewal attempt to occur") + + // give the failed renewal time to stop the auto-renewer, then allow future auths to succeed + time.Sleep(100 * time.Millisecond) + failSecondAuthRequest.Store(false) + + // the client should still work, lazily re-authenticating on the next foreground call + got, err = client.GetSwitcher("MY_SWITCHER").IsOn() + assert.NoError(t, err) + assert.True(t, got) + }) + + t.Run("should stop pending renewal when the client is replaced via BuildContext", func(t *testing.T) { + var authRequests atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/criteria/auth", func(writer http.ResponseWriter, request *http.Request) { + count := authRequests.Add(1) + writeJSONResponse(t, writer, http.StatusOK, map[string]any{ + "token": fmt.Sprintf("[token-%d]", count), + "exp": time.Now().Add(150 * time.Millisecond).Unix(), + }) + }) + mux.HandleFunc("/criteria", func(writer http.ResponseWriter, request *http.Request) { + writeJSONResponse(t, writer, http.StatusOK, map[string]any{"result": true}) + }) + server := httptest.NewServer(mux) + defer server.Close() + + BuildContext(Context{ + Domain: "My Domain", + URL: server.URL, + APIKey: "[YOUR_API_KEY]", + Component: "MyApp", + Options: ContextOptions{ + Remote: RemoteOptions{AutoRenewToken: true}, + }, + }) + + got, err := GetSwitcher("MY_SWITCHER").IsOn() + assert.NoError(t, err) + assert.True(t, got) + assert.Equal(t, int32(1), authRequests.Load()) + + // replacing the global client should stop the pending renewal on the old client + BuildContext(Context{Domain: "Other Domain"}) + + time.Sleep(1200 * time.Millisecond) + assert.Equal(t, int32(1), authRequests.Load(), "expected no further auth requests against the replaced client's server") + }) +} + type remoteTestHandlers struct { authStatus int authBody map[string]any