From aa3b04900019e018030e1fa909bc9a5e803a759e Mon Sep 17 00:00:00 2001 From: ASWJ <122414131+aishangwuji@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:55:46 +0800 Subject: [PATCH] fix(opencode): cap Retry-After hint at 24h for usage refresh backoff --- backend/internal/service/opencode_go_usage.go | 11 +++++++- .../service/opencode_go_usage_test.go | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go index f0408a0f30..bb7b7d7cdb 100644 --- a/backend/internal/service/opencode_go_usage.go +++ b/backend/internal/service/opencode_go_usage.go @@ -1000,8 +1000,17 @@ func decodeOpenCodeGoUsageSnapshot(extra map[string]any) *OpenCodeGoUsageSnapsho // nextOpenCodeGoUsageDelay computes the not-before delay for the next refresh: // interval * 2^min(failureCount-1, 6) capped at 24h, ±10% jitter capped at 5min, -// never below a Retry-After hint or one minute. +// never below a Retry-After hint or one minute. The hint is honored over the +// local backoff but clamped to the same 24h ceiling: it arrives over a network +// path we do not control (upstream CDN, account proxy), and an unbounded value +// would freeze a group's auto refresh indefinitely. func nextOpenCodeGoUsageDelay(intervalMinutes, failureCount int, retryAfterDuration time.Duration) time.Duration { + if retryAfterDuration < 0 { + retryAfterDuration = 0 + } + if retryAfterDuration > opencodeGoUsageMaxDelay { + retryAfterDuration = opencodeGoUsageMaxDelay + } minimumDelay := retryAfterDuration base := time.Duration(intervalMinutes) * time.Minute if base < opencodeGoUsageMinIntervalMinutes*time.Minute { diff --git a/backend/internal/service/opencode_go_usage_test.go b/backend/internal/service/opencode_go_usage_test.go index 91b9047042..2afeb156c6 100644 --- a/backend/internal/service/opencode_go_usage_test.go +++ b/backend/internal/service/opencode_go_usage_test.go @@ -403,11 +403,39 @@ func TestNextOpenCodeGoUsageDelayBackoff(t *testing.T) { delay = nextOpenCodeGoUsageDelay(15, 0, 2*time.Hour) require.GreaterOrEqual(t, delay, 2*time.Hour) + // a hint beyond the 24h ceiling is clamped, never adopted verbatim + delay = nextOpenCodeGoUsageDelay(15, 0, 48*time.Hour) + require.GreaterOrEqual(t, delay, 23*time.Hour+55*time.Minute) + require.LessOrEqual(t, delay, 24*time.Hour+5*time.Minute) + // floor of one minute delay = nextOpenCodeGoUsageDelay(5, 0, 0) require.GreaterOrEqual(t, delay, time.Minute) } +func TestOpenCodeGoUsageRefreshClampsHugeRetryAfter(t *testing.T) { + now := time.Now().UTC() + account := openCodeGoUsageAccount(7) + repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}} + stub := &openCodeGoUsageHTTPStub{ + status: http.StatusTooManyRequests, + header: http.Header{"Retry-After": []string{"99999999"}}, + } + svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{}) + svc.now = func() time.Time { return now } + + state, err := svc.Refresh(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status) + require.Equal(t, http.StatusTooManyRequests, state.Snapshot.HTTPStatus) + require.Equal(t, "http_error", state.Snapshot.LastError) + + maxExpected := now.Add(24*time.Hour + 5*time.Minute) + minExpected := now.Add(23*time.Hour + 55*time.Minute) + require.True(t, state.Snapshot.NextRefreshAt.Before(maxExpected.Add(time.Second))) + require.True(t, state.Snapshot.NextRefreshAt.After(minExpected.Add(-time.Second))) +} + func TestOpenCodeGoUsageManualRefreshThrottle(t *testing.T) { now := time.Now().UTC() account := openCodeGoUsageAccount(7)