diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift index 6d8d2355a9..3cd4ca58e2 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanPersonalUsageParser.swift @@ -35,7 +35,7 @@ enum AlibabaTokenPlanPersonalUsageParser { containingAnyOf: ["per5HourPercentage", "per1WeekPercentage"], in: expanded) else { - throw AlibabaTokenPlanUsageError.parseFailed("Missing Personal usage windows") + throw AlibabaTokenPlanUsageError.usageWindowsUnavailable } let fiveHourPercent = OneConsoleJSON.percentagePoints( @@ -43,7 +43,7 @@ enum AlibabaTokenPlanPersonalUsageParser { let weeklyPercent = OneConsoleJSON.percentagePoints( fromRatio: OneConsoleJSON.number(usage["per1WeekPercentage"])) guard fiveHourPercent != nil || weeklyPercent != nil else { - throw AlibabaTokenPlanUsageError.parseFailed("Missing Personal usage windows") + throw AlibabaTokenPlanUsageError.usageWindowsUnavailable } let planCode = subscriptionData.flatMap(self.planCode) diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift index 988b414ed5..88c86301d3 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanProviderDescriptor.swift @@ -341,7 +341,7 @@ extension AlibabaTokenPlanUsageError { switch self { case .loginRequired, .invalidCredentials: true - case .apiError, .networkError, .parseFailed: + case .apiError, .networkError, .parseFailed, .usageWindowsUnavailable: false } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index 7f8a92d354..bda024b29d 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -9,6 +9,9 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { case apiError(String) case networkError(String) case parseFailed(String) + /// The Personal gateway returned a 200 "Success" envelope with no rolling-window payload — a + /// transient server-side quirk, not a real parse failure. Retried before it ever surfaces. + case usageWindowsUnavailable public var errorDescription: String? { switch self { @@ -22,6 +25,8 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { "Alibaba Token Plan network error: \(message)" case let .parseFailed(message): "Could not parse Alibaba Token Plan usage: \(message)" + case .usageWindowsUnavailable: + "Alibaba Token Plan usage is temporarily unavailable; it will refresh automatically." } } } @@ -44,6 +49,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static let personalUsageAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage" private static let personalSubscriptionAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription" private static let personalQuotaConfigAPI = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config" + /// The Personal usage gateway intermittently answers with a 200 "Success" envelope that omits the + /// rolling-window payload; an immediate re-request usually returns it. Bounded so a genuinely empty + /// stretch still degrades quickly. + private static let personalUsageMaxAttempts = 3 + private static let personalUsageRetryDelayNanoseconds: UInt64 = 400_000_000 private static let browserLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" @@ -337,10 +347,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "secTokenSource": context.secToken == nil ? "missing" : "resolved", ]) - let usageData = try await self.fetchPersonalAPI( - api: self.personalUsageAPI, - dataParameters: [:], - context: context) let subscriptionData = await self.fetchOptionalPersonalAPI( api: self.personalSubscriptionAPI, dataParameters: ["commodityCode": context.region.tokenPlanProductCode], @@ -350,11 +356,32 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { dataParameters: [:], context: context) - return try AlibabaTokenPlanPersonalUsageParser.parse( - from: usageData, - subscriptionData: subscriptionData, - quotaConfigData: quotaConfigData, - now: context.now) + // The Personal usage gateway intermittently returns a 200 "Success" with an empty payload + // (no rolling-window fields). It is usually populated on an immediate re-request, so retry a + // few times before surfacing the transient gap — which keeps the last-good card and shows a + // "temporarily unavailable" note rather than a hard "could not parse" error. + for attempt in 0.. 0 { + try? await Task.sleep(nanoseconds: Self.personalUsageRetryDelayNanoseconds) + } + do { + let usageData = try await self.fetchPersonalAPI( + api: self.personalUsageAPI, + dataParameters: [:], + context: context) + return try AlibabaTokenPlanPersonalUsageParser.parse( + from: usageData, + subscriptionData: subscriptionData, + quotaConfigData: quotaConfigData, + now: context.now) + } catch AlibabaTokenPlanUsageError.usageWindowsUnavailable { + Self.log.info( + "Alibaba Token Plan Personal usage returned no windows; retrying", + metadata: ["attempt": "\(attempt + 1)", "max": "\(Self.personalUsageMaxAttempts)"]) + continue + } + } + throw AlibabaTokenPlanUsageError.usageWindowsUnavailable } private static func fetchOptionalPersonalAPI( diff --git a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift index e2709b6af0..f97bb7bd89 100644 --- a/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift +++ b/Sources/CodexBarCore/Providers/QwenCloud/QwenCloudUsageParser.swift @@ -134,6 +134,7 @@ enum QwenCloudUsageParser { case let .apiError(message): .apiError(message) case let .networkError(message): .networkError(message) case let .parseFailed(message): .parseFailed(message) + case .usageWindowsUnavailable: .parseFailed("Usage is temporarily unavailable.") } } } diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index bf1e6cb24d..8b6906b66a 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -889,7 +889,7 @@ struct AlibabaTokenPlanUsageParsingTests { #expect(redirected.value(forHTTPHeaderField: "Cookie") == "dashboard_only=keep") } - private static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { + static func makeResponse(url: URL, body: String, statusCode: Int) -> (HTTPURLResponse, Data) { let response = HTTPURLResponse( url: url, statusCode: statusCode, @@ -1344,3 +1344,99 @@ struct AlibabaTokenPlanSECTokenScrapeTests { #expect(AlibabaTokenPlanUsageFetcher.extractSECToken(from: "no token here") == nil) } } + +struct AlibabaTokenPlanPersonalUsageRetryTests { + private static let emptySuccess = #"{"code":"SUCCESS","successResponse":true,"msg":"Success.","data":{}}"# + + private static func personalHandler( + usageBodies: @escaping @Sendable (Int) -> String, + subscription: String, + quota: String, + usageCalls: LockIsolated) -> @Sendable (URLRequest) throws -> (HTTPURLResponse, Data) + { + { request in + guard let url = request.url else { throw URLError(.badURL) } + if url.host == "bailian.console.aliyun.com", request.httpMethod == "GET" { + if url.path == "/tool/user/info.json" { + return AlibabaTokenPlanUsageParsingTests.makeResponse( + url: url, + body: #"{"code":"200","data":{"secToken":"t"},"successResponse":true}"#, + statusCode: 200) + } + return AlibabaTokenPlanUsageParsingTests.makeResponse(url: url, body: "", statusCode: 200) + } + let api = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "api" })?.value + switch api { + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage": + let n = usageCalls.value + 1 + usageCalls.setValue(n) + return AlibabaTokenPlanUsageParsingTests.makeResponse(url: url, body: usageBodies(n), statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/subscription": + return AlibabaTokenPlanUsageParsingTests.makeResponse(url: url, body: subscription, statusCode: 200) + case "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/quota-config": + return AlibabaTokenPlanUsageParsingTests.makeResponse(url: url, body: quota, statusCode: 200) + default: + throw URLError(.unsupportedURL) + } + } + } + + private static func stubSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [AlibabaTokenPlanStubURLProtocol.self] + return URLSession(configuration: configuration) + } + + @Test + func `recovers when an empty Success usage response is followed by a full one`() async throws { + defer { AlibabaTokenPlanStubURLProtocol.handler = nil } + let usageBody = try #require(String(data: alibabaTokenPlanFixture("personal_usage"), encoding: .utf8)) + let subscriptionBody = try #require( + String(data: alibabaTokenPlanFixture("personal_subscription"), encoding: .utf8)) + let quotaBody = try #require(String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) + let usageCalls = LockIsolated(0) + + // The gateway answers the first usage request with an empty Success payload, the second with data. + AlibabaTokenPlanStubURLProtocol.handler = Self.personalHandler( + usageBodies: { $0 == 1 ? Self.emptySuccess : usageBody }, + subscription: subscriptionBody, + quota: quotaBody, + usageCalls: usageCalls) + + let snapshot = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: .chinaMainlandPersonal, + environment: [:], + session: Self.stubSession()) + + #expect(snapshot.toUsageSnapshot().primary != nil) + #expect(usageCalls.value == 2) + } + + @Test + func `surfaces usageWindowsUnavailable when every usage attempt is an empty Success`() async throws { + defer { AlibabaTokenPlanStubURLProtocol.handler = nil } + let subscriptionBody = try #require( + String(data: alibabaTokenPlanFixture("personal_subscription"), encoding: .utf8)) + let quotaBody = try #require(String(data: alibabaTokenPlanFixture("personal_quota_config"), encoding: .utf8)) + let usageCalls = LockIsolated(0) + + AlibabaTokenPlanStubURLProtocol.handler = Self.personalHandler( + usageBodies: { _ in Self.emptySuccess }, + subscription: subscriptionBody, + quota: quotaBody, + usageCalls: usageCalls) + + await #expect(throws: AlibabaTokenPlanUsageError.usageWindowsUnavailable) { + _ = try await AlibabaTokenPlanUsageFetcher.fetchUsage( + apiCookieHeader: "quota_only=quota", + dashboardCookieHeader: "dashboard_only=dashboard", + region: .chinaMainlandPersonal, + environment: [:], + session: Self.stubSession()) + } + #expect(usageCalls.value > 1) + } +}