From 6cfd31b3a40740a9ad582995202b7199db3c30ff Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 26 Apr 2026 21:44:33 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E6=94=B6=E7=B4=A7=20`LimiterFileSystem`=20?= =?UTF-8?q?=E7=9A=84=20429=20=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=EF=BC=8C=E9=81=BF=E5=85=8D=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E8=A2=AB=E7=9B=B2=E7=9B=AE=E9=87=8D=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/filesystem/limiter.test.ts | 107 ++++++++++++++++++++++++++++ packages/filesystem/limiter.ts | 34 +++++---- 2 files changed, 127 insertions(+), 14 deletions(-) create mode 100644 packages/filesystem/limiter.test.ts diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts new file mode 100644 index 000000000..30a241a5e --- /dev/null +++ b/packages/filesystem/limiter.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type FileSystem from "./filesystem"; +import type { FileInfo, FileReader, FileWriter } from "./filesystem"; +import LimiterFileSystem from "./limiter"; + +function createFs(): FileSystem { + return { + verify: vi.fn(async () => {}), + open: vi.fn(async () => { + const reader: FileReader = { + read: vi.fn(async () => "content"), + }; + return reader; + }), + openDir: vi.fn(async () => createFs()), + create: vi.fn(async () => { + const writer: FileWriter = { + write: vi.fn(async () => {}), + }; + return writer; + }), + createDir: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + list: vi.fn(async () => []), + getDirUrl: vi.fn(async () => "url"), + }; +} + +const file: FileInfo = { + name: "test.user.js", + path: "/test.user.js", + size: 1, + digest: "digest", + createtime: 1, + updatetime: 1, +}; + +describe("LimiterFileSystem", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("should retry list on 429", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.list).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce([]); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.list(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toEqual([]); + expect(fs.list).toHaveBeenCalledTimes(2); + }); + + it("should not retry delete on 429", async () => { + const fs = createFs(); + vi.mocked(fs.delete).mockRejectedValueOnce(new Error("429 Too Many Requests")); + const limiter = new LimiterFileSystem(fs); + + await expect(limiter.delete("/test.user.js")).rejects.toThrow("429 Too Many Requests"); + expect(fs.delete).toHaveBeenCalledTimes(1); + }); + + it("should not retry createDir on 429", async () => { + const fs = createFs(); + vi.mocked(fs.createDir).mockRejectedValueOnce(new Error("429 Too Many Requests")); + const limiter = new LimiterFileSystem(fs); + + await expect(limiter.createDir("/dir")).rejects.toThrow("429 Too Many Requests"); + expect(fs.createDir).toHaveBeenCalledTimes(1); + }); + + it("should not retry writer.write on 429", async () => { + const fs = createFs(); + const write = vi.fn(async () => {}); + vi.mocked(fs.create).mockResolvedValueOnce({ + write, + }); + write.mockRejectedValueOnce(new Error("429 Too Many Requests")); + const limiter = new LimiterFileSystem(fs); + const writer = await limiter.create(file.path); + + await expect(writer.write("content")).rejects.toThrow("429 Too Many Requests"); + expect(write).toHaveBeenCalledTimes(1); + }); + + it("should retry reader.read on 429", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const read = vi.fn(async () => "content"); + vi.mocked(fs.open).mockResolvedValueOnce({ + read, + }); + read.mockRejectedValueOnce(new Error("429 Too Many Requests")); + read.mockResolvedValueOnce("content"); + const limiter = new LimiterFileSystem(fs); + const reader = await limiter.open(file); + + const promise = reader.read("string"); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBe("content"); + expect(read).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 77b984700..df993f297 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,6 +1,8 @@ import type FileSystem from "./filesystem"; import type { FileInfo, FileReader, FileWriter } from "./filesystem"; +const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); + /** * 速率限制器 * 控制并发操作数量,防止过多并发请求 @@ -21,7 +23,7 @@ export class RateLimiter { * @param fn 要执行的操作函数 * @returns 操作结果 */ - async execute(fn: () => Promise): Promise { + async execute(fn: () => Promise, op = "unknown"): Promise { // 如果当前运行的操作数已达到上限,则等待 while (this.running >= this.maxConcurrent) { await new Promise((resolve) => { @@ -31,7 +33,7 @@ export class RateLimiter { this.running++; try { - return await this.executeWithRetry(fn); + return await this.executeWithRetry(fn, op); } finally { this.running--; // 执行完成后,从队列中取出下一个等待的操作 @@ -47,7 +49,7 @@ export class RateLimiter { * @param fn 要执行的操作函数 * @returns 操作结果 */ - private async executeWithRetry(fn: () => Promise): Promise { + private async executeWithRetry(fn: () => Promise, op: string): Promise { // 最多重试 10 次 for (let i = 0; i <= 10; i++) { try { @@ -55,7 +57,7 @@ export class RateLimiter { } catch (error) { // 检查错误字符串中是否包含 429 const errorStr = String(error); - if (errorStr.includes("429") && i < 10) { + if (this.shouldRetry429(op, errorStr) && i < 10) { // 遇到 429 错误且未达到重试上限,采用指数退避策略延迟后继续重试 const delay = Math.min(2000 * Math.pow(2, i), 60000); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -68,6 +70,10 @@ export class RateLimiter { } throw new Error("Max retries exceeded"); } + + private shouldRetry429(op: string, errorStr: string): boolean { + return errorStr.includes("429") && RETRYABLE_429_OPS.has(op); + } } // 文件系统限速器,防止并发请求过多达到服务器限制 @@ -83,47 +89,47 @@ export default class LimiterFileSystem implements FileSystem { } verify(): Promise { - return this.limiter.execute(() => this.fs.verify()); + return this.limiter.execute(() => this.fs.verify(), "verify"); } async open(file: FileInfo): Promise { return this.limiter.execute(async () => { const reader = await this.fs.open(file); return { - read: (type) => this.limiter.execute(() => reader.read(type)), + read: (type) => this.limiter.execute(() => reader.read(type), "read"), }; - }); + }, "open"); } async openDir(path: string): Promise { return this.limiter.execute(async () => { const fs = await this.fs.openDir(path); return new LimiterFileSystem(fs, this.limiter); - }); + }, "openDir"); } async create(path: string): Promise { return this.limiter.execute(async () => { const writer = await this.fs.create(path); return { - write: (content) => this.limiter.execute(() => writer.write(content)), + write: (content) => this.limiter.execute(() => writer.write(content), "write"), }; - }); + }, "create"); } createDir(dir: string): Promise { - return this.limiter.execute(() => this.fs.createDir(dir)); + return this.limiter.execute(() => this.fs.createDir(dir), "createDir"); } delete(path: string): Promise { - return this.limiter.execute(() => this.fs.delete(path)); + return this.limiter.execute(() => this.fs.delete(path), "delete"); } list(): Promise { - return this.limiter.execute(() => this.fs.list()); + return this.limiter.execute(() => this.fs.list(), "list"); } getDirUrl(): Promise { - return this.limiter.execute(() => this.fs.getDirUrl()); + return this.limiter.execute(() => this.fs.getDirUrl(), "getDirUrl"); } } From cba38732b948645b88042cbce23e4bb8d437f5ee Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:18:12 +0900 Subject: [PATCH 2/4] update shouldRetry429 condition --- packages/filesystem/limiter.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index df993f297..870c598d6 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -72,7 +72,11 @@ export class RateLimiter { } private shouldRetry429(op: string, errorStr: string): boolean { - return errorStr.includes("429") && RETRYABLE_429_OPS.has(op); + return ( + ((errorStr.includes("429") && /[^A-Za-z\d]429[^A-Za-z\d]/.test(` ${errorStr} `)) || + errorStr.toLowerCase().includes("too many requests")) && + RETRYABLE_429_OPS.has(op) + ); } } From 845d45decb2cdb234679b1a31db5133e52d75425 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 26 Apr 2026 23:03:01 +0900 Subject: [PATCH 3/4] update shouldRetry429 condition --- packages/filesystem/limiter.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 870c598d6..4de09145e 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -56,8 +56,8 @@ export class RateLimiter { return await fn(); } catch (error) { // 检查错误字符串中是否包含 429 - const errorStr = String(error); - if (this.shouldRetry429(op, errorStr) && i < 10) { + const errorStr = String(error).toLowerCase(); + if (this.shouldRetry429(op, ` ${errorStr} `) && i < 10) { // 遇到 429 错误且未达到重试上限,采用指数退避策略延迟后继续重试 const delay = Math.min(2000 * Math.pow(2, i), 60000); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -73,8 +73,7 @@ export class RateLimiter { private shouldRetry429(op: string, errorStr: string): boolean { return ( - ((errorStr.includes("429") && /[^A-Za-z\d]429[^A-Za-z\d]/.test(` ${errorStr} `)) || - errorStr.toLowerCase().includes("too many requests")) && + ((errorStr.includes("429") && /[^a-z\d]429[^a-z\d]/.test(errorStr)) || errorStr.includes("too many requests")) && RETRYABLE_429_OPS.has(op) ); } From b6c73508b62e941440ee6cc9d0636a6e59e0639f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 27 Apr 2026 14:15:27 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=A4=84=E7=90=86=20Copilot=20=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=EF=BC=9A=E8=A1=A5=E5=85=A8=20op=20JSDoc=20/=20opts=20?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=20/=20=E9=87=8D=E8=AF=95=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RateLimiter.execute / executeWithRetry 的 JSDoc 补充 op 参数说明 - executeWithRetry 末尾兜底错误带上 op,便于定位(理论上不可达) - LimiterFileSystem.create / createDir 透传 FileCreateOptions(修复预存在的元信息丢失) - 新增测试:verify/open/openDir/getDirUrl 在 429 自动重试,create 不重试 - 新增测试:create / createDir 的 FileCreateOptions 透传 --- packages/filesystem/limiter.test.ts | 77 +++++++++++++++++++++++++++++ packages/filesystem/limiter.ts | 15 +++--- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/packages/filesystem/limiter.test.ts b/packages/filesystem/limiter.test.ts index 30a241a5e..f76ed56f4 100644 --- a/packages/filesystem/limiter.test.ts +++ b/packages/filesystem/limiter.test.ts @@ -54,6 +54,83 @@ describe("LimiterFileSystem", () => { expect(fs.list).toHaveBeenCalledTimes(2); }); + it("should retry verify on 429", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.verify).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce(undefined); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.verify(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeUndefined(); + expect(fs.verify).toHaveBeenCalledTimes(2); + }); + + it("should retry open on 429", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const reader: FileReader = { read: vi.fn(async () => "content") }; + vi.mocked(fs.open).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce(reader); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.open(file); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeDefined(); + expect(fs.open).toHaveBeenCalledTimes(2); + }); + + it("should retry openDir on 429", async () => { + vi.useFakeTimers(); + const fs = createFs(); + const inner = createFs(); + vi.mocked(fs.openDir).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce(inner); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.openDir("/dir"); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBeDefined(); + expect(fs.openDir).toHaveBeenCalledTimes(2); + }); + + it("should retry getDirUrl on 429", async () => { + vi.useFakeTimers(); + const fs = createFs(); + vi.mocked(fs.getDirUrl).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce("url"); + const limiter = new LimiterFileSystem(fs); + + const promise = limiter.getDirUrl(); + await vi.runOnlyPendingTimersAsync(); + + await expect(promise).resolves.toBe("url"); + expect(fs.getDirUrl).toHaveBeenCalledTimes(2); + }); + + it("should not retry create on 429", async () => { + const fs = createFs(); + vi.mocked(fs.create).mockRejectedValueOnce(new Error("429 Too Many Requests")); + const limiter = new LimiterFileSystem(fs); + + await expect(limiter.create(file.path)).rejects.toThrow("429 Too Many Requests"); + expect(fs.create).toHaveBeenCalledTimes(1); + }); + + it("should pass FileCreateOptions through create", async () => { + const fs = createFs(); + const limiter = new LimiterFileSystem(fs); + await limiter.create(file.path, { modifiedDate: 123 }); + expect(fs.create).toHaveBeenCalledWith(file.path, { modifiedDate: 123 }); + }); + + it("should pass FileCreateOptions through createDir", async () => { + const fs = createFs(); + const limiter = new LimiterFileSystem(fs); + await limiter.createDir("/dir", { modifiedDate: 456 }); + expect(fs.createDir).toHaveBeenCalledWith("/dir", { modifiedDate: 456 }); + }); + it("should not retry delete on 429", async () => { const fs = createFs(); vi.mocked(fs.delete).mockRejectedValueOnce(new Error("429 Too Many Requests")); diff --git a/packages/filesystem/limiter.ts b/packages/filesystem/limiter.ts index 4de09145e..5dfb54b81 100644 --- a/packages/filesystem/limiter.ts +++ b/packages/filesystem/limiter.ts @@ -1,5 +1,5 @@ import type FileSystem from "./filesystem"; -import type { FileInfo, FileReader, FileWriter } from "./filesystem"; +import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem"; const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]); @@ -21,6 +21,7 @@ export class RateLimiter { /** * 执行限速操作 * @param fn 要执行的操作函数 + * @param op 操作类型,用于在遇到 429 时判断是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试 * @returns 操作结果 */ async execute(fn: () => Promise, op = "unknown"): Promise { @@ -47,6 +48,7 @@ export class RateLimiter { /** * 执行操作并处理 429 错误重试 * @param fn 要执行的操作函数 + * @param op 操作类型,用于判定该操作在遇到 429 时是否进入指数退避重试 * @returns 操作结果 */ private async executeWithRetry(fn: () => Promise, op: string): Promise { @@ -68,7 +70,8 @@ export class RateLimiter { throw error; } } - throw new Error("Max retries exceeded"); + // 理论上不会到达这里:循环最后一次的 catch 已经把原始错误抛出 + throw new Error(`Max retries exceeded (op=${op})`); } private shouldRetry429(op: string, errorStr: string): boolean { @@ -111,17 +114,17 @@ export default class LimiterFileSystem implements FileSystem { }, "openDir"); } - async create(path: string): Promise { + async create(path: string, opts?: FileCreateOptions): Promise { return this.limiter.execute(async () => { - const writer = await this.fs.create(path); + const writer = await this.fs.create(path, opts); return { write: (content) => this.limiter.execute(() => writer.write(content), "write"), }; }, "create"); } - createDir(dir: string): Promise { - return this.limiter.execute(() => this.fs.createDir(dir), "createDir"); + createDir(dir: string, opts?: FileCreateOptions): Promise { + return this.limiter.execute(() => this.fs.createDir(dir, opts), "createDir"); } delete(path: string): Promise {