fix(subscription): 修复账户观察软失败与授权重试

This commit is contained in:
tbphp committed 2026-08-17 12:07:47 +08:00
1 parent fb63141d9a
commit 86dc86985e
16 files changed
+360 -32

No files matched your search

+12
View File
@@ -97,6 +97,14 @@ func (e *TokenEndpointError) Error() string {
return fmt.Sprintf("Claude token endpoint returned status %d", e.StatusCode)
}
type UpstreamHTTPError struct {
StatusCode int
}
func (err *UpstreamHTTPError) Error() string {
return fmt.Sprintf("Claude upstream returned status %d", err.StatusCode)
}
func IsDefinitiveRefreshRejection(code string) bool {
return cpaembedded.IsDefinitiveRefreshRejection(code)
}
@@ -176,5 +184,9 @@ func normalizeAuthorizationError(err error) error {
if errors.As(err, &tokenErr) {
return &TokenEndpointError{StatusCode: tokenErr.StatusCode, Code: strings.TrimSpace(tokenErr.Code)}
}
var upstream *cpaembedded.ClaudeUpstreamHTTPError
if errors.As(err, &upstream) {
return &UpstreamHTTPError{StatusCode: upstream.StatusCode}
}
return err
}
+8
View File
@@ -160,4 +160,12 @@ func TestAccountObservationPreservesIncompleteSourceMarkers(t *testing.T) {
}
}
func TestNormalizeAuthorizationErrorPreservesCompanionHTTPStatus(t *testing.T) {
err := normalizeAuthorizationError(&cpaembedded.ClaudeUpstreamHTTPError{StatusCode: http.StatusUnauthorized})
var upstream *UpstreamHTTPError
if !errors.As(err, &upstream) || upstream.StatusCode != http.StatusUnauthorized {
t.Fatalf("normalized error = %#v / %v", upstream, err)
}
}
var _ cpaembedded.ClaudeHTTPExecutor = (*fakeClaudeBridgeExecutor)(nil)
+1 -1
View File
@@ -182,7 +182,7 @@ func ListModels(ctx context.Context, credential Credential) ([]Model, error) {
func ObserveAccount(ctx context.Context, credential Credential) (AccountObservation, error) {
value, err := cpaembedded.ObserveCodexAccount(ctx, credentialToBridge(credential), "")
if err != nil {
return AccountObservation{}, err
return AccountObservation{}, normalizeUpstreamError(err)
}
return AccountObservation{Payload: append([]byte(nil), value.Payload...), Header: value.Header.Clone()}, nil
}
+11
View File
@@ -2,8 +2,11 @@ package codex
import (
"encoding/json"
"errors"
"reflect"
"testing"
cpaembedded "github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded/embedded"
)
func TestCredentialRoundTripKeepsCPACompatibleSchema(t *testing.T) {
@@ -36,3 +39,11 @@ func TestCredentialRoundTripKeepsCPACompatibleSchema(t *testing.T) {
t.Fatalf("secret values = %#v, want %#v", got, want)
}
}
func TestNormalizeUpstreamErrorPreservesObservationStatus(t *testing.T) {
err := normalizeUpstreamError(&cpaembedded.UpstreamHTTPError{Operation: "usage", StatusCode: 401})
var upstream *UpstreamHTTPError
if !errors.As(err, &upstream) || upstream.StatusCode != 401 || upstream.Operation != "usage" {
t.Fatalf("normalized error = %#v / %v", upstream, err)
}
}
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"math"
"net/http"
"strconv"
"time"
@@ -203,6 +204,19 @@ func (s *Service) refreshCredentialObservationOnce(
defer cancelObserve()
channelID := channel.ID(group.ChannelID)
observation, observeErr := s.observeSubscriptionAccount(observeContext, channelID, preparedCredential)
if subscriptionObservationHTTPStatus(observeErr) == http.StatusUnauthorized &&
s.prepareSubscriptionCredential != nil {
preparedCredential, err = s.prepareStoredSubscriptionCredentialWithForce(
observeContext,
group,
credential,
true,
)
if err != nil {
return CredentialObservationResponse{}, err
}
observation, observeErr = s.observeSubscriptionAccount(observeContext, channelID, preparedCredential)
}
if observeErr != nil {
if errors.Is(observeErr, subscriptionruntime.ErrObservationPayloadInvalid) {
return s.recordCredentialObservationFailure(
@@ -210,6 +224,13 @@ func (s *Service) refreshCredentialObservationOnce(
"observation_payload_invalid", "normalize subscription information",
)
}
if status := subscriptionObservationHTTPStatus(observeErr); status == http.StatusUnauthorized ||
status == http.StatusForbidden {
return s.recordCredentialObservationFailure(
ctx, credential, previous, attemptMS, nextAllowedMS,
"observation_authorization_failed", "authorize subscription information",
)
}
return s.recordCredentialObservationFailure(
ctx, credential, previous, attemptMS, nextAllowedMS,
"observation_upstream_failed", "refresh subscription information",
@@ -256,6 +277,14 @@ func (s *Service) refreshCredentialObservationOnce(
return response, nil
}
func subscriptionObservationHTTPStatus(err error) int {
var upstream *subscriptionruntime.UpstreamHTTPError
if errors.As(err, &upstream) && upstream != nil {
return upstream.StatusCode
}
return 0
}
func (s *Service) recordCredentialObservationFailure(
ctx context.Context,
credential models.Credential,
@@ -14,6 +14,7 @@ import (
"gpt-load/internal/channel"
"gpt-load/internal/claude"
"gpt-load/internal/codex"
"gpt-load/internal/execution"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/requestlog"
"gpt-load/internal/storage/models"
@@ -178,6 +179,118 @@ func TestRefreshCredentialObservationPersistsLKGAndAllowsImmediateManualRefresh(
}
}
func TestRefreshCredentialObservationRetriesOnceAfterUnauthorized(t *testing.T) {
fixture, groupID, credentialID := newSubscriptionCredentialFixture(t)
originalPrepare := fixture.service.prepareSubscriptionCredential
forcedRefreshes := 0
fixture.service.prepareSubscriptionCredential = func(
ctx context.Context,
channelID channel.ID,
snapshot execution.CredentialSnapshot,
force bool,
) (subscriptionruntime.Credential, *execution.ErrorEvidence) {
if force {
forcedRefreshes++
}
return originalPrepare(ctx, channelID, snapshot, false)
}
observationCalls := 0
fixture.service.observeSubscriptionAccount = func(
context.Context,
channel.ID,
subscriptionruntime.Credential,
) (subscriptionruntime.Observation, error) {
observationCalls++
if observationCalls == 1 {
return subscriptionruntime.Observation{}, &subscriptionruntime.UpstreamHTTPError{StatusCode: 401}
}
return subscriptionruntime.Observation{Payload: []byte(`{"quota_windows":[]}`)}, nil
}
result, err := fixture.service.RefreshCredentialObservation(t.Context(), groupID, credentialID)
if err != nil || result.State != string(models.CredentialObservationFresh) ||
forcedRefreshes != 1 || observationCalls != 2 {
t.Fatalf("result/error/refreshes/calls = %#v / %v / %d / %d", result, err, forcedRefreshes, observationCalls)
}
}
func TestRefreshCredentialObservationClassifiesRepeatedAuthorizationFailure(t *testing.T) {
fixture, groupID, credentialID := newSubscriptionCredentialFixture(t)
originalPrepare := fixture.service.prepareSubscriptionCredential
forcedRefreshes := 0
fixture.service.prepareSubscriptionCredential = func(
ctx context.Context,
channelID channel.ID,
snapshot execution.CredentialSnapshot,
force bool,
) (subscriptionruntime.Credential, *execution.ErrorEvidence) {
if force {
forcedRefreshes++
}
return originalPrepare(ctx, channelID, snapshot, false)
}
observationCalls := 0
fixture.service.observeSubscriptionAccount = func(
context.Context,
channel.ID,
subscriptionruntime.Credential,
) (subscriptionruntime.Observation, error) {
observationCalls++
return subscriptionruntime.Observation{}, &subscriptionruntime.UpstreamHTTPError{StatusCode: 401}
}
result, err := fixture.service.RefreshCredentialObservation(t.Context(), groupID, credentialID)
if err == nil || result.State != string(models.CredentialObservationError) ||
result.LastErrorCode != "observation_authorization_failed" ||
forcedRefreshes != 1 || observationCalls != 2 {
t.Fatalf("result/error/refreshes/calls = %#v / %v / %d / %d", result, err, forcedRefreshes, observationCalls)
}
}
func TestRefreshCredentialObservationClassifiesNonRefreshableHTTPFailure(t *testing.T) {
tests := []struct {
name string
statusCode int
errorCode string
}{
{name: "forbidden", statusCode: 403, errorCode: "observation_authorization_failed"},
{name: "server error", statusCode: 500, errorCode: "observation_upstream_failed"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture, groupID, credentialID := newSubscriptionCredentialFixture(t)
originalPrepare := fixture.service.prepareSubscriptionCredential
forcedRefreshes := 0
fixture.service.prepareSubscriptionCredential = func(
ctx context.Context,
channelID channel.ID,
snapshot execution.CredentialSnapshot,
force bool,
) (subscriptionruntime.Credential, *execution.ErrorEvidence) {
if force {
forcedRefreshes++
}
return originalPrepare(ctx, channelID, snapshot, false)
}
observationCalls := 0
fixture.service.observeSubscriptionAccount = func(
context.Context,
channel.ID,
subscriptionruntime.Credential,
) (subscriptionruntime.Observation, error) {
observationCalls++
return subscriptionruntime.Observation{}, &subscriptionruntime.UpstreamHTTPError{StatusCode: test.statusCode}
}
result, err := fixture.service.RefreshCredentialObservation(t.Context(), groupID, credentialID)
if err == nil || result.LastErrorCode != test.errorCode ||
forcedRefreshes != 0 || observationCalls != 1 {
t.Fatalf("result/error/refreshes/calls = %#v / %v / %d / %d", result, err, forcedRefreshes, observationCalls)
}
})
}
}
func TestRefreshCredentialObservationPersistsPartialSnapshotAsStale(t *testing.T) {
fixture, groupID, credentialID := newSubscriptionCredentialFixture(t)
fixture.service.observeSubscriptionAccount = func(
+10 -1
View File
@@ -86,6 +86,15 @@ func (s *Service) prepareStoredSubscriptionCredential(
ctx context.Context,
group models.Group,
row models.Credential,
) (subscriptionruntime.Credential, error) {
return s.prepareStoredSubscriptionCredentialWithForce(ctx, group, row, false)
}
func (s *Service) prepareStoredSubscriptionCredentialWithForce(
ctx context.Context,
group models.Group,
row models.Credential,
forceRefresh bool,
) (subscriptionruntime.Credential, error) {
switch row.AuthState {
case "", models.CredentialAuthStateReady:
@@ -120,7 +129,7 @@ func (s *Service) prepareStoredSubscriptionCredential(
groupCollectionCredentialVersion(row.SecretVersion),
groupCollectionCredentialIdentity(row.IdentityFingerprint, group),
canonical,
), false)
), forceRefresh)
if evidence != nil {
return subscriptionruntime.Credential{}, subscriptionPreparationAPIError(evidence)
}
@@ -148,6 +148,10 @@ func (*claudeDriver) Observe(ctx context.Context, credential Credential) (Observ
}
observed, err := claude.ObserveAccount(ctx, value)
if err != nil {
var upstream *claude.UpstreamHTTPError
if errors.As(err, &upstream) {
return Observation{}, &UpstreamHTTPError{StatusCode: upstream.StatusCode}
}
return Observation{}, err
}
normalized, err := NormalizeClaudeObservation(observed)
@@ -148,6 +148,10 @@ func (*codexDriver) Observe(ctx context.Context, credential Credential) (Observa
}
observed, err := codex.ObserveAccount(ctx, value)
if err != nil {
var upstream *codex.UpstreamHTTPError
if errors.As(err, &upstream) {
return Observation{}, &UpstreamHTTPError{StatusCode: upstream.StatusCode}
}
return Observation{}, err
}
var detailsPayload []byte
+112 -27
View File
@@ -29,6 +29,16 @@ const (
// returned a usable observation. Callers retain the last-known-good snapshot.
var ErrClaudeAccountObservationUnavailable = errors.New("Claude account observation is unavailable")
// ClaudeUpstreamHTTPError retains only the status needed to classify Claude
// companion endpoint failures. Provider response bodies never cross this boundary.
type ClaudeUpstreamHTTPError struct {
StatusCode int
}
func (err *ClaudeUpstreamHTTPError) Error() string {
return fmt.Sprintf("Claude OAuth companion endpoint returned status %d", err.StatusCode)
}
// ClaudeModel is the safe model metadata returned by Claude account discovery.
type ClaudeModel struct {
ID string
@@ -136,6 +146,15 @@ type claudeUsageWindowPayload struct {
ResetsAt *string `json:"resets_at"`
}
type claudeExtraUsagePayload struct {
Enabled *bool `json:"is_enabled"`
MonthlyLimit *float64 `json:"monthly_limit"`
UsedCredits *float64 `json:"used_credits"`
Utilization *float64 `json:"utilization"`
Currency *string `json:"currency"`
DisabledReason *string `json:"disabled_reason"`
}
type claudeUsagePayload struct {
FiveHour *claudeUsageWindowPayload `json:"five_hour"`
SevenDay *claudeUsageWindowPayload `json:"seven_day"`
@@ -143,15 +162,8 @@ type claudeUsagePayload struct {
SevenDayOpus *claudeUsageWindowPayload `json:"seven_day_opus"`
SevenDaySonnet *claudeUsageWindowPayload `json:"seven_day_sonnet"`
CinderCove *claudeUsageWindowPayload `json:"cinder_cove"`
ExtraUsage *struct {
Enabled bool `json:"is_enabled"`
MonthlyLimit *float64 `json:"monthly_limit"`
UsedCredits *float64 `json:"used_credits"`
Utilization *float64 `json:"utilization"`
Currency *string `json:"currency"`
DisabledReason *string `json:"disabled_reason"`
} `json:"extra_usage"`
Limits []struct {
ExtraUsage *claudeExtraUsagePayload `json:"extra_usage"`
Limits []struct {
Kind string `json:"kind"`
Group string `json:"group"`
Percent float64 `json:"percent"`
@@ -298,6 +310,21 @@ func ObserveClaudeAccount(
}
incomplete := make([]string, 0, 4)
successfulSources := 0
failedSources, commonHTTPStatus := 0, 0
onlyHTTPFailures := true
recordFailure := func(sourceErr error) {
failedSources++
var upstream *ClaudeUpstreamHTTPError
if sourceErr == nil || !errors.As(sourceErr, &upstream) {
onlyHTTPFailures = false
return
}
if commonHTTPStatus == 0 {
commonHTTPStatus = upstream.StatusCode
} else if commonHTTPStatus != upstream.StatusCode {
onlyHTTPFailures = false
}
}
if profilePayload, profileErr := fetchClaudeOAuthProfile(ctx, options, credential.AccessToken); profileErr == nil {
if err := validateClaudeProfileIdentity(credential, profilePayload); err != nil {
return ClaudeAccountObservation{}, err
@@ -305,6 +332,7 @@ func ObserveClaudeAccount(
observedProfile, mapErr := mapClaudeAccountProfile(profilePayload)
if mapErr != nil {
incomplete = append(incomplete, "profile")
recordFailure(nil)
} else {
profile = mergeClaudeAccountProfile(profile, observedProfile)
successfulSources++
@@ -313,48 +341,67 @@ func ObserveClaudeAccount(
return ClaudeAccountObservation{}, profileErr
} else {
incomplete = append(incomplete, "profile")
recordFailure(profileErr)
}
if roles, rolesErr := fetchAndDecodeClaudeRoles(ctx, credential, options); rolesErr == nil {
successfulSources++
rolesComplete := true
rolesUsable, rolesComplete := false, true
if value, valueErr := claudeSafeScalar("organization role", roles.OrganizationRole, false); valueErr == nil {
profile.OrganizationRole = value
if value != "" {
profile.OrganizationRole = value
rolesUsable = true
}
} else {
rolesComplete = false
}
if value, valueErr := claudeSafeScalar("workspace role", roles.WorkspaceRole, false); valueErr == nil {
profile.WorkspaceRole = value
if value != "" {
profile.WorkspaceRole = value
rolesUsable = true
}
} else {
rolesComplete = false
}
if value, valueErr := claudeSafeScalar("organization name", roles.OrganizationName, false); valueErr == nil && value != "" {
profile.OrganizationName = value
rolesUsable = true
} else if valueErr != nil {
rolesComplete = false
}
if !rolesComplete {
if rolesUsable {
successfulSources++
}
if !rolesUsable || !rolesComplete {
incomplete = append(incomplete, "roles")
}
if !rolesUsable {
recordFailure(nil)
}
} else if isClaudeObservationContextError(rolesErr) {
return ClaudeAccountObservation{}, rolesErr
} else {
incomplete = append(incomplete, "roles")
recordFailure(rolesErr)
}
if bootstrap, bootstrapErr := fetchClaudeBootstrap(ctx, credential, options); bootstrapErr == nil {
if err := validateClaudeBootstrapIdentity(credential, bootstrap); err != nil {
return ClaudeAccountObservation{}, err
}
successfulSources++
if bootstrap.OAuthAccount != nil {
if !applyClaudeBootstrapAccount(&profile, bootstrap.OAuthAccount) {
incomplete = append(incomplete, "bootstrap")
}
bootstrapUsable, bootstrapComplete := applyClaudeBootstrapAccount(&profile, bootstrap.OAuthAccount)
if bootstrapUsable {
successfulSources++
}
if !bootstrapUsable || !bootstrapComplete {
incomplete = append(incomplete, "bootstrap")
}
if !bootstrapUsable {
recordFailure(nil)
}
} else if isClaudeObservationContextError(bootstrapErr) {
return ClaudeAccountObservation{}, bootstrapErr
} else {
incomplete = append(incomplete, "bootstrap")
recordFailure(bootstrapErr)
}
usage, header, err := fetchAndDecodeClaudeUsage(ctx, credential, options)
@@ -363,10 +410,17 @@ func ObserveClaudeAccount(
return ClaudeAccountObservation{}, err
}
incomplete = append(incomplete, "usage")
} else {
recordFailure(err)
} else if claudeUsageUsable(usage) {
successfulSources++
} else {
incomplete = append(incomplete, "usage")
recordFailure(nil)
}
if successfulSources == 0 {
if failedSources == 4 && onlyHTTPFailures && commonHTTPStatus != 0 {
return ClaudeAccountObservation{}, &ClaudeUpstreamHTTPError{StatusCode: commonHTTPStatus}
}
return ClaudeAccountObservation{}, ErrClaudeAccountObservationUnavailable
}
return ClaudeAccountObservation{
@@ -545,11 +599,18 @@ func applyClaudeBootstrapAccount(
UserRateLimitTier string `json:"user_rate_limit_tier"`
SeatTier string `json:"seat_tier"`
},
) bool {
) (bool, bool) {
if profile == nil || account == nil {
return true
return false, false
}
usable, complete := false, true
for _, value := range []string{account.AccountUUID, account.OrganizationUUID} {
if cleaned, err := claudeSafeScalar("bootstrap account identity", value, false); err != nil {
complete = false
} else if cleaned != "" {
usable = true
}
}
complete := true
for target, value := range map[*string]string{
&profile.Email: account.AccountEmail,
&profile.OrganizationName: account.OrganizationName,
@@ -560,11 +621,29 @@ func applyClaudeBootstrapAccount(
} {
if cleaned, err := claudeSafeScalar("bootstrap account field", value, false); err == nil && cleaned != "" {
*target = cleaned
usable = true
} else if err != nil {
complete = false
}
}
return complete
return usable, complete
}
func claudeUsageUsable(usage ClaudeUsage) bool {
for _, window := range []*ClaudeUsageWindow{
usage.FiveHour,
usage.SevenDay,
usage.SevenDayOAuthApps,
usage.SevenDayOpus,
usage.SevenDaySonnet,
usage.CinderCove,
} {
if window != nil && (window.Utilization != nil ||
window.ResetsAt != nil && strings.TrimSpace(*window.ResetsAt) != "") {
return true
}
}
return usage.ExtraUsage != nil || len(usage.Limits) > 0
}
func validateClaudeProfileIdentity(credential ClaudeCredential, profile claudeOAuthProfile) error {
@@ -613,7 +692,7 @@ func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) {
if result.CinderCove, err = mapClaudeUsageWindow("cinder_cove", payload.CinderCove); err != nil {
return ClaudeUsage{}, err
}
if payload.ExtraUsage != nil {
if claudeExtraUsagePayloadUsable(payload.ExtraUsage) {
for field, value := range map[string]*float64{
"monthly_limit": payload.ExtraUsage.MonthlyLimit,
"used_credits": payload.ExtraUsage.UsedCredits,
@@ -632,7 +711,7 @@ func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) {
return ClaudeUsage{}, err
}
result.ExtraUsage = &ClaudeExtraUsage{
Enabled: payload.ExtraUsage.Enabled,
Enabled: payload.ExtraUsage.Enabled != nil && *payload.ExtraUsage.Enabled,
MonthlyLimit: cloneFloat64(payload.ExtraUsage.MonthlyLimit),
UsedCredits: cloneFloat64(payload.ExtraUsage.UsedCredits),
Utilization: cloneFloat64(payload.ExtraUsage.Utilization),
@@ -684,6 +763,12 @@ func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) {
return result, nil
}
func claudeExtraUsagePayloadUsable(payload *claudeExtraUsagePayload) bool {
return payload != nil && (payload.Enabled != nil || payload.MonthlyLimit != nil ||
payload.UsedCredits != nil || payload.Utilization != nil || payload.Currency != nil ||
payload.DisabledReason != nil)
}
func mapClaudeUsageWindow(name string, payload *claudeUsageWindowPayload) (*ClaudeUsageWindow, error) {
if payload == nil {
return nil, nil
@@ -735,7 +820,7 @@ func fetchClaudeOAuthJSONRequest(
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
clear(body)
return nil, nil, fmt.Errorf("Claude OAuth companion endpoint returned status %d", response.StatusCode)
return nil, nil, &ClaudeUpstreamHTTPError{StatusCode: response.StatusCode}
}
return body, response.Header.Clone(), nil
}
+1 -1
View File
@@ -288,7 +288,7 @@ func fetchClaudeOAuthJSON(
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
clear(body)
return nil, fmt.Errorf("Claude OAuth companion endpoint returned status %d", response.StatusCode)
return nil, &ClaudeUpstreamHTTPError{StatusCode: response.StatusCode}
}
return body, nil
}
+49 -2
View File
@@ -929,6 +929,10 @@ func TestObserveClaudeAccountFailsWhenEverySourceIsUnavailable(t *testing.T) {
if calls != 4 {
t.Fatalf("observation calls = %d, want 4", calls)
}
var upstream *ClaudeUpstreamHTTPError
if !errors.As(err, &upstream) || upstream.StatusCode != status {
t.Fatalf("observation error = %v, want status %d", err, status)
}
if strings.Contains(err.Error(), "sensitive upstream detail") {
t.Fatalf("observation error leaked upstream response: %v", err)
}
@@ -936,6 +940,45 @@ func TestObserveClaudeAccountFailsWhenEverySourceIsUnavailable(t *testing.T) {
}
}
func TestObserveClaudeAccountRejectsSourceWithoutUsableData(t *testing.T) {
credential := testClaudeExecutionCredential()
tests := []struct {
name string
path string
payload string
}{
{name: "empty roles", path: "/roles", payload: `[]`},
{name: "empty bootstrap", path: "/bootstrap", payload: `{}`},
{name: "empty usage", path: "/usage", payload: `{}`},
{name: "empty usage window", path: "/usage", payload: `{"five_hour":{}}`},
{name: "empty extra usage", path: "/usage", payload: `{"extra_usage":{}}`},
{name: "unknown usage schema", path: "/usage", payload: `{"new_unknown_field":"value"}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Content-Type", "application/json")
if request.URL.Path == test.path {
_, _ = writer.Write([]byte(test.payload))
return
}
writer.WriteHeader(http.StatusServiceUnavailable)
_, _ = writer.Write([]byte(`{"error":"temporarily unavailable"}`))
}))
defer server.Close()
_, err := ObserveClaudeAccount(t.Context(), credential, ClaudeOptions{
ProfileURL: server.URL + "/profile", RolesURL: server.URL + "/roles",
BootstrapURL: server.URL + "/bootstrap", UsageURL: server.URL + "/usage",
HTTPClient: server.Client(),
})
if !errors.Is(err, ErrClaudeAccountObservationUnavailable) {
t.Fatalf("observation error = %v, want unavailable", err)
}
})
}
}
func TestObserveClaudeAccountKeepsPartialProfileWhenCompanionEndpointsFail(t *testing.T) {
credential := testClaudeExecutionCredential()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
@@ -979,8 +1022,12 @@ func TestObserveClaudeAccountTreatsMissingProfileIdentityAsPartial(t *testing.T)
switch request.URL.Path {
case "/profile":
_, _ = writer.Write([]byte(`{"account":{"email":"owner@example.com"},"organization":{"uuid":"organization-one"}}`))
case "/roles", "/bootstrap", "/usage":
_, _ = writer.Write([]byte(`{}`))
case "/roles":
_, _ = writer.Write([]byte(`{"organization_role":"member"}`))
case "/bootstrap":
_, _ = writer.Write([]byte(`{"oauth_account":{"account_uuid":"account-one","organization_uuid":"organization-one"}}`))
case "/usage":
_, _ = writer.Write([]byte(`{"five_hour":{"utilization":10}}`))
default:
http.NotFound(writer, request)
}
@@ -276,6 +276,8 @@ const failureLabel = computed(() =>
}`,
)
const observationErrorKeys: Readonly<Record<string, string>> = {
observation_authorization_failed:
'group.credentials.subscription.observationError.authorizationFailed',
observation_upstream_failed: 'group.credentials.subscription.observationError.upstreamFailed',
observation_payload_invalid: 'group.credentials.subscription.observationError.payloadInvalid',
}
+2
View File
@@ -466,6 +466,8 @@ export default {
refreshStartFailed: 'The credential refresh could not start. Try again shortly.',
},
observationError: {
authorizationFailed:
'The upstream subscription authorization is no longer valid. Sign in again.',
upstreamFailed: 'Unable to read upstream subscription entitlement information',
payloadInvalid: 'The upstream returned unrecognized subscription entitlement information',
},
+1
View File
@@ -471,6 +471,7 @@ export default {
'認証情報の更新を開始できません。しばらくしてから再試行してください。',
},
observationError: {
authorizationFailed: '上流のサブスクリプション認証が無効です。再ログインしてください',
upstreamFailed: '上流のサブスクリプション利用枠情報を取得できません',
payloadInvalid: '上流から認識できないサブスクリプション利用枠情報が返されました',
},
+1
View File
@@ -449,6 +449,7 @@ export default {
refreshStartFailed: '当前无法开始刷新凭据,请稍后重试。',
},
observationError: {
authorizationFailed: '上游订阅授权已失效,请重新登录账号',
upstreamFailed: '无法读取上游订阅权益信息',
payloadInvalid: '上游返回了无法识别的订阅权益信息',
},