fix(usage): purge stale quota on auth failure, reject partial payloads

This commit is contained in:
Will Bogusz
2026-08-12 13:52:49 +02:00
parent a120c96784
commit 990984f19b
4 changed files with 114 additions and 4 deletions
+7
View File
@@ -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),
+10 -1
View File
@@ -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();
}
});
});
+4 -2
View File
@@ -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" } },