diff --git a/changelog/unreleased/qoder-quota-refresh-status.md b/changelog/unreleased/qoder-quota-refresh-status.md new file mode 100644 index 00000000..1f7f525b --- /dev/null +++ b/changelog/unreleased/qoder-quota-refresh-status.md @@ -0,0 +1,7 @@ +### English + +- Make Qoder quota refresh failures visible to the account refresh endpoint while keeping account readiness intact, and run quota and model catalog refreshes independently. + +### 中文 + +- 让 Qoder 额度刷新失败在账号刷新接口中可见,同时保持账号就绪状态不受影响,并让额度与模型目录刷新相互独立执行。 diff --git a/internal/runtime/manager_probe.go b/internal/runtime/manager_probe.go index cb0e74ce..936fbce9 100644 --- a/internal/runtime/manager_probe.go +++ b/internal/runtime/manager_probe.go @@ -32,6 +32,13 @@ func (m *Manager) RefreshAll(ctx context.Context, forceQuota bool) error { var joined error for _, item := range m.pool.Items() { if err := m.refreshOne(ctx, item, forceQuota); err != nil { + var quotaErr *QuotaRefreshError + if errors.As(err, "aErr) { + // Background maintenance must remain best-effort. The manual + // single-account endpoint still returns this typed error so the + // console can show that quota refresh did not complete. + continue + } joined = errors.Join(joined, err) } } @@ -75,8 +82,15 @@ func (m *Manager) refreshOne(ctx context.Context, item Item, forceQuota bool) er // never changes readiness. A successful exhausted snapshot may still // keep the account out of request routing. if health.Hot || ready { - m.fetchQuota(ctx, item.ID, item.URL, forceQuota) + // Quota and model discovery are independent upstream operations. Run + // them concurrently so a quota outage cannot prevent catalog refresh. + quotaDone := make(chan error, 1) + go func() { quotaDone <- m.fetchQuota(ctx, item.ID, item.URL, forceQuota) }() m.fetchAccountModels(ctx, item) + quotaErr := <-quotaDone + if quotaErr != nil { + return quotaErr + } } return nil } diff --git a/internal/runtime/manager_quota.go b/internal/runtime/manager_quota.go index bb5e894c..aa9607d0 100644 --- a/internal/runtime/manager_quota.go +++ b/internal/runtime/manager_quota.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "fmt" "log" "net/http" "strings" @@ -12,6 +13,13 @@ import ( "github.com/caigee-cmd/cli2api/internal/providers/qoder" ) +// QuotaRefreshError reports an upstream quota refresh failure without +// implying that the account itself is unhealthy or unavailable. +type QuotaRefreshError struct{ Err error } + +func (e *QuotaRefreshError) Error() string { return e.Err.Error() } +func (e *QuotaRefreshError) Unwrap() error { return e.Err } + // Quota snapshots: pool MergeQuota then Store.SaveQuota. Worker JSON is Qoder-only; // in-process providers use AccountProber.Quota. @@ -132,14 +140,21 @@ func (m *Manager) persistQuota(ctx context.Context, accountID string, quota *Quo } } -func (m *Manager) fetchQuota(ctx context.Context, accountID, workerURL string, force bool) { +func (m *Manager) fetchQuota(ctx context.Context, accountID, workerURL string, force bool) error { + if strings.TrimSpace(workerURL) == "" { + return fmt.Errorf("quota worker URL is empty") + } client := qoder.WorkerClient{ HTTP: &http.Client{Timeout: 5 * time.Second}, ProxyAPIKey: m.ProxyAPIKey(), } quota, err := client.Quota(ctx, workerURL, force) if err != nil || quota == nil { - return + if err != nil { + return &QuotaRefreshError{Err: fmt.Errorf("fetch quota account=%s: %w", accountID, err)} + } + return &QuotaRefreshError{Err: fmt.Errorf("fetch quota account=%s: empty response", accountID)} } m.persistQuota(ctx, accountID, quota) + return nil }