fix(github): hold monthly-exhausted accounts until reset
Lock GitHub Copilot connections account-wide until 00:00 UTC on the first of next month when the upstream 402 response indicates the monthly additional-usage-limit was hit, instead of only cooling down the requested model for 120s. Other GitHub 402 responses keep the existing model-scoped cooldown.
This commit is contained in:
parent
9138c99391
commit
3292dfc102
2 changed files with 104 additions and 2 deletions
|
|
@ -8,6 +8,15 @@ import * as log from "../utils/logger.js";
|
||||||
// Mutex to prevent race conditions during account selection
|
// Mutex to prevent race conditions during account selection
|
||||||
let selectionMutex = Promise.resolve();
|
let selectionMutex = Promise.resolve();
|
||||||
|
|
||||||
|
const GITHUB_MONTHLY_USAGE_LIMIT = "you've reached your additional usage limit for your plan";
|
||||||
|
|
||||||
|
function githubMonthlyResetMs(status, errorText, provider) {
|
||||||
|
if (resolveProviderId(provider) !== "github" || Number(status) !== 402) return null;
|
||||||
|
if (!String(errorText || "").toLowerCase().includes(GITHUB_MONTHLY_USAGE_LIMIT)) return null;
|
||||||
|
const now = new Date();
|
||||||
|
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get provider credentials from localDb
|
* Get provider credentials from localDb
|
||||||
* Filters out unavailable accounts and returns the selected account based on strategy
|
* Filters out unavailable accounts and returns the selected account based on strategy
|
||||||
|
|
@ -213,9 +222,16 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||||
const conn = connections.find(c => c.id === connectionId);
|
const conn = connections.find(c => c.id === connectionId);
|
||||||
const backoffLevel = conn?.backoffLevel || 0;
|
const backoffLevel = conn?.backoffLevel || 0;
|
||||||
|
|
||||||
|
// GitHub premium-request exhaustion is account-wide until the next UTC month.
|
||||||
|
const githubResetAtMs = githubMonthlyResetMs(status, errorText, provider);
|
||||||
|
|
||||||
// Provider-specific precise cooldown (e.g. codex usage_limit_reached resets_at) overrides backoff
|
// Provider-specific precise cooldown (e.g. codex usage_limit_reached resets_at) overrides backoff
|
||||||
let shouldFallback, cooldownMs, newBackoffLevel;
|
let shouldFallback, cooldownMs, newBackoffLevel;
|
||||||
if (resetsAtMs && resetsAtMs > Date.now()) {
|
if (githubResetAtMs) {
|
||||||
|
shouldFallback = true;
|
||||||
|
cooldownMs = githubResetAtMs - Date.now();
|
||||||
|
newBackoffLevel = 0;
|
||||||
|
} else if (resetsAtMs && resetsAtMs > Date.now()) {
|
||||||
shouldFallback = true;
|
shouldFallback = true;
|
||||||
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
|
||||||
newBackoffLevel = 0;
|
newBackoffLevel = 0;
|
||||||
|
|
@ -225,7 +241,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||||
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
|
if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 };
|
||||||
|
|
||||||
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
|
const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
|
||||||
const lockUpdate = buildModelLockUpdate(model, cooldownMs);
|
const lockUpdate = buildModelLockUpdate(githubResetAtMs ? null : model, cooldownMs);
|
||||||
|
|
||||||
await updateProviderConnection(connectionId, {
|
await updateProviderConnection(connectionId, {
|
||||||
...lockUpdate,
|
...lockUpdate,
|
||||||
|
|
|
||||||
86
tests/unit/github-monthly-usage-lock.test.js
Normal file
86
tests/unit/github-monthly-usage-lock.test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const dbMocks = vi.hoisted(() => ({
|
||||||
|
getProviderConnections: vi.fn(),
|
||||||
|
updateProviderConnection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/localDb", () => dbMocks);
|
||||||
|
vi.mock("@/lib/network/connectionProxy", () => ({
|
||||||
|
pickProxyPoolId: vi.fn(),
|
||||||
|
resolveConnectionProxyConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/shared/constants/providers.js", () => ({
|
||||||
|
FREE_PROVIDERS: {},
|
||||||
|
resolveProviderId: (provider) => provider,
|
||||||
|
}));
|
||||||
|
vi.mock("@/sse/utils/logger.js", () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn() }));
|
||||||
|
|
||||||
|
const { markAccountUnavailable } = await import("../../src/sse/services/auth.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
dbMocks.getProviderConnections.mockResolvedValue([{
|
||||||
|
id: "github-a",
|
||||||
|
provider: "github",
|
||||||
|
name: "github-a",
|
||||||
|
backoffLevel: 4,
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GitHub monthly usage exhaustion", () => {
|
||||||
|
it("locks the whole account until the next UTC month", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-04T19:30:00.000Z"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markAccountUnavailable(
|
||||||
|
"github-a",
|
||||||
|
402,
|
||||||
|
"You've reached your additional usage limit for your plan. Go to GitHub settings for details.",
|
||||||
|
"github",
|
||||||
|
"claude-fable-5",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dbMocks.updateProviderConnection).toHaveBeenCalledWith(
|
||||||
|
"github-a",
|
||||||
|
expect.objectContaining({
|
||||||
|
modelLock___all: "2026-09-01T00:00:00.000Z",
|
||||||
|
testStatus: "unavailable",
|
||||||
|
errorCode: 402,
|
||||||
|
backoffLevel: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(dbMocks.updateProviderConnection.mock.calls[0][1])
|
||||||
|
.not.toHaveProperty("modelLock_claude-fable-5");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps unrelated GitHub 402 errors model-scoped", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-08-04T19:30:00.000Z"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await markAccountUnavailable(
|
||||||
|
"github-a",
|
||||||
|
402,
|
||||||
|
"Payment required",
|
||||||
|
"github",
|
||||||
|
"claude-fable-5",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dbMocks.updateProviderConnection).toHaveBeenCalledWith(
|
||||||
|
"github-a",
|
||||||
|
expect.objectContaining({
|
||||||
|
"modelLock_claude-fable-5": "2026-08-04T19:32:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(dbMocks.updateProviderConnection.mock.calls[0][1])
|
||||||
|
.not.toHaveProperty("modelLock___all");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue