Skip to content

Commit 9a88df7

Browse files
authored
Merge pull request Wei-Shaw#1167 from touwaeriol/pr/proxy-fast-fail
fix(antigravity): fast-fail on proxy unavailable, temp-unschedule account
2 parents a47f622 + 528ff5d commit 9a88df7

10 files changed

Lines changed: 125 additions & 20 deletions

File tree

backend/cmd/server/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/pkg/antigravity/client.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,31 @@ type Client struct {
228228
httpClient *http.Client
229229
}
230230

231+
const (
232+
// proxyDialTimeout 代理 TCP 连接超时(含代理握手),代理不通时快速失败
233+
proxyDialTimeout = 5 * time.Second
234+
// proxyTLSHandshakeTimeout 代理 TLS 握手超时
235+
proxyTLSHandshakeTimeout = 5 * time.Second
236+
// clientTimeout 整体请求超时(含连接、发送、等待响应、读取 body)
237+
clientTimeout = 10 * time.Second
238+
)
239+
231240
func NewClient(proxyURL string) (*Client, error) {
232241
client := &http.Client{
233-
Timeout: 30 * time.Second,
242+
Timeout: clientTimeout,
234243
}
235244

236245
_, parsed, err := proxyurl.Parse(proxyURL)
237246
if err != nil {
238247
return nil, err
239248
}
240249
if parsed != nil {
241-
transport := &http.Transport{}
250+
transport := &http.Transport{
251+
DialContext: (&net.Dialer{
252+
Timeout: proxyDialTimeout,
253+
}).DialContext,
254+
TLSHandshakeTimeout: proxyTLSHandshakeTimeout,
255+
}
242256
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
243257
return nil, fmt.Errorf("configure proxy: %w", err)
244258
}
@@ -250,8 +264,8 @@ func NewClient(proxyURL string) (*Client, error) {
250264
}, nil
251265
}
252266

253-
// isConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝)
254-
func isConnectionError(err error) bool {
267+
// IsConnectionError 判断是否为连接错误(网络超时、DNS 失败、连接拒绝)
268+
func IsConnectionError(err error) bool {
255269
if err == nil {
256270
return false
257271
}
@@ -276,7 +290,7 @@ func isConnectionError(err error) bool {
276290
// shouldFallbackToNextURL 判断是否应切换到下一个 URL
277291
// 与 Antigravity-Manager 保持一致:连接错误、429、408、404、5xx 触发 URL 降级
278292
func shouldFallbackToNextURL(err error, statusCode int) bool {
279-
if isConnectionError(err) {
293+
if IsConnectionError(err) {
280294
return true
281295
}
282296
return statusCode == http.StatusTooManyRequests ||

backend/internal/pkg/antigravity/client_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,8 @@ func TestNewClient_无代理(t *testing.T) {
274274
if client.httpClient == nil {
275275
t.Fatal("httpClient 为 nil")
276276
}
277-
if client.httpClient.Timeout != 30*time.Second {
278-
t.Errorf("Timeout 不匹配: got %v, want 30s", client.httpClient.Timeout)
277+
if client.httpClient.Timeout != clientTimeout {
278+
t.Errorf("Timeout 不匹配: got %v, want %v", client.httpClient.Timeout, clientTimeout)
279279
}
280280
// 无代理时 Transport 应为 nil(使用默认)
281281
if client.httpClient.Transport != nil {
@@ -322,11 +322,11 @@ func TestNewClient_无效代理URL(t *testing.T) {
322322
}
323323

324324
// ---------------------------------------------------------------------------
325-
// isConnectionError
325+
// IsConnectionError
326326
// ---------------------------------------------------------------------------
327327

328328
func TestIsConnectionError_nil(t *testing.T) {
329-
if isConnectionError(nil) {
329+
if IsConnectionError(nil) {
330330
t.Error("nil 错误不应判定为连接错误")
331331
}
332332
}
@@ -338,7 +338,7 @@ func TestIsConnectionError_超时错误(t *testing.T) {
338338
Net: "tcp",
339339
Err: &timeoutError{},
340340
}
341-
if !isConnectionError(err) {
341+
if !IsConnectionError(err) {
342342
t.Error("超时错误应判定为连接错误")
343343
}
344344
}
@@ -356,7 +356,7 @@ func TestIsConnectionError_netOpError(t *testing.T) {
356356
Net: "tcp",
357357
Err: fmt.Errorf("connection refused"),
358358
}
359-
if !isConnectionError(err) {
359+
if !IsConnectionError(err) {
360360
t.Error("net.OpError 应判定为连接错误")
361361
}
362362
}
@@ -367,14 +367,14 @@ func TestIsConnectionError_urlError(t *testing.T) {
367367
URL: "https://example.com",
368368
Err: fmt.Errorf("some error"),
369369
}
370-
if !isConnectionError(err) {
370+
if !IsConnectionError(err) {
371371
t.Error("url.Error 应判定为连接错误")
372372
}
373373
}
374374

375375
func TestIsConnectionError_普通错误(t *testing.T) {
376376
err := fmt.Errorf("some random error")
377-
if isConnectionError(err) {
377+
if IsConnectionError(err) {
378378
t.Error("普通错误不应判定为连接错误")
379379
}
380380
}
@@ -386,7 +386,7 @@ func TestIsConnectionError_包装的netOpError(t *testing.T) {
386386
Err: fmt.Errorf("connection refused"),
387387
}
388388
err := fmt.Errorf("wrapping: %w", inner)
389-
if !isConnectionError(err) {
389+
if !IsConnectionError(err) {
390390
t.Error("被包装的 net.OpError 应判定为连接错误")
391391
}
392392
}

backend/internal/pkg/httpclient/pool.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package httpclient
1717

1818
import (
1919
"fmt"
20+
"net"
2021
"net/http"
2122
"strings"
2223
"sync"
@@ -32,6 +33,8 @@ const (
3233
defaultMaxIdleConns = 100 // 最大空闲连接数
3334
defaultMaxIdleConnsPerHost = 10 // 每个主机最大空闲连接数
3435
defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间(建议小于上游 LB 超时)
36+
defaultDialTimeout = 5 * time.Second // TCP 连接超时(含代理握手),代理不通时快速失败
37+
defaultTLSHandshakeTimeout = 5 * time.Second // TLS 握手超时
3538
validatedHostTTL = 30 * time.Second // DNS Rebinding 校验缓存 TTL
3639
)
3740

@@ -107,6 +110,10 @@ func buildTransport(opts Options) (*http.Transport, error) {
107110
}
108111

109112
transport := &http.Transport{
113+
DialContext: (&net.Dialer{
114+
Timeout: defaultDialTimeout,
115+
}).DialContext,
116+
TLSHandshakeTimeout: defaultTLSHandshakeTimeout,
110117
MaxIdleConns: maxIdleConns,
111118
MaxIdleConnsPerHost: maxIdleConnsPerHost,
112119
MaxConnsPerHost: opts.MaxConnsPerHost, // 0 表示无限制

backend/internal/repository/proxy_probe_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func NewProxyExitInfoProber(cfg *config.Config) service.ProxyExitInfoProber {
4040
}
4141

4242
const (
43-
defaultProxyProbeTimeout = 30 * time.Second
43+
defaultProxyProbeTimeout = 10 * time.Second
4444
defaultProxyProbeResponseMaxBytes = int64(1024 * 1024)
4545
)
4646

backend/internal/service/antigravity_gateway_service.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,7 +1359,10 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
13591359
}
13601360
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
13611361
if err != nil {
1362-
return nil, s.writeClaudeError(c, http.StatusBadGateway, "authentication_error", "Failed to get upstream access token")
1362+
return nil, &UpstreamFailoverError{
1363+
StatusCode: http.StatusBadGateway,
1364+
ResponseBody: []byte(`{"error":{"type":"authentication_error","message":"Failed to get upstream access token"},"type":"error"}`),
1365+
}
13631366
}
13641367

13651368
// 获取 project_id(部分账户类型可能没有)
@@ -2101,7 +2104,10 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
21012104
}
21022105
accessToken, err := s.tokenProvider.GetAccessToken(ctx, account)
21032106
if err != nil {
2104-
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Failed to get upstream access token")
2107+
return nil, &UpstreamFailoverError{
2108+
StatusCode: http.StatusBadGateway,
2109+
ResponseBody: []byte(`{"error":{"message":"Failed to get upstream access token","status":"UNAVAILABLE"}}`),
2110+
}
21052111
}
21062112

21072113
// 获取 project_id(部分账户类型可能没有)

backend/internal/service/antigravity_oauth_service.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,10 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
192192
if isNonRetryableAntigravityOAuthError(err) {
193193
return nil, err
194194
}
195+
// 代理连接错误(TCP 超时、连接拒绝、DNS 失败)不重试,立即返回
196+
if antigravity.IsConnectionError(err) {
197+
return nil, fmt.Errorf("proxy unavailable: %w", err)
198+
}
195199
lastErr = err
196200
}
197201

backend/internal/service/antigravity_token_provider.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ const (
1414
antigravityTokenRefreshSkew = 3 * time.Minute
1515
antigravityTokenCacheSkew = 5 * time.Minute
1616
antigravityBackfillCooldown = 5 * time.Minute
17+
// antigravityRequestRefreshTimeout 请求路径上 token 刷新的最大等待时间。
18+
// 超过此时间直接放弃刷新、标记账号临时不可调度并触发 failover,
19+
// 让后台 TokenRefreshService 在下个周期继续重试。
20+
antigravityRequestRefreshTimeout = 8 * time.Second
1721
)
1822

1923
// AntigravityTokenCache token cache interface.
@@ -28,6 +32,7 @@ type AntigravityTokenProvider struct {
2832
refreshAPI *OAuthRefreshAPI
2933
executor OAuthRefreshExecutor
3034
refreshPolicy ProviderRefreshPolicy
35+
tempUnschedCache TempUnschedCache // 用于同步更新 Redis 临时不可调度缓存
3136
}
3237

3338
func NewAntigravityTokenProvider(
@@ -54,6 +59,11 @@ func (p *AntigravityTokenProvider) SetRefreshPolicy(policy ProviderRefreshPolicy
5459
p.refreshPolicy = policy
5560
}
5661

62+
// SetTempUnschedCache injects temp unschedulable cache for immediate scheduler sync.
63+
func (p *AntigravityTokenProvider) SetTempUnschedCache(cache TempUnschedCache) {
64+
p.tempUnschedCache = cache
65+
}
66+
5767
// GetAccessToken returns a valid access_token.
5868
func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *Account) (string, error) {
5969
if account == nil {
@@ -88,8 +98,13 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
8898
expiresAt := account.GetCredentialAsTime("expires_at")
8999
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= antigravityTokenRefreshSkew
90100
if needsRefresh && p.refreshAPI != nil && p.executor != nil {
91-
result, err := p.refreshAPI.RefreshIfNeeded(ctx, account, p.executor, antigravityTokenRefreshSkew)
101+
// 请求路径使用短超时,避免代理不通时阻塞过久(后台刷新服务会继续重试)
102+
refreshCtx, cancel := context.WithTimeout(ctx, antigravityRequestRefreshTimeout)
103+
defer cancel()
104+
result, err := p.refreshAPI.RefreshIfNeeded(refreshCtx, account, p.executor, antigravityTokenRefreshSkew)
92105
if err != nil {
106+
// 标记账号临时不可调度,避免后续请求继续命中
107+
p.markTempUnschedulable(account, err)
93108
if p.refreshPolicy.OnRefreshError == ProviderRefreshErrorReturn {
94109
return "", err
95110
}
@@ -172,6 +187,45 @@ func (p *AntigravityTokenProvider) shouldAttemptBackfill(accountID int64) bool {
172187
return true
173188
}
174189

190+
// markTempUnschedulable 在请求路径上 token 刷新失败时标记账号临时不可调度。
191+
// 同时写 DB 和 Redis 缓存,确保调度器立即跳过该账号。
192+
// 使用 background context 因为请求 context 可能已超时。
193+
func (p *AntigravityTokenProvider) markTempUnschedulable(account *Account, refreshErr error) {
194+
if p.accountRepo == nil || account == nil {
195+
return
196+
}
197+
now := time.Now()
198+
until := now.Add(tokenRefreshTempUnschedDuration)
199+
reason := "token refresh failed on request path: " + refreshErr.Error()
200+
bgCtx := context.Background()
201+
if err := p.accountRepo.SetTempUnschedulable(bgCtx, account.ID, until, reason); err != nil {
202+
slog.Warn("antigravity_token_provider.set_temp_unschedulable_failed",
203+
"account_id", account.ID,
204+
"error", err,
205+
)
206+
return
207+
}
208+
slog.Warn("antigravity_token_provider.temp_unschedulable_set",
209+
"account_id", account.ID,
210+
"until", until.Format(time.RFC3339),
211+
"reason", reason,
212+
)
213+
// 同步写 Redis 缓存,调度器立即生效
214+
if p.tempUnschedCache != nil {
215+
state := &TempUnschedState{
216+
UntilUnix: until.Unix(),
217+
TriggeredAtUnix: now.Unix(),
218+
ErrorMessage: reason,
219+
}
220+
if err := p.tempUnschedCache.SetTempUnsched(bgCtx, account.ID, state); err != nil {
221+
slog.Warn("antigravity_token_provider.temp_unsched_cache_set_failed",
222+
"account_id", account.ID,
223+
"error", err,
224+
)
225+
}
226+
}
227+
}
228+
175229
func (p *AntigravityTokenProvider) markBackfillAttempted(accountID int64) {
176230
p.backfillCooldown.Store(accountID, time.Now())
177231
}

backend/internal/service/token_refresh_service.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import (
1212
"github.com/Wei-Shaw/sub2api/internal/config"
1313
)
1414

15+
// tokenRefreshTempUnschedDuration token 刷新重试耗尽后临时不可调度的持续时间
16+
const tokenRefreshTempUnschedDuration = 10 * time.Minute
17+
1518
// TokenRefreshService OAuth token自动刷新服务
1619
// 定期检查并刷新即将过期的token
1720
type TokenRefreshService struct {
@@ -317,14 +320,29 @@ func (s *TokenRefreshService) refreshWithRetry(ctx context.Context, account *Acc
317320
}
318321
}
319322

320-
// 可重试错误耗尽:仅记录日志,不标记 error(可能是临时网络问题,下个周期继续重试)
323+
// 可重试错误耗尽:临时标记账号不可调度,避免请求路径反复命中已知失败的账号
321324
slog.Warn("token_refresh.retry_exhausted",
322325
"account_id", account.ID,
323326
"platform", account.Platform,
324327
"max_retries", s.cfg.MaxRetries,
325328
"error", lastErr,
326329
)
327330

331+
// 设置临时不可调度 10 分钟(不标记 error,保持 status=active 让下个刷新周期能继续尝试)
332+
until := time.Now().Add(tokenRefreshTempUnschedDuration)
333+
reason := fmt.Sprintf("token refresh retry exhausted: %v", lastErr)
334+
if setErr := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); setErr != nil {
335+
slog.Warn("token_refresh.set_temp_unschedulable_failed",
336+
"account_id", account.ID,
337+
"error", setErr,
338+
)
339+
} else {
340+
slog.Info("token_refresh.temp_unschedulable_set",
341+
"account_id", account.ID,
342+
"until", until.Format(time.RFC3339),
343+
)
344+
}
345+
328346
return lastErr
329347
}
330348

backend/internal/service/wire.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,13 @@ func ProvideAntigravityTokenProvider(
114114
tokenCache GeminiTokenCache,
115115
antigravityOAuthService *AntigravityOAuthService,
116116
refreshAPI *OAuthRefreshAPI,
117+
tempUnschedCache TempUnschedCache,
117118
) *AntigravityTokenProvider {
118119
p := NewAntigravityTokenProvider(accountRepo, tokenCache, antigravityOAuthService)
119120
executor := NewAntigravityTokenRefresher(antigravityOAuthService)
120121
p.SetRefreshAPI(refreshAPI, executor)
121122
p.SetRefreshPolicy(AntigravityProviderRefreshPolicy())
123+
p.SetTempUnschedCache(tempUnschedCache)
122124
return p
123125
}
124126

0 commit comments

Comments
 (0)