mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-11-12 21:44:22 +08:00
feat(updater): throttle auto-update checks with exponential backoff
Check GitHub releases at most every 6 hours and retry transient failures (release query, checksum or binary download) with exponential backoff from 1 minute up to 3 hours. Forced updates bypass the throttle.
This commit is contained in:
@@ -24,6 +24,7 @@ sidebar: false
|
||||
### 优化
|
||||
|
||||
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,仅加载当前页的节点与最新下发记录;详情页改为单节点查询,减少大规模节点列表的轮询和渲染开销,并保留旧列表接口兼容现有集成;分页控件支持中英文及俄语,新增注册和列表错误按界面语言展示。搜索保留 Unicode 原始字符匹配,切页等待时保持已加载数据并禁用旧节点操作,避免页面跳动及错误操作。搜索框按接口的 UTF-8 字节上限校验输入,超限时显示可翻译提示并阻止发送无效查询。
|
||||
- Agent、Relay 与 Flared 的自动更新检查改为每 6 小时一次,查询 GitHub 或下载更新包失败时按 1 分钟起翻倍退避重试(最长 3 小时),避免频繁请求触发 GitHub API 限流;手动触发的立即更新不受限制。
|
||||
|
||||
|
||||
### 🛠 修复
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# AutoUpdate 6-Hour Interval and Exponential Backoff Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Throttle agent/edge auto-update checks to once every 6 hours, and apply exponential backoff on failure (doubling up to a maximum of 3 hours) to prevent exhausting GitHub API rate limits.
|
||||
|
||||
**Architecture:** Add check interval and exponential backoff state tracking to `edgeupdater.Service` protected by a mutex, with clock injection (`NowFunc`) for deterministic testing. Expose `CanCheck(options UpdateOptions) bool` and integrate it into both `CheckAndUpdate` and `edgeheartbeat.TryAutoUpdate` so unthrottled checks and noisy logs are eliminated during periodic heartbeat loops.
|
||||
|
||||
**Tech Stack:** Go (1.25), standard library (`sync`, `time`, `net/http`, `log/slog`).
|
||||
|
||||
## Global Constraints
|
||||
- Normal update check interval: 6 hours (`6 * time.Hour`).
|
||||
- Initial backoff on failure: 1 minute (`1 * time.Minute`), doubling on each consecutive failure.
|
||||
- Maximum retry backoff: 3 hours (`3 * time.Hour`).
|
||||
- Success resets failure count and backoff, and sets next check to `now + 6h`.
|
||||
- Force check (`UpdateOptions.Force = true`) must bypass backoff and interval immediately.
|
||||
- TDD required: write failing tests first, watch them fail, implement minimal code, verify pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Interval and Exponential Backoff to `edgeupdater.Service`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/edge/updater/service.go`
|
||||
- Test: `internal/apps/edge/updater/service_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `UpdateOptions{Channel, TagName, Force}`
|
||||
- Produces: `(s *Service) CanCheck(options UpdateOptions) bool`, `(s *Service) CheckAndUpdate(...) error` with throttling and exponential backoff
|
||||
|
||||
- [x] **Step 1: Write the failing tests in `service_test.go`**
|
||||
|
||||
Add tests covering:
|
||||
- `TestCheckAndUpdateIntervalAndBackoff`:
|
||||
1. Initial state allows check.
|
||||
2. Successful check sets next check time 6 hours later. Calls before 6 hours return false on `CanCheck` and are skipped in `CheckAndUpdate`.
|
||||
3. After 6 hours, check is allowed again.
|
||||
4. Failed check (e.g. mock server returns 403) schedules next check 1 minute later.
|
||||
5. Second failed check schedules next check 2 minutes later.
|
||||
6. Subsequent failures double up to 3 hours (capped at 3 hours).
|
||||
7. Force check (`Force: true`) always succeeds in running regardless of cooldown/backoff.
|
||||
8. Success after failure resets backoff to initial and schedules next check in 6 hours.
|
||||
|
||||
- [x] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test -v ./internal/apps/edge/updater -run TestCheckAndUpdateIntervalAndBackoff`
|
||||
Expected: FAIL (fields or methods missing or behavior not implemented).
|
||||
|
||||
- [x] **Step 3: Write minimal implementation in `service.go`**
|
||||
|
||||
1. Define constants:
|
||||
```go
|
||||
const (
|
||||
DefaultCheckInterval = 6 * time.Hour
|
||||
DefaultInitialBackoff = 1 * time.Minute
|
||||
DefaultMaxBackoff = 3 * time.Hour
|
||||
)
|
||||
```
|
||||
|
||||
2. Extend `Config`:
|
||||
```go
|
||||
type Config struct {
|
||||
LocalVersion string
|
||||
AssetPrefix string
|
||||
LogLabel string
|
||||
CheckInterval time.Duration
|
||||
InitialBackoff time.Duration
|
||||
MaxBackoff time.Duration
|
||||
NowFunc func() time.Time
|
||||
}
|
||||
```
|
||||
|
||||
3. Extend `Service` struct with:
|
||||
```go
|
||||
type Service struct {
|
||||
httpClient *http.Client
|
||||
lastCheckKey string
|
||||
localVersion string
|
||||
assetPrefix string
|
||||
logLabel string
|
||||
checkInterval time.Duration
|
||||
initialBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
nowFunc func() time.Time
|
||||
mu sync.Mutex
|
||||
nextCheckTime time.Time
|
||||
currentBackoff time.Duration
|
||||
consecutiveFailures int
|
||||
}
|
||||
```
|
||||
|
||||
4. Implement `CanCheck(options UpdateOptions) bool`:
|
||||
```go
|
||||
func (s *Service) CanCheck(options UpdateOptions) bool {
|
||||
if options.Force {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.nextCheckTime.IsZero() {
|
||||
return true
|
||||
}
|
||||
now := s.now()
|
||||
return !now.Before(s.nextCheckTime)
|
||||
}
|
||||
```
|
||||
|
||||
5. In `CheckAndUpdate`:
|
||||
- Check `CanCheck(options)`. If false, return `nil`.
|
||||
- On `getRelease` error: calculate next backoff (initial backoff on 1st failure, double previous backoff capped at maxBackoff on subsequent), increment `consecutiveFailures`, set `nextCheckTime = now.Add(currentBackoff)`, return error.
|
||||
- On `getRelease` success: reset `consecutiveFailures = 0`, reset `currentBackoff = initialBackoff`, set `nextCheckTime = now.Add(checkInterval)`.
|
||||
|
||||
- [x] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test -v ./internal/apps/edge/updater/...`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Integrate `CanCheck` in `edgeheartbeat.TryAutoUpdate`
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/apps/edge/heartbeat/autoupdate.go`
|
||||
- Test: `internal/apps/edge/heartbeat/autoupdate_test.go`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `(s *Service) CanCheck(options UpdateOptions) bool`
|
||||
- Produces: `TryAutoUpdate(ctx context.Context, updater *edgeupdater.Service, settings *AutoUpdateSettings, logLabel string)`
|
||||
|
||||
- [x] **Step 1: Write the failing test in `autoupdate_test.go`**
|
||||
|
||||
Test that `TryAutoUpdate` checks `updater.CanCheck(opts)` and skips both log output and `CheckAndUpdate` call when throttled.
|
||||
|
||||
- [x] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test -v ./internal/apps/edge/heartbeat/...`
|
||||
Expected: FAIL.
|
||||
|
||||
- [x] **Step 3: Update `autoupdate.go`**
|
||||
|
||||
Update `TryAutoUpdate` to evaluate `updater.CanCheck(opts)` before logging `checking for updates` and executing `CheckAndUpdate`.
|
||||
|
||||
- [x] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test -v ./internal/apps/edge/heartbeat/...`
|
||||
Expected: PASS.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Full Verification of Edge and Agent Packages
|
||||
|
||||
**Files:**
|
||||
- Test: All tests in `internal/apps/edge/...` and `internal/apps/agent/...`
|
||||
|
||||
- [x] **Step 1: Run comprehensive tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
go test -v ./internal/apps/edge/... ./internal/apps/agent/...
|
||||
```
|
||||
Expected: PASS with zero failures or race conditions.
|
||||
@@ -35,12 +35,16 @@ func TryAutoUpdate(ctx context.Context, updater *edgeupdater.Service, settings *
|
||||
if force && strings.TrimSpace(settings.UpdateChannel) != "" {
|
||||
channel = settings.UpdateChannel
|
||||
}
|
||||
slog.Info("checking for "+logLabel+" updates", "repo", settings.UpdateRepo, "channel", channel, "force", force)
|
||||
err := updater.CheckAndUpdate(ctx, settings.UpdateRepo, edgeupdater.UpdateOptions{
|
||||
opts := edgeupdater.UpdateOptions{
|
||||
Channel: channel,
|
||||
TagName: settings.UpdateTag,
|
||||
Force: force,
|
||||
})
|
||||
}
|
||||
if !updater.CanCheck(opts) {
|
||||
return
|
||||
}
|
||||
slog.Info("checking for "+logLabel+" updates", "repo", settings.UpdateRepo, "channel", channel, "force", force)
|
||||
err := updater.CheckAndUpdate(ctx, settings.UpdateRepo, opts)
|
||||
if err != nil {
|
||||
slog.Error(logLabel+" update check failed", "error", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
edgeupdater "github.com/Rain-kl/Wavelet/internal/apps/edge/updater"
|
||||
)
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func TestTryAutoUpdateThrottling(t *testing.T) {
|
||||
currentTime := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
requestCount := 0
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.0"}`)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
updater := edgeupdater.New(edgeupdater.Config{
|
||||
LocalVersion: "v1.0.0",
|
||||
AssetPrefix: "openflare-agent",
|
||||
LogLabel: "agent",
|
||||
CheckInterval: 6 * time.Hour,
|
||||
InitialBackoff: 1 * time.Minute,
|
||||
MaxBackoff: 3 * time.Hour,
|
||||
NowFunc: func() time.Time { return currentTime },
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
|
||||
settings := &AutoUpdateSettings{
|
||||
AutoUpdate: true,
|
||||
UpdateRepo: "Rain-kl/OpenFlare",
|
||||
}
|
||||
|
||||
// 1. Initial call should be permitted
|
||||
opts := edgeupdater.UpdateOptions{Channel: "stable", Force: false}
|
||||
if !updater.CanCheck(opts) {
|
||||
t.Fatalf("expected CanCheck to be true initially")
|
||||
}
|
||||
|
||||
// Run first check manually or via TryAutoUpdate
|
||||
TryAutoUpdate(context.Background(), updater, settings, "agent")
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("expected 1 request after first check, got %d", requestCount)
|
||||
}
|
||||
|
||||
// 2. Immediately after, CanCheck is false (throttled for 6 hours)
|
||||
if updater.CanCheck(opts) {
|
||||
t.Fatalf("expected CanCheck to be false after check")
|
||||
}
|
||||
|
||||
// Capture slog output to verify that throttled calls do NOT emit "checking for agent updates"
|
||||
var logBuf strings.Builder
|
||||
prevLogger := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuf, nil)))
|
||||
defer slog.SetDefault(prevLogger)
|
||||
|
||||
// Calling TryAutoUpdate again while throttled should NOT perform any checks or log
|
||||
TryAutoUpdate(context.Background(), updater, settings, "agent")
|
||||
if strings.Contains(logBuf.String(), "checking for agent updates") {
|
||||
t.Fatalf("expected no log message when throttled, got: %s", logBuf.String())
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("expected no request when throttled, got %d requests", requestCount)
|
||||
}
|
||||
|
||||
// 3. Force check should still log and check even when regular check is throttled
|
||||
forceSettings := &AutoUpdateSettings{
|
||||
AutoUpdate: true,
|
||||
UpdateNow: true,
|
||||
UpdateRepo: "Rain-kl/OpenFlare",
|
||||
}
|
||||
logBuf.Reset()
|
||||
TryAutoUpdate(context.Background(), updater, forceSettings, "agent")
|
||||
if !strings.Contains(logBuf.String(), "checking for agent updates") {
|
||||
t.Fatalf("expected log message for force check, got: %s", logBuf.String())
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Fatalf("expected force check to send a request, got %d requests", requestCount)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/util"
|
||||
@@ -27,34 +28,107 @@ const (
|
||||
goosWindows = "windows"
|
||||
updateTmpFilePerm = 0o600
|
||||
updateBinaryFilePerm = 0o755
|
||||
|
||||
// DefaultCheckInterval is the default interval between regular update checks.
|
||||
DefaultCheckInterval = 6 * time.Hour
|
||||
// DefaultInitialBackoff is the default delay before retrying after the first failed check.
|
||||
DefaultInitialBackoff = 1 * time.Minute
|
||||
// DefaultMaxBackoff caps the exponential retry delay after consecutive failures.
|
||||
DefaultMaxBackoff = 3 * time.Hour
|
||||
)
|
||||
|
||||
var replaceAndRestartFunc = replaceAndRestart
|
||||
|
||||
// Config defines the configuration for the update service.
|
||||
type Config struct {
|
||||
LocalVersion string
|
||||
AssetPrefix string
|
||||
LogLabel string
|
||||
LocalVersion string
|
||||
AssetPrefix string
|
||||
LogLabel string
|
||||
CheckInterval time.Duration
|
||||
InitialBackoff time.Duration
|
||||
MaxBackoff time.Duration
|
||||
NowFunc func() time.Time
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Service handles checking and applying application binary updates.
|
||||
type Service struct {
|
||||
httpClient *http.Client
|
||||
lastCheckKey string
|
||||
localVersion string
|
||||
assetPrefix string
|
||||
logLabel string
|
||||
httpClient *http.Client
|
||||
lastCheckKey string
|
||||
localVersion string
|
||||
assetPrefix string
|
||||
logLabel string
|
||||
checkInterval time.Duration
|
||||
initialBackoff time.Duration
|
||||
maxBackoff time.Duration
|
||||
nowFunc func() time.Time
|
||||
mu sync.Mutex
|
||||
nextCheckTime time.Time
|
||||
consecutiveFailures int
|
||||
}
|
||||
|
||||
// New creates a new updater Service with the provided configuration.
|
||||
func New(cfg Config) *Service {
|
||||
return &Service{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
localVersion: cfg.LocalVersion,
|
||||
assetPrefix: cfg.AssetPrefix,
|
||||
logLabel: cfg.LogLabel,
|
||||
checkInterval := cfg.CheckInterval
|
||||
if checkInterval <= 0 {
|
||||
checkInterval = DefaultCheckInterval
|
||||
}
|
||||
initialBackoff := cfg.InitialBackoff
|
||||
if initialBackoff <= 0 {
|
||||
initialBackoff = DefaultInitialBackoff
|
||||
}
|
||||
maxBackoff := cfg.MaxBackoff
|
||||
if maxBackoff <= 0 {
|
||||
maxBackoff = DefaultMaxBackoff
|
||||
}
|
||||
nowFunc := cfg.NowFunc
|
||||
if nowFunc == nil {
|
||||
nowFunc = time.Now
|
||||
}
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
return &Service{
|
||||
httpClient: httpClient,
|
||||
localVersion: cfg.LocalVersion,
|
||||
assetPrefix: cfg.AssetPrefix,
|
||||
logLabel: cfg.LogLabel,
|
||||
checkInterval: checkInterval,
|
||||
initialBackoff: initialBackoff,
|
||||
maxBackoff: maxBackoff,
|
||||
nowFunc: nowFunc,
|
||||
}
|
||||
}
|
||||
|
||||
// CanCheck returns whether an update check is allowed to run under the check interval and failure backoff.
|
||||
func (s *Service) CanCheck(options UpdateOptions) bool {
|
||||
if options.Force {
|
||||
return true
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return !s.nowFunc().Before(s.nextCheckTime)
|
||||
}
|
||||
|
||||
// recordFailure schedules the next check using exponential backoff capped at maxBackoff.
|
||||
func (s *Service) recordFailure() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
backoff := s.initialBackoff
|
||||
for i := 0; i < s.consecutiveFailures && backoff < s.maxBackoff; i++ {
|
||||
backoff *= 2
|
||||
}
|
||||
s.consecutiveFailures++
|
||||
s.nextCheckTime = s.nowFunc().Add(min(backoff, s.maxBackoff))
|
||||
}
|
||||
|
||||
// recordSuccess resets the failure backoff and schedules the next regular check.
|
||||
func (s *Service) recordSuccess() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.consecutiveFailures = 0
|
||||
s.nextCheckTime = s.nowFunc().Add(s.checkInterval)
|
||||
}
|
||||
|
||||
// UpdateOptions specifies parameters for checking and applying updates.
|
||||
@@ -78,13 +152,29 @@ type githubAsset struct {
|
||||
}
|
||||
|
||||
// CheckAndUpdate checks for a newer release on GitHub and performs an update if available.
|
||||
// Transient failures are retried with exponential backoff; otherwise the next check waits for the check interval.
|
||||
// Callers are expected to invoke it serially.
|
||||
func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options UpdateOptions) error {
|
||||
if !s.CanCheck(options) {
|
||||
return nil
|
||||
}
|
||||
retry, err := s.checkAndUpdate(ctx, repo, options)
|
||||
if retry {
|
||||
s.recordFailure()
|
||||
} else {
|
||||
s.recordSuccess()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// checkAndUpdate performs one update check and reports whether a returned error is transient and worth retrying.
|
||||
func (s *Service) checkAndUpdate(ctx context.Context, repo string, options UpdateOptions) (retry bool, err error) {
|
||||
release, err := s.getRelease(ctx, repo, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check latest release: %w", err)
|
||||
return true, fmt.Errorf("check latest release: %w", err)
|
||||
}
|
||||
if release == nil || release.TagName == "" {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
remoteVersion := normalizeVersion(release.TagName)
|
||||
@@ -92,14 +182,14 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options Updat
|
||||
checkKey := buildReleaseCheckKey(options, remoteVersion)
|
||||
|
||||
if remoteVersion == localVersion {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
if !options.Force && checkKey != "" && checkKey == s.lastCheckKey {
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
if !isNewer(localVersion, remoteVersion) {
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
slog.Info(s.logLabel+" update available", "from", localVersion, "to", remoteVersion)
|
||||
@@ -108,20 +198,22 @@ func (s *Service) CheckAndUpdate(ctx context.Context, repo string, options Updat
|
||||
downloadURL, expectedChecksum, err := s.resolveReleaseAsset(ctx, release, assetName)
|
||||
if err != nil {
|
||||
if downloadURL == "" {
|
||||
// The release has no asset for this platform; wait for the next regular check.
|
||||
s.lastCheckKey = checkKey
|
||||
return false, err
|
||||
}
|
||||
return err
|
||||
return true, err
|
||||
}
|
||||
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get executable path: %w", err)
|
||||
return false, fmt.Errorf("get executable path: %w", err)
|
||||
}
|
||||
if err = s.downloadAndRestart(ctx, downloadURL, expectedChecksum, execPath); err != nil {
|
||||
return fmt.Errorf("download and restart: %w", err)
|
||||
return true, fmt.Errorf("download and restart: %w", err)
|
||||
}
|
||||
s.lastCheckKey = checkKey
|
||||
return nil
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *Service) getRelease(ctx context.Context, repo string, options UpdateOptions) (*githubRelease, error) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
||||
@@ -23,12 +24,12 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
|
||||
func testService(httpClient *http.Client) *Service {
|
||||
return &Service{
|
||||
httpClient: httpClient,
|
||||
localVersion: "v1.0.0",
|
||||
assetPrefix: "openflare-agent",
|
||||
logLabel: "agent",
|
||||
}
|
||||
return New(Config{
|
||||
LocalVersion: "v1.0.0",
|
||||
AssetPrefix: "openflare-agent",
|
||||
LogLabel: "agent",
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetLatestPreviewRelease(t *testing.T) {
|
||||
@@ -106,6 +107,56 @@ func TestCheckAndUpdateRequiresChecksumSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndUpdateSchedulesNextCheckByFailureKind(t *testing.T) {
|
||||
assetName := testService(nil).assetNameForGOOSGOARCH(runtime.GOOS, runtime.GOARCH)
|
||||
digest := "sha256:" + strings.Repeat("a", sha256.Size*2)
|
||||
testCases := []struct {
|
||||
name string
|
||||
releaseAssetName string
|
||||
wantRetry bool
|
||||
}{
|
||||
{name: "asset download failure retries with backoff", releaseAssetName: assetName, wantRetry: true},
|
||||
{name: "missing platform asset waits for regular interval", releaseAssetName: "openflare-agent-other", wantRetry: false},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
currentTime := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
service := New(Config{
|
||||
LocalVersion: "v1.0.0",
|
||||
AssetPrefix: "openflare-agent",
|
||||
LogLabel: "agent",
|
||||
NowFunc: func() time.Time { return currentTime },
|
||||
HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Host != "api.github.com" {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Status: "500 Internal Server Error",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("error")),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"tag_name":"v1.0.1","assets":[` +
|
||||
`{"name":"` + tc.releaseAssetName + `","browser_download_url":"https://example.test/agent","digest":"` + digest + `"}]}`)),
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
if err := service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{}); err == nil {
|
||||
t.Fatalf("expected update error")
|
||||
}
|
||||
currentTime = currentTime.Add(DefaultInitialBackoff)
|
||||
if got := service.CanCheck(UpdateOptions{}); got != tc.wantRetry {
|
||||
t.Fatalf("CanCheck after initial backoff = %v, want %v", got, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGitHubDigest(t *testing.T) {
|
||||
checksum := strings.Repeat("a", sha256.Size*2)
|
||||
testCases := []struct {
|
||||
@@ -325,3 +376,188 @@ func TestIsNewerSupportsPrerelease(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckAndUpdateIntervalAndBackoff(t *testing.T) {
|
||||
currentTime := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
nowFunc := func() time.Time { return currentTime }
|
||||
|
||||
var (
|
||||
statusCode = http.StatusOK
|
||||
responseBody = `{"tag_name":"v1.0.0"}`
|
||||
requestCount = 0
|
||||
)
|
||||
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
if statusCode != http.StatusOK {
|
||||
return &http.Response{
|
||||
StatusCode: statusCode,
|
||||
Status: http.StatusText(statusCode),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("error")),
|
||||
}, nil
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(responseBody)),
|
||||
}, nil
|
||||
}),
|
||||
}
|
||||
|
||||
service := New(Config{
|
||||
LocalVersion: "v1.0.0",
|
||||
AssetPrefix: "openflare-agent",
|
||||
LogLabel: "agent",
|
||||
CheckInterval: 6 * time.Hour,
|
||||
InitialBackoff: 1 * time.Minute,
|
||||
MaxBackoff: 3 * time.Hour,
|
||||
NowFunc: nowFunc,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
|
||||
// 1. Initial state: CanCheck should be true
|
||||
if !service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be true initially")
|
||||
}
|
||||
|
||||
// 2. First check succeeds (version v1.0.0 is same as local, no update needed)
|
||||
err := service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on first check: %v", err)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("expected 1 request, got %d", requestCount)
|
||||
}
|
||||
|
||||
// Immediately after success: CanCheck should be false, and CheckAndUpdate should be throttled (no HTTP call)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false immediately after success")
|
||||
}
|
||||
err = service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on throttled check: %v", err)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("expected request count to remain 1, got %d", requestCount)
|
||||
}
|
||||
|
||||
// 3. Fast-forward 5 hours: still throttled (< 6h)
|
||||
currentTime = currentTime.Add(5 * time.Hour)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false after 5 hours")
|
||||
}
|
||||
|
||||
// Force check should bypass throttle even within 6 hours
|
||||
if !service.CanCheck(UpdateOptions{Force: true}) {
|
||||
t.Fatalf("expected CanCheck(Force: true) to be true within 6 hours")
|
||||
}
|
||||
err = service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error on force check: %v", err)
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Fatalf("expected request count 2 after force check, got %d", requestCount)
|
||||
}
|
||||
|
||||
// 4. Fast-forward to 6 hours after the force check: should be allowed again
|
||||
currentTime = currentTime.Add(6 * time.Hour)
|
||||
if !service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be true after 6 hours")
|
||||
}
|
||||
|
||||
// 5. Simulate Failure: GitHub returns 403 Forbidden
|
||||
statusCode = http.StatusForbidden
|
||||
err = service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on 403 Forbidden check")
|
||||
}
|
||||
if requestCount != 3 {
|
||||
t.Fatalf("expected request count 3, got %d", requestCount)
|
||||
}
|
||||
|
||||
// Immediately after 1st failure: CanCheck should be false (1 min initial backoff)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false after 1st failure")
|
||||
}
|
||||
// At +30s: still false
|
||||
currentTime = currentTime.Add(30 * time.Second)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false at +30s after 1st failure")
|
||||
}
|
||||
// At +1m (total +60s): should be true
|
||||
currentTime = currentTime.Add(30 * time.Second)
|
||||
if !service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be true at +1m after 1st failure")
|
||||
}
|
||||
|
||||
// 6. Second failure: backoff doubles to 2 minutes
|
||||
err = service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on 2nd failure")
|
||||
}
|
||||
if requestCount != 4 {
|
||||
t.Fatalf("expected request count 4, got %d", requestCount)
|
||||
}
|
||||
// At +1m: should still be false
|
||||
currentTime = currentTime.Add(1 * time.Minute)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false at +1m after 2nd failure (backoff is 2m)")
|
||||
}
|
||||
// At +2m: should be true
|
||||
currentTime = currentTime.Add(1 * time.Minute)
|
||||
if !service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be true at +2m after 2nd failure")
|
||||
}
|
||||
|
||||
// 7. Simulate multiple consecutive failures to verify exponential doubling and capping at 3 hours
|
||||
expectedBackoffs := []time.Duration{
|
||||
4 * time.Minute,
|
||||
8 * time.Minute,
|
||||
16 * time.Minute,
|
||||
32 * time.Minute,
|
||||
64 * time.Minute,
|
||||
128 * time.Minute,
|
||||
3 * time.Hour, // 256m capped at 180m (3h)
|
||||
3 * time.Hour, // capped at 3h
|
||||
}
|
||||
for i, expectedBackoff := range expectedBackoffs {
|
||||
err = service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error on consecutive failure iteration %d", i)
|
||||
}
|
||||
// Halfway through backoff: should be false
|
||||
currentTime = currentTime.Add(expectedBackoff / 2)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false halfway through backoff %v (iter %d)", expectedBackoff, i)
|
||||
}
|
||||
// At full backoff: should be true
|
||||
currentTime = currentTime.Add(expectedBackoff / 2)
|
||||
if !service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be true at full backoff %v (iter %d)", expectedBackoff, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Recovery: next check succeeds, backoff should be reset and next check scheduled in 6 hours
|
||||
statusCode = http.StatusOK
|
||||
err = service.CheckAndUpdate(context.Background(), "Rain-kl/OpenFlare", UpdateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("expected success on recovery, got: %v", err)
|
||||
}
|
||||
// Immediately: CanCheck is false
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false immediately after recovery")
|
||||
}
|
||||
// At +3 hours: still false (because next check is 6 hours, not 3 hours)
|
||||
currentTime = currentTime.Add(3 * time.Hour)
|
||||
if service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be false at +3 hours after recovery")
|
||||
}
|
||||
// At +6 hours: should be true
|
||||
currentTime = currentTime.Add(3 * time.Hour)
|
||||
if !service.CanCheck(UpdateOptions{}) {
|
||||
t.Fatalf("expected CanCheck to be true at +6 hours after recovery")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user