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
16 changes: 8 additions & 8 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand All @@ -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 != ''
Expand All @@ -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 }}

Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/sonar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand All @@ -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 != ''
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}

Expand Down
402 changes: 402 additions & 0 deletions ARCHITECTURE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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` |

Expand Down
3 changes: 3 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type Client struct {
authToken string
authTokenExp int64

autoRenewer *tokenAutoRenewer

httpClientMu sync.Mutex
httpClient_ *http.Client

Expand All @@ -55,6 +57,7 @@ func NewClient(ctx Context) *Client {
throttleTokens: newThrottleTokens(defaulted.Options.ThrottleMaxWorkers),
snapshotWatcher: newSnapshotWatcher(),
snapshotAutoUpdater: newSnapshotAutoUpdater(),
autoRenewer: newTokenAutoRenewer(),
}
}

Expand Down
119 changes: 119 additions & 0 deletions client_auto_renew.go
Original file line number Diff line number Diff line change
@@ -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
}
137 changes: 137 additions & 0 deletions client_auto_renew_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
2 changes: 2 additions & 0 deletions client_silent_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ func (c *Client) authState() (string, int64) {
}

func (c *Client) updateSilentToken() {
c.autoRenewer.stop()

c.authMu.Lock()
defer c.authMu.Unlock()

Expand Down
Loading