Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ enum AlibabaTokenPlanPersonalUsageParser {
containingAnyOf: ["per5HourPercentage", "per1WeekPercentage"],
in: expanded)
else {
throw AlibabaTokenPlanUsageError.parseFailed("Missing Personal usage windows")
throw AlibabaTokenPlanUsageError.usageWindowsUnavailable
}

let fiveHourPercent = OneConsoleJSON.percentagePoints(
fromRatio: OneConsoleJSON.number(usage["per5HourPercentage"]))
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ extension AlibabaTokenPlanUsageError {
switch self {
case .loginRequired, .invalidCredentials:
true
case .apiError, .networkError, .parseFailed:
case .apiError, .networkError, .parseFailed, .usageWindowsUnavailable:
false
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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."
}
}
}
Expand All @@ -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"
Expand Down Expand Up @@ -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],
Expand All @@ -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..<Self.personalUsageMaxAttempts {
if attempt > 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}
}
98 changes: 97 additions & 1 deletion Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1344,3 +1344,99 @@ struct AlibabaTokenPlanSECTokenScrapeTests {
#expect(AlibabaTokenPlanUsageFetcher.extractSECToken(from: "<html><body>no token here</body></html>") == nil)
}
}

struct AlibabaTokenPlanPersonalUsageRetryTests {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize tests that share the URLProtocol handler

Swift Testing runs these two tests concurrently by default, but both replace and later clear the process-wide AlibabaTokenPlanStubURLProtocol.handler; they can therefore route one fetch through the other test's closure, producing the wrong response sequence or resetting the handler mid-request. This suite can also overlap the existing handler-mutating AlibabaTokenPlanUsageParsingTests, so keep all tests using this shared stub in the same serialized suite or otherwise isolate the handler per session.

Useful? React with 👍 / 👎.

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<Int>) -> @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: "<html></html>", 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)
}
}