Skip to content

Commit fda7062

Browse files
cyfung1031CodFrm
andauthored
🐛 收紧 LimiterFileSystem 的 429 自动重试范围,避免写操作被盲目重放 (#1376)
* 收紧 `LimiterFileSystem` 的 429 自动重试范围,避免写操作被盲目重放 * update shouldRetry429 condition * update shouldRetry429 condition * 处理 Copilot 评论:补全 op JSDoc / opts 透传 / 重试覆盖测试 - RateLimiter.execute / executeWithRetry 的 JSDoc 补充 op 参数说明 - executeWithRetry 末尾兜底错误带上 op,便于定位(理论上不可达) - LimiterFileSystem.create / createDir 透传 FileCreateOptions(修复预存在的元信息丢失) - 新增测试:verify/open/openDir/getDirUrl 在 429 自动重试,create 不重试 - 新增测试:create / createDir 的 FileCreateOptions 透传 --------- Co-authored-by: 王一之 <yz@ggnb.top>
1 parent a1eb8dc commit fda7062

2 files changed

Lines changed: 216 additions & 20 deletions

File tree

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type FileSystem from "./filesystem";
3+
import type { FileInfo, FileReader, FileWriter } from "./filesystem";
4+
import LimiterFileSystem from "./limiter";
5+
6+
function createFs(): FileSystem {
7+
return {
8+
verify: vi.fn(async () => {}),
9+
open: vi.fn(async () => {
10+
const reader: FileReader = {
11+
read: vi.fn(async () => "content"),
12+
};
13+
return reader;
14+
}),
15+
openDir: vi.fn(async () => createFs()),
16+
create: vi.fn(async () => {
17+
const writer: FileWriter = {
18+
write: vi.fn(async () => {}),
19+
};
20+
return writer;
21+
}),
22+
createDir: vi.fn(async () => {}),
23+
delete: vi.fn(async () => {}),
24+
list: vi.fn(async () => []),
25+
getDirUrl: vi.fn(async () => "url"),
26+
};
27+
}
28+
29+
const file: FileInfo = {
30+
name: "test.user.js",
31+
path: "/test.user.js",
32+
size: 1,
33+
digest: "digest",
34+
createtime: 1,
35+
updatetime: 1,
36+
};
37+
38+
describe("LimiterFileSystem", () => {
39+
afterEach(() => {
40+
vi.useRealTimers();
41+
vi.restoreAllMocks();
42+
});
43+
44+
it("should retry list on 429", async () => {
45+
vi.useFakeTimers();
46+
const fs = createFs();
47+
vi.mocked(fs.list).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce([]);
48+
const limiter = new LimiterFileSystem(fs);
49+
50+
const promise = limiter.list();
51+
await vi.runOnlyPendingTimersAsync();
52+
53+
await expect(promise).resolves.toEqual([]);
54+
expect(fs.list).toHaveBeenCalledTimes(2);
55+
});
56+
57+
it("should retry verify on 429", async () => {
58+
vi.useFakeTimers();
59+
const fs = createFs();
60+
vi.mocked(fs.verify).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce(undefined);
61+
const limiter = new LimiterFileSystem(fs);
62+
63+
const promise = limiter.verify();
64+
await vi.runOnlyPendingTimersAsync();
65+
66+
await expect(promise).resolves.toBeUndefined();
67+
expect(fs.verify).toHaveBeenCalledTimes(2);
68+
});
69+
70+
it("should retry open on 429", async () => {
71+
vi.useFakeTimers();
72+
const fs = createFs();
73+
const reader: FileReader = { read: vi.fn(async () => "content") };
74+
vi.mocked(fs.open).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce(reader);
75+
const limiter = new LimiterFileSystem(fs);
76+
77+
const promise = limiter.open(file);
78+
await vi.runOnlyPendingTimersAsync();
79+
80+
await expect(promise).resolves.toBeDefined();
81+
expect(fs.open).toHaveBeenCalledTimes(2);
82+
});
83+
84+
it("should retry openDir on 429", async () => {
85+
vi.useFakeTimers();
86+
const fs = createFs();
87+
const inner = createFs();
88+
vi.mocked(fs.openDir).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce(inner);
89+
const limiter = new LimiterFileSystem(fs);
90+
91+
const promise = limiter.openDir("/dir");
92+
await vi.runOnlyPendingTimersAsync();
93+
94+
await expect(promise).resolves.toBeDefined();
95+
expect(fs.openDir).toHaveBeenCalledTimes(2);
96+
});
97+
98+
it("should retry getDirUrl on 429", async () => {
99+
vi.useFakeTimers();
100+
const fs = createFs();
101+
vi.mocked(fs.getDirUrl).mockRejectedValueOnce(new Error("429 Too Many Requests")).mockResolvedValueOnce("url");
102+
const limiter = new LimiterFileSystem(fs);
103+
104+
const promise = limiter.getDirUrl();
105+
await vi.runOnlyPendingTimersAsync();
106+
107+
await expect(promise).resolves.toBe("url");
108+
expect(fs.getDirUrl).toHaveBeenCalledTimes(2);
109+
});
110+
111+
it("should not retry create on 429", async () => {
112+
const fs = createFs();
113+
vi.mocked(fs.create).mockRejectedValueOnce(new Error("429 Too Many Requests"));
114+
const limiter = new LimiterFileSystem(fs);
115+
116+
await expect(limiter.create(file.path)).rejects.toThrow("429 Too Many Requests");
117+
expect(fs.create).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it("should pass FileCreateOptions through create", async () => {
121+
const fs = createFs();
122+
const limiter = new LimiterFileSystem(fs);
123+
await limiter.create(file.path, { modifiedDate: 123 });
124+
expect(fs.create).toHaveBeenCalledWith(file.path, { modifiedDate: 123 });
125+
});
126+
127+
it("should pass FileCreateOptions through createDir", async () => {
128+
const fs = createFs();
129+
const limiter = new LimiterFileSystem(fs);
130+
await limiter.createDir("/dir", { modifiedDate: 456 });
131+
expect(fs.createDir).toHaveBeenCalledWith("/dir", { modifiedDate: 456 });
132+
});
133+
134+
it("should not retry delete on 429", async () => {
135+
const fs = createFs();
136+
vi.mocked(fs.delete).mockRejectedValueOnce(new Error("429 Too Many Requests"));
137+
const limiter = new LimiterFileSystem(fs);
138+
139+
await expect(limiter.delete("/test.user.js")).rejects.toThrow("429 Too Many Requests");
140+
expect(fs.delete).toHaveBeenCalledTimes(1);
141+
});
142+
143+
it("should not retry createDir on 429", async () => {
144+
const fs = createFs();
145+
vi.mocked(fs.createDir).mockRejectedValueOnce(new Error("429 Too Many Requests"));
146+
const limiter = new LimiterFileSystem(fs);
147+
148+
await expect(limiter.createDir("/dir")).rejects.toThrow("429 Too Many Requests");
149+
expect(fs.createDir).toHaveBeenCalledTimes(1);
150+
});
151+
152+
it("should not retry writer.write on 429", async () => {
153+
const fs = createFs();
154+
const write = vi.fn(async () => {});
155+
vi.mocked(fs.create).mockResolvedValueOnce({
156+
write,
157+
});
158+
write.mockRejectedValueOnce(new Error("429 Too Many Requests"));
159+
const limiter = new LimiterFileSystem(fs);
160+
const writer = await limiter.create(file.path);
161+
162+
await expect(writer.write("content")).rejects.toThrow("429 Too Many Requests");
163+
expect(write).toHaveBeenCalledTimes(1);
164+
});
165+
166+
it("should retry reader.read on 429", async () => {
167+
vi.useFakeTimers();
168+
const fs = createFs();
169+
const read = vi.fn(async () => "content");
170+
vi.mocked(fs.open).mockResolvedValueOnce({
171+
read,
172+
});
173+
read.mockRejectedValueOnce(new Error("429 Too Many Requests"));
174+
read.mockResolvedValueOnce("content");
175+
const limiter = new LimiterFileSystem(fs);
176+
const reader = await limiter.open(file);
177+
178+
const promise = reader.read("string");
179+
await vi.runOnlyPendingTimersAsync();
180+
181+
await expect(promise).resolves.toBe("content");
182+
expect(read).toHaveBeenCalledTimes(2);
183+
});
184+
});

packages/filesystem/limiter.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type FileSystem from "./filesystem";
2-
import type { FileInfo, FileReader, FileWriter } from "./filesystem";
2+
import type { FileCreateOptions, FileInfo, FileReader, FileWriter } from "./filesystem";
3+
4+
const RETRYABLE_429_OPS = new Set(["verify", "open", "read", "openDir", "list", "getDirUrl"]);
35

46
/**
57
* 速率限制器
@@ -19,9 +21,10 @@ export class RateLimiter {
1921
/**
2022
* 执行限速操作
2123
* @param fn 要执行的操作函数
24+
* @param op 操作类型,用于在遇到 429 时判断是否允许自动重试。默认值 "unknown" 不在白名单内,会被视为不可重试
2225
* @returns 操作结果
2326
*/
24-
async execute<T>(fn: () => Promise<T>): Promise<T> {
27+
async execute<T>(fn: () => Promise<T>, op = "unknown"): Promise<T> {
2528
// 如果当前运行的操作数已达到上限,则等待
2629
while (this.running >= this.maxConcurrent) {
2730
await new Promise<void>((resolve) => {
@@ -31,7 +34,7 @@ export class RateLimiter {
3134

3235
this.running++;
3336
try {
34-
return await this.executeWithRetry(fn);
37+
return await this.executeWithRetry(fn, op);
3538
} finally {
3639
this.running--;
3740
// 执行完成后,从队列中取出下一个等待的操作
@@ -45,17 +48,18 @@ export class RateLimiter {
4548
/**
4649
* 执行操作并处理 429 错误重试
4750
* @param fn 要执行的操作函数
51+
* @param op 操作类型,用于判定该操作在遇到 429 时是否进入指数退避重试
4852
* @returns 操作结果
4953
*/
50-
private async executeWithRetry<T>(fn: () => Promise<T>): Promise<T> {
54+
private async executeWithRetry<T>(fn: () => Promise<T>, op: string): Promise<T> {
5155
// 最多重试 10 次
5256
for (let i = 0; i <= 10; i++) {
5357
try {
5458
return await fn();
5559
} catch (error) {
5660
// 检查错误字符串中是否包含 429
57-
const errorStr = String(error);
58-
if (errorStr.includes("429") && i < 10) {
61+
const errorStr = String(error).toLowerCase();
62+
if (this.shouldRetry429(op, ` ${errorStr} `) && i < 10) {
5963
// 遇到 429 错误且未达到重试上限,采用指数退避策略延迟后继续重试
6064
const delay = Math.min(2000 * Math.pow(2, i), 60000);
6165
await new Promise((resolve) => setTimeout(resolve, delay));
@@ -66,7 +70,15 @@ export class RateLimiter {
6670
throw error;
6771
}
6872
}
69-
throw new Error("Max retries exceeded");
73+
// 理论上不会到达这里:循环最后一次的 catch 已经把原始错误抛出
74+
throw new Error(`Max retries exceeded (op=${op})`);
75+
}
76+
77+
private shouldRetry429(op: string, errorStr: string): boolean {
78+
return (
79+
((errorStr.includes("429") && /[^a-z\d]429[^a-z\d]/.test(errorStr)) || errorStr.includes("too many requests")) &&
80+
RETRYABLE_429_OPS.has(op)
81+
);
7082
}
7183
}
7284

@@ -83,47 +95,47 @@ export default class LimiterFileSystem implements FileSystem {
8395
}
8496

8597
verify(): Promise<void> {
86-
return this.limiter.execute(() => this.fs.verify());
98+
return this.limiter.execute(() => this.fs.verify(), "verify");
8799
}
88100

89101
async open(file: FileInfo): Promise<FileReader> {
90102
return this.limiter.execute(async () => {
91103
const reader = await this.fs.open(file);
92104
return {
93-
read: (type) => this.limiter.execute(() => reader.read(type)),
105+
read: (type) => this.limiter.execute(() => reader.read(type), "read"),
94106
};
95-
});
107+
}, "open");
96108
}
97109

98110
async openDir(path: string): Promise<FileSystem> {
99111
return this.limiter.execute(async () => {
100112
const fs = await this.fs.openDir(path);
101113
return new LimiterFileSystem(fs, this.limiter);
102-
});
114+
}, "openDir");
103115
}
104116

105-
async create(path: string): Promise<FileWriter> {
117+
async create(path: string, opts?: FileCreateOptions): Promise<FileWriter> {
106118
return this.limiter.execute(async () => {
107-
const writer = await this.fs.create(path);
119+
const writer = await this.fs.create(path, opts);
108120
return {
109-
write: (content) => this.limiter.execute(() => writer.write(content)),
121+
write: (content) => this.limiter.execute(() => writer.write(content), "write"),
110122
};
111-
});
123+
}, "create");
112124
}
113125

114-
createDir(dir: string): Promise<void> {
115-
return this.limiter.execute(() => this.fs.createDir(dir));
126+
createDir(dir: string, opts?: FileCreateOptions): Promise<void> {
127+
return this.limiter.execute(() => this.fs.createDir(dir, opts), "createDir");
116128
}
117129

118130
delete(path: string): Promise<void> {
119-
return this.limiter.execute(() => this.fs.delete(path));
131+
return this.limiter.execute(() => this.fs.delete(path), "delete");
120132
}
121133

122134
list(): Promise<FileInfo[]> {
123-
return this.limiter.execute(() => this.fs.list());
135+
return this.limiter.execute(() => this.fs.list(), "list");
124136
}
125137

126138
getDirUrl(): Promise<string> {
127-
return this.limiter.execute(() => this.fs.getDirUrl());
139+
return this.limiter.execute(() => this.fs.getDirUrl(), "getDirUrl");
128140
}
129141
}

0 commit comments

Comments
 (0)