diff --git a/backend/cloud/tencent.go b/backend/cloud/tencent.go index e99e419..fb23c48 100644 --- a/backend/cloud/tencent.go +++ b/backend/cloud/tencent.go @@ -3,17 +3,39 @@ package cloud import ( "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" "sort" "strings" + "sync" "time" ) // TencentProvider 通过腾讯云 TC3-HMAC-SHA256 API 同步 CVM 资源。 // Logan.Li type TencentProvider struct{ accessKey, secretKey string } +const ( + tencentRequestInterval = 260 * time.Millisecond + tencentMaxAttempts = 4 +) + +var tencentLimiter = struct { + sync.Mutex + next time.Time +}{} + +// tencentAPIError 保留腾讯云错误码,便于对限频错误做退避重试。 // Logan.Li +type tencentAPIError struct { + Code string + Message string +} + +func (e *tencentAPIError) Error() string { + return fmt.Sprintf("腾讯云 API 错误 %s: %s", e.Code, e.Message) +} + // tencentInstancesResponse 描述腾讯云 CVM 实例分页响应。 // Logan.Li type tencentInstancesResponse struct { Response struct { @@ -56,6 +78,44 @@ func (p *TencentProvider) callInRegion(region, action string, payload interface{ // callService 使用 TC3-HMAC-SHA256 调用腾讯云服务 API。 // Logan.Li func (p *TencentProvider) callService(service, host, version, action, region string, payload interface{}) ([]byte, error) { + var lastErr error + for attempt := 0; attempt < tencentMaxAttempts; attempt++ { + waitTencentRequestSlot() + data, err := p.callServiceOnce(service, host, version, action, region, payload) + if err == nil { + return data, nil + } + lastErr = err + delay, retry := tencentRetryDelay(err, attempt) + if !retry { + return nil, err + } + time.Sleep(delay) + } + return nil, lastErr +} + +// waitTencentRequestSlot 将进程内所有腾讯云请求统一限速在每秒 4 次以内。 // Logan.Li +func waitTencentRequestSlot() { + tencentLimiter.Lock() + defer tencentLimiter.Unlock() + if delay := time.Until(tencentLimiter.next); delay > 0 { + time.Sleep(delay) + } + tencentLimiter.next = time.Now().Add(tencentRequestInterval) +} + +// tencentRetryDelay 对 RequestLimitExceeded 使用指数退避,其他错误立即返回。 // Logan.Li +func tencentRetryDelay(err error, attempt int) (time.Duration, bool) { + var apiErr *tencentAPIError + if !errors.As(err, &apiErr) || !strings.HasPrefix(apiErr.Code, "RequestLimitExceeded") || attempt >= tencentMaxAttempts-1 { + return 0, false + } + return (500 * time.Millisecond) * time.Duration(1<= 300 { + if resp.StatusCode == http.StatusTooManyRequests { + return nil, &tencentAPIError{Code: "RequestLimitExceeded.HTTP429", Message: string(data)} + } return nil, fmt.Errorf("腾讯云 API 返回 HTTP %d: %s", resp.StatusCode, string(data)) } var envelope struct { @@ -104,7 +167,7 @@ func (p *TencentProvider) callService(service, host, version, action, region str } `json:"Response"` } if json.Unmarshal(data, &envelope) == nil && envelope.Response.Error != nil { - return nil, fmt.Errorf("腾讯云 API 错误 %s: %s", envelope.Response.Error.Code, envelope.Response.Error.Message) + return nil, &tencentAPIError{Code: envelope.Response.Error.Code, Message: envelope.Response.Error.Message} } return data, nil } diff --git a/backend/cloud/tencent_billing.go b/backend/cloud/tencent_billing.go index e47987e..c0293c9 100644 --- a/backend/cloud/tencent_billing.go +++ b/backend/cloud/tencent_billing.go @@ -7,7 +7,6 @@ import ( "sort" "strconv" "strings" - "time" ) // FetchDailyBill 拉取指定日期的腾讯云日账单,并按产品和地域汇总。 // Logan.Li @@ -68,8 +67,6 @@ func (p *TencentProvider) FetchDailyBill(billingDate string) ([]CloudBillingReco if len(response.Response.DetailSet) < pageSize || (response.Response.Total > 0 && offset+pageSize >= response.Response.Total) { break } - // 腾讯云账单接口频率上限为每秒 5 次,分页请求限速为每秒 4 次。 // Logan.Li - time.Sleep(250 * time.Millisecond) } keys := make([]aggregationKey, 0, len(aggregation)) diff --git a/backend/cloud/tencent_test.go b/backend/cloud/tencent_test.go new file mode 100644 index 0000000..dea1f25 --- /dev/null +++ b/backend/cloud/tencent_test.go @@ -0,0 +1,28 @@ +package cloud + +import ( + "errors" + "testing" + "time" +) + +// TestTencentRetryDelay 验证限频错误指数退避与最大重试次数。 // Logan.Li +func TestTencentRetryDelay(t *testing.T) { + rateLimitErr := &tencentAPIError{Code: "RequestLimitExceeded", Message: "frequency limit"} + delay, retry := tencentRetryDelay(rateLimitErr, 0) + if !retry || delay != 500*time.Millisecond { + t.Fatalf("首次限频退避错误: retry=%v delay=%s", retry, delay) + } + + delay, retry = tencentRetryDelay(rateLimitErr, 2) + if !retry || delay != 2*time.Second { + t.Fatalf("第三次限频退避错误: retry=%v delay=%s", retry, delay) + } + + if _, retry = tencentRetryDelay(rateLimitErr, tencentMaxAttempts-1); retry { + t.Fatal("达到最大次数后不应继续重试") + } + if _, retry = tencentRetryDelay(errors.New("鉴权失败"), 0); retry { + t.Fatal("非限频错误不应重试") + } +} diff --git a/backend/pkg/systask/registry.go b/backend/pkg/systask/registry.go index cb59042..7a636e3 100644 --- a/backend/pkg/systask/registry.go +++ b/backend/pkg/systask/registry.go @@ -69,7 +69,7 @@ var ( }, { Code: "cloud_billing_sync", Name: "云账单同步", - Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云", + Description: "从云账号 AK 拉取昨日账单(按产品+地域),写入云账单管理;当前支持阿里云和腾讯云", // Logan.Li Schedule: "每天 10:30", Category: "finops", Runnable: true, Enabled: true, }, } @@ -237,4 +237,4 @@ func findDef(code string) (Def, bool) { } } return Def{}, false -} \ No newline at end of file +} diff --git a/frontend/src/components/cloud/CloudBilling.tsx b/frontend/src/components/cloud/CloudBilling.tsx index f2699f2..227e7b3 100644 --- a/frontend/src/components/cloud/CloudBilling.tsx +++ b/frontend/src/components/cloud/CloudBilling.tsx @@ -125,7 +125,7 @@ export function CloudBilling() { if (activeTab === 'alerts') loadRules(); }; - // 从云账号 AK 同步昨日账单(当前支持阿里云) // Logan.Li + // 从云账号 AK 同步昨日账单(当前支持阿里云和腾讯云) // Logan.Li const handleSyncYesterday = async () => { setSyncing(true); try {