From 3292dfc1023366c2c26c3b815ec05f9cca349ca3 Mon Sep 17 00:00:00 2001 From: ryanngit Date: Wed, 5 Aug 2026 10:59:49 +0700 Subject: [PATCH] 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. --- src/sse/services/auth.js | 20 ++++- tests/unit/github-monthly-usage-lock.test.js | 86 ++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 tests/unit/github-monthly-usage-lock.test.js diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 36fd6c49..feaaa2ab 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -8,6 +8,15 @@ import * as log from "../utils/logger.js"; // Mutex to prevent race conditions during account selection 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 * 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 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 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; cooldownMs = Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS); newBackoffLevel = 0; @@ -225,7 +241,7 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; 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, { ...lockUpdate, diff --git a/tests/unit/github-monthly-usage-lock.test.js b/tests/unit/github-monthly-usage-lock.test.js new file mode 100644 index 00000000..d7a0d204 --- /dev/null +++ b/tests/unit/github-monthly-usage-lock.test.js @@ -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(); + } + }); +});