From 995b8a087c408de0a65084b53467fc1407ba1d5a Mon Sep 17 00:00:00 2001 From: azumag Date: Fri, 4 Sep 2026 08:54:19 +0900 Subject: [PATCH] feat: add exponential backoff to v2 wait mode --- README.md | 7 +++- __tests__/v2.test.ts | 79 +++++++++++++++++++++++++++++++++++++++----- package-lock.json | 4 +-- package.json | 2 +- v2.ts | 45 ++++++++++++++++++++++--- 5 files changed, 121 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 72676cd..a5abf6d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ OpenCode v2 / Web uses the `plugins` key: ```json { - "plugins": ["@azumag/opencode-rate-limit-fallback@2.0.1"] + "plugins": ["@azumag/opencode-rate-limit-fallback@2.0.2"] } ``` @@ -52,6 +52,11 @@ installations do not receive the incompatible v2 entry point. The v2 entry point currently implements `fallbackMode: "wait"`. Model-switching modes remain available in the v1-compatible 1.x line. +In v2 wait mode, `cooldownMs` is the initial delay. Consecutive rate-limit +failures use exponential backoff (`1x`, `2x`, `4x`, ...), capped at one hour. +A new user prompt resets the delay to `cooldownMs`; automatic resume attempts +preserve the existing backoff sequence. + OpenCode will automatically install the plugin on startup. This release is tested with OpenCode `1.18.16`. Older OpenCode releases may diff --git a/__tests__/v2.test.ts b/__tests__/v2.test.ts index 83966a6..e42b864 100644 --- a/__tests__/v2.test.ts +++ b/__tests__/v2.test.ts @@ -18,7 +18,7 @@ describe("OpenCode v2 plugin", () => { expect(plugin.setup).toBeTypeOf("function"); }); - it("overrides rate-limit retries with the configured cooldown", async () => { + it("uses the configured cooldown as the base of exponential backoff", async () => { vi.mocked(existsSync).mockReturnValue(true); vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ enabled: true, @@ -27,13 +27,14 @@ describe("OpenCode v2 plugin", () => { fallbackModels: [], })); let retryHook: ((event: any) => void) | undefined; - const dispose = vi.fn(); + const promptDispose = vi.fn(); + const retryDispose = vi.fn(); const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { - hook: vi.fn(async (_name, callback) => { - retryHook = callback; - return { dispose }; + hook: vi.fn(async (name, callback) => { + if (name === "retry") retryHook = callback; + return { dispose: name === "retry" ? retryDispose : promptDispose }; }), prompt: vi.fn(), }, @@ -48,8 +49,70 @@ describe("OpenCode v2 plugin", () => { retryHook?.(event); expect(event.decision).toEqual({ retry: true, delay: 60000 }); + const secondEvent = { ...event, attempt: 2, decision: { retry: false } }; + retryHook?.(secondEvent); + expect(secondEvent.decision).toEqual({ retry: true, delay: 120000 }); + + await cleanup?.(); + expect(promptDispose).toHaveBeenCalledOnce(); + expect(retryDispose).toHaveBeenCalledOnce(); + }); + + it("caps backoff at one hour and resets it for a new user prompt", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ + enabled: true, + fallbackMode: "wait", + cooldownMs: 60000, + fallbackModels: [], + })); + let promptHook: ((event: any) => void) | undefined; + let retryHook: ((event: any) => void) | undefined; + const cleanup = await plugin.setup({ + location: { directory: "/test" }, + session: { + hook: vi.fn(async (name, callback) => { + if (name === "prompt") promptHook = callback; + if (name === "retry") retryHook = callback; + return { dispose: vi.fn() }; + }), + prompt: vi.fn(), + }, + }); + + let event: any; + for (let attempt = 1; attempt <= 8; attempt += 1) { + event = { + sessionID: "session-1", + attempt, + error: { status: 429, message: "Rate limit exceeded" }, + decision: { retry: false }, + }; + retryHook?.(event); + } + expect(event.decision).toEqual({ retry: true, delay: 3600000 }); + + promptHook?.({ sessionID: "session-1", prompt: { text: "" } }); + event = { + sessionID: "session-1", + attempt: 1, + error: { status: 429, message: "Rate limit exceeded" }, + decision: { retry: false }, + }; + retryHook?.(event); + expect(event.decision).toEqual({ retry: true, delay: 3600000 }); + + promptHook?.({ sessionID: "session-1", prompt: { text: "new request" } }); + event = { + sessionID: "session-1", + attempt: 1, + error: { status: 429, message: "Rate limit exceeded" }, + decision: { retry: false }, + }; + retryHook?.(event); + expect(event.decision).toEqual({ retry: true, delay: 60000 }); + await cleanup?.(); - expect(dispose).toHaveBeenCalledOnce(); }); it("resumes a terminal rate-limit failure after cooldown", async () => { @@ -66,8 +129,8 @@ describe("OpenCode v2 plugin", () => { const cleanup = await plugin.setup({ location: { directory: "/test" }, session: { - hook: vi.fn(async (_name, callback) => { - retryHook = callback; + hook: vi.fn(async (name, callback) => { + if (name === "retry") retryHook = callback; return { dispose: vi.fn() }; }), prompt, diff --git a/package-lock.json b/package-lock.json index f3042b6..8452e7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "devDependencies": { "@opencode-ai/plugin": "1.18.16", diff --git a/package.json b/package.json index 1c71bd4..a9a6825 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azumag/opencode-rate-limit-fallback", - "version": "2.0.1", + "version": "2.0.2", "description": "OpenCode plugin that automatically switches to fallback models when rate limited", "main": "dist/v2.js", "types": "dist/v2.d.ts", diff --git a/v2.ts b/v2.ts index 2c3180f..7ccfd5c 100644 --- a/v2.ts +++ b/v2.ts @@ -13,11 +13,20 @@ type RetryEvent = { decision: { retry: false } | { retry: true; delay: number }; }; +type PromptEvent = { + readonly sessionID: string; + readonly prompt: { readonly text: string }; +}; + type Registration = { readonly dispose: () => Promise | void }; type V2Context = { readonly location: { readonly directory: string }; readonly session: { + hook( + name: "prompt", + callback: (event: PromptEvent) => Promise | void, + ): Promise; hook( name: "retry", callback: (event: RetryEvent) => Promise | void, @@ -30,6 +39,8 @@ type V2Context = { }; }; +const MAX_BACKOFF_MS = 60 * 60 * 1000; + const RATE_LIMIT_PATTERNS = [ /\b429\b/i, /rate[\s_-]*limit/i, @@ -52,6 +63,11 @@ function effectiveCooldown(cooldownMs: number | undefined): number { return Math.max(1000, cooldownMs ?? 60000); } +function exponentialDelay(baseDelayMs: number, failureCount: number): number { + const exponent = Math.min(Math.max(0, failureCount - 1), 52); + return Math.min(Math.max(MAX_BACKOFF_MS, baseDelayMs), baseDelayMs * 2 ** exponent); +} + export default { id: "opencode-rate-limit-fallback", async setup(context: V2Context) { @@ -60,8 +76,18 @@ export default { const cooldownMs = effectiveCooldown(config.cooldownMs); const resumeTimers = new Map>(); + const failureCounts = new Map(); - const scheduleResume = (sessionID: string) => { + const clearSessionState = (sessionID: string) => { + const previous = resumeTimers.get(sessionID); + if (previous) { + clearTimeout(previous); + resumeTimers.delete(sessionID); + } + failureCounts.delete(sessionID); + }; + + const scheduleResume = (sessionID: string, delay: number) => { const previous = resumeTimers.get(sessionID); if (previous) clearTimeout(previous); const timer = setTimeout(() => { @@ -69,10 +95,16 @@ export default { void context.session.prompt({ sessionID, text: "", resume: true }).catch(() => { // A deleted or otherwise unavailable session must not keep retrying. }); - }, cooldownMs + 250); + }, delay + 250); resumeTimers.set(sessionID, timer); }; + const promptRegistration = await context.session.hook("prompt", (event) => { + // A real user prompt starts a fresh backoff sequence. The empty prompt used + // to resume after OpenCode's hard retry ceiling must preserve the streak. + if (event.prompt.text.trim().length > 0) clearSessionState(event.sessionID); + }); + const retryRegistration = await context.session.hook("retry", (event) => { if (!isRateLimit(event.error)) return; const previous = resumeTimers.get(event.sessionID); @@ -80,16 +112,21 @@ export default { clearTimeout(previous); resumeTimers.delete(event.sessionID); } - event.decision = { retry: true, delay: cooldownMs }; + const failureCount = (failureCounts.get(event.sessionID) ?? 0) + 1; + failureCounts.set(event.sessionID, failureCount); + const delay = exponentialDelay(cooldownMs, failureCount); + event.decision = { retry: true, delay }; // OpenCode v2 currently stops after attempt 5 even when a retry hook // requests another retry. Resume the same prompt after that hard limit. - if (event.attempt >= 5) scheduleResume(event.sessionID); + if (event.attempt >= 5) scheduleResume(event.sessionID, delay); }); return async () => { + await promptRegistration.dispose(); await retryRegistration.dispose(); for (const timer of resumeTimers.values()) clearTimeout(timer); resumeTimers.clear(); + failureCounts.clear(); }; }, };