fix(usage): purge stale quota on auth failure, reject partial payloads
This commit is contained in:
@@ -3195,6 +3195,13 @@ export class AuthStorage {
|
||||
}
|
||||
return report;
|
||||
} catch (error) {
|
||||
if (error instanceof AIError.ProviderHttpError && (error.status === 401 || error.status === 403)) {
|
||||
// Definitive auth failure (revoked key, lapsed subscription): purge
|
||||
// the last-good report so #fetchUsageCached's failure branch can't
|
||||
// keep rendering and ranking from stale quota the way it does for
|
||||
// transient failures. Mirrors the definitive-OAuth-refresh path.
|
||||
this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
|
||||
}
|
||||
logger.debug("AuthStorage usage fetch failed", {
|
||||
provider: request.provider,
|
||||
error: String(error),
|
||||
|
||||
@@ -137,7 +137,16 @@ async function fetchOpenCodeGoUsage(params: UsageFetchParams, ctx: UsageFetchCon
|
||||
const limit = buildWindowLimit(descriptor, usage[descriptor.key]);
|
||||
if (limit) limits.push(limit);
|
||||
}
|
||||
if (limits.length === 0) return null;
|
||||
// All-or-nothing: a partial report would overwrite the complete last-good
|
||||
// report in the usage cache, silently dropping the windows used for
|
||||
// ranking and display. Treat any malformed/missing window like a
|
||||
// transient failure so the cached report keeps serving instead.
|
||||
if (limits.length !== OPENCODE_GO_WINDOWS.length) {
|
||||
ctx.logger?.warn("OpenCode Go usage response missing or malformed windows", {
|
||||
decoded: limits.map(limit => limit.id),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: OPENCODE_GO_PROVIDER,
|
||||
|
||||
@@ -267,7 +267,11 @@ describe("OpenCode Go usage via the upstream endpoint", () => {
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
usage: { rolling: { status: "ok", percent: 5, resetsAt: "2026-08-12T15:09:04.847Z" } },
|
||||
usage: {
|
||||
rolling: { status: "ok", percent: 5, resetsAt: "2026-08-12T15:09:04.847Z" },
|
||||
weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" },
|
||||
monthly: { status: "ok", percent: 10, resetsAt: "2026-08-19T00:31:53.847Z" },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
@@ -286,4 +290,92 @@ describe("OpenCode Go usage via the upstream endpoint", () => {
|
||||
referenceStorage.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the last-good report when the key turns definitively unauthorized", async () => {
|
||||
// Transient failures serve the cached report; a 401/403 must not — a
|
||||
// revoked key or lapsed subscription would otherwise keep rendering and
|
||||
// ranking from stale quota until the process restarts.
|
||||
let respondWith: "success" | "unauthorized" = "success";
|
||||
const transitionStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")), {
|
||||
usageProviderResolver: provider =>
|
||||
provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined,
|
||||
usageFetch: (async () =>
|
||||
respondWith === "success"
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
usage: {
|
||||
rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" },
|
||||
weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" },
|
||||
monthly: { status: "ok", percent: 10, resetsAt: "2026-08-19T00:31:53.847Z" },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
: new Response(
|
||||
JSON.stringify({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)) as unknown as typeof fetch,
|
||||
});
|
||||
try {
|
||||
await transitionStorage.reload();
|
||||
await transitionStorage.set("opencode-go", { type: "api_key", key: "opencode-go-key" });
|
||||
|
||||
const nowMs = Date.now();
|
||||
setSystemTime(new Date(nowMs));
|
||||
const fresh = await transitionStorage.fetchUsageReports();
|
||||
expect(fresh?.some(candidate => candidate.provider === "opencode-go")).toBe(true);
|
||||
|
||||
// Past the report TTL the next poll re-hits the endpoint and gets 401.
|
||||
respondWith = "unauthorized";
|
||||
setSystemTime(new Date(nowMs + 10 * 60_000));
|
||||
const afterRevocation = await transitionStorage.fetchUsageReports();
|
||||
expect(afterRevocation?.some(candidate => candidate.provider === "opencode-go")).toBe(false);
|
||||
} finally {
|
||||
transitionStorage.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains the last-good report through a partial payload", async () => {
|
||||
// One malformed window fails the whole decode, which must fall back to
|
||||
// the cached complete report instead of replacing it with fewer windows.
|
||||
let respondWith: "success" | "partial" = "success";
|
||||
const partialStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")), {
|
||||
usageProviderResolver: provider =>
|
||||
provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined,
|
||||
usageFetch: (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
usage: {
|
||||
rolling:
|
||||
respondWith === "success"
|
||||
? { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" }
|
||||
: { status: "ok", percent: "abc" },
|
||||
weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" },
|
||||
monthly: { status: "ok", percent: 10, resetsAt: "2026-08-19T00:31:53.847Z" },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)) as unknown as typeof fetch,
|
||||
});
|
||||
try {
|
||||
await partialStorage.reload();
|
||||
await partialStorage.set("opencode-go", { type: "api_key", key: "opencode-go-key" });
|
||||
|
||||
const nowMs = Date.now();
|
||||
setSystemTime(new Date(nowMs));
|
||||
const fresh = await partialStorage.fetchUsageReports();
|
||||
expect(fresh?.find(candidate => candidate.provider === "opencode-go")?.limits).toHaveLength(3);
|
||||
|
||||
respondWith = "partial";
|
||||
setSystemTime(new Date(nowMs + 10 * 60_000));
|
||||
const afterPartial = await partialStorage.fetchUsageReports();
|
||||
const retained = afterPartial?.find(candidate => candidate.provider === "opencode-go");
|
||||
expect(retained?.limits.map(limit => limit.id)).toEqual(["rolling-5h", "weekly", "monthly"]);
|
||||
} finally {
|
||||
partialStorage.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +137,16 @@ describe("opencode-go usage provider", () => {
|
||||
expect(report).toBeNull();
|
||||
});
|
||||
|
||||
it("skips malformed windows and returns null when no window parses", async () => {
|
||||
it("rejects the whole payload unless all three windows decode", async () => {
|
||||
// A partial report would overwrite the complete last-good report in the
|
||||
// usage cache, so one malformed window must fail the entire payload.
|
||||
const partial = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
{
|
||||
fetch: fakeFetch(usagePayload({ rolling: { status: "ok", percent: "abc" }, weekly: null })),
|
||||
},
|
||||
);
|
||||
expect(partial?.limits.map(limit => limit.id)).toEqual(["monthly"]);
|
||||
expect(partial).toBeNull();
|
||||
|
||||
const empty = await opencodeGoUsageProvider.fetchUsage(
|
||||
{ provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },
|
||||
|
||||
Reference in New Issue
Block a user