Skip to content

Commit 419178e

Browse files
committed
fix(sync): 规范化 Google Drive 和 OneDrive 的文件系统请求错误
1 parent 9acb2f1 commit 419178e

5 files changed

Lines changed: 585 additions & 11 deletions

File tree

packages/filesystem/error.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,68 @@ export class WarpNetworkError {
2121
export function isNetworkError(error: any): error is WarpNetworkError {
2222
return error instanceof WarpNetworkError;
2323
}
24+
25+
export type FileSystemProvider = "googledrive" | "onedrive" | "dropbox" | "baidu" | "webdav" | "s3" | "zip";
26+
27+
export type FileSystemErrorOptions = {
28+
provider: FileSystemProvider;
29+
message: string;
30+
status?: number;
31+
code?: string;
32+
retryable?: boolean;
33+
conflict?: boolean;
34+
auth?: boolean;
35+
notFound?: boolean;
36+
rateLimit?: boolean;
37+
raw?: unknown;
38+
};
39+
40+
export class FileSystemError extends Error {
41+
provider: FileSystemProvider;
42+
43+
status?: number;
44+
45+
code?: string;
46+
47+
retryable: boolean;
48+
49+
conflict: boolean;
50+
51+
auth: boolean;
52+
53+
notFound: boolean;
54+
55+
rateLimit: boolean;
56+
57+
raw?: unknown;
58+
59+
constructor(options: FileSystemErrorOptions) {
60+
super(options.message);
61+
this.name = "FileSystemError";
62+
this.provider = options.provider;
63+
this.status = options.status;
64+
this.code = options.code;
65+
this.retryable = options.retryable ?? false;
66+
this.conflict = options.conflict ?? false;
67+
this.auth = options.auth ?? false;
68+
this.notFound = options.notFound ?? false;
69+
this.rateLimit = options.rateLimit ?? false;
70+
this.raw = options.raw;
71+
}
72+
}
73+
74+
export function isNotFoundError(error: unknown): error is FileSystemError {
75+
return error instanceof FileSystemError && error.notFound;
76+
}
77+
78+
export function isConflictError(error: unknown): error is FileSystemError {
79+
return error instanceof FileSystemError && error.conflict;
80+
}
81+
82+
export function isRateLimitError(error: unknown): error is FileSystemError {
83+
return error instanceof FileSystemError && error.rateLimit;
84+
}
85+
86+
export function isAuthError(error: unknown): error is FileSystemError | WarpTokenError {
87+
return error instanceof FileSystemError ? error.auth : isWarpTokenError(error);
88+
}

packages/filesystem/googledrive/googledrive.test.ts

Lines changed: 215 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { LocalStorageDAO } from "@App/app/repo/localStorage";
3+
import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error";
24
import GoogleDriveFileSystem from "./googledrive";
35

6+
function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response {
7+
const { ok = true, status = 200, text = "", json = {} } = options;
8+
return {
9+
ok,
10+
status,
11+
text: vi.fn().mockResolvedValue(text),
12+
json: vi.fn().mockResolvedValue(json),
13+
headers: new Headers(),
14+
} as unknown as Response;
15+
}
16+
417
describe("GoogleDriveFileSystem", () => {
5-
beforeEach(() => {
18+
const localStorageDAO = new LocalStorageDAO();
19+
let originalFetch: typeof fetch;
20+
21+
beforeEach(async () => {
622
vi.clearAllMocks();
23+
await chrome.storage.local.clear();
24+
originalFetch = globalThis.fetch;
25+
});
26+
27+
afterEach(() => {
28+
vi.stubGlobal("fetch", originalFetch);
729
});
830

931
it("delete should be idempotent when file id is missing", async () => {
@@ -59,4 +81,195 @@ describe("GoogleDriveFileSystem", () => {
5981
expect(findSpy).toHaveBeenCalledWith("file.txt", "base-id");
6082
expect(requestSpy).toHaveBeenCalledTimes(1);
6183
});
84+
85+
it("request should return retry result after token refresh", async () => {
86+
await localStorageDAO.saveValue("netdisk:token:googledrive", {
87+
accessToken: "expired-token",
88+
refreshToken: "refresh-token",
89+
createtime: Date.now(),
90+
});
91+
92+
const fs = new GoogleDriveFileSystem("/", "expired-token");
93+
const fetchMock = vi
94+
.fn()
95+
.mockResolvedValueOnce(
96+
createMockResponse({
97+
ok: false,
98+
status: 401,
99+
text: JSON.stringify({
100+
error: {
101+
code: 401,
102+
message: "Invalid Credentials",
103+
status: "UNAUTHENTICATED",
104+
},
105+
}),
106+
})
107+
)
108+
.mockResolvedValueOnce({
109+
json: vi.fn().mockResolvedValue({
110+
code: 0,
111+
data: {
112+
token: {
113+
access_token: "fresh-token",
114+
refresh_token: "fresh-refresh-token",
115+
},
116+
},
117+
}),
118+
} as unknown as Response)
119+
.mockResolvedValueOnce(
120+
createMockResponse({
121+
json: {
122+
files: [{ id: "ok" }],
123+
},
124+
})
125+
);
126+
vi.stubGlobal("fetch", fetchMock);
127+
128+
const data = await fs.request("https://www.googleapis.com/drive/v3/files");
129+
130+
expect(data.files).toHaveLength(1);
131+
expect(fetchMock).toHaveBeenCalledTimes(3);
132+
});
133+
134+
it("request should throw auth error when retry still gets 401", async () => {
135+
await localStorageDAO.saveValue("netdisk:token:googledrive", {
136+
accessToken: "expired-token",
137+
refreshToken: "refresh-token",
138+
createtime: Date.now(),
139+
});
140+
141+
const fs = new GoogleDriveFileSystem("/", "expired-token");
142+
vi.stubGlobal(
143+
"fetch",
144+
vi
145+
.fn()
146+
.mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "expired" }))
147+
.mockResolvedValueOnce({
148+
json: vi.fn().mockResolvedValue({
149+
code: 0,
150+
data: {
151+
token: {
152+
access_token: "fresh-token",
153+
refresh_token: "fresh-refresh-token",
154+
},
155+
},
156+
}),
157+
} as unknown as Response)
158+
.mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "still expired" }))
159+
);
160+
161+
try {
162+
await fs.request("https://www.googleapis.com/drive/v3/files");
163+
throw new Error("Expected request to fail");
164+
} catch (error) {
165+
expect(error).toBeInstanceOf(FileSystemError);
166+
expect(isAuthError(error)).toBe(true);
167+
expect(error).toMatchObject({
168+
provider: "googledrive",
169+
status: 401,
170+
auth: true,
171+
});
172+
}
173+
});
174+
175+
it("request should throw typed not found error", async () => {
176+
const fs = new GoogleDriveFileSystem("/", "token");
177+
vi.stubGlobal(
178+
"fetch",
179+
vi.fn().mockResolvedValueOnce(
180+
createMockResponse({
181+
ok: false,
182+
status: 404,
183+
text: JSON.stringify({
184+
error: {
185+
code: 404,
186+
message: "File not found",
187+
status: "NOT_FOUND",
188+
},
189+
}),
190+
})
191+
)
192+
);
193+
194+
try {
195+
await fs.request("https://www.googleapis.com/drive/v3/files/missing");
196+
throw new Error("Expected request to fail");
197+
} catch (error) {
198+
expect(error).toBeInstanceOf(FileSystemError);
199+
expect(isNotFoundError(error)).toBe(true);
200+
expect(error).toMatchObject({
201+
provider: "googledrive",
202+
status: 404,
203+
code: "NOT_FOUND",
204+
notFound: true,
205+
});
206+
}
207+
});
208+
209+
it.each([409, 412])("request should throw typed conflict error for status %s", async (status) => {
210+
const fs = new GoogleDriveFileSystem("/", "token");
211+
vi.stubGlobal(
212+
"fetch",
213+
vi.fn().mockResolvedValueOnce(
214+
createMockResponse({
215+
ok: false,
216+
status,
217+
text: JSON.stringify({
218+
error: {
219+
code: status,
220+
message: "Conflict",
221+
status: status === 409 ? "ABORTED" : "FAILED_PRECONDITION",
222+
},
223+
}),
224+
})
225+
)
226+
);
227+
228+
try {
229+
await fs.request("https://www.googleapis.com/drive/v3/files/conflict");
230+
throw new Error("Expected request to fail");
231+
} catch (error) {
232+
expect(error).toBeInstanceOf(FileSystemError);
233+
expect(isConflictError(error)).toBe(true);
234+
expect(error).toMatchObject({
235+
provider: "googledrive",
236+
status,
237+
conflict: true,
238+
});
239+
}
240+
});
241+
242+
it("request should throw typed rate-limit error", async () => {
243+
const fs = new GoogleDriveFileSystem("/", "token");
244+
vi.stubGlobal(
245+
"fetch",
246+
vi.fn().mockResolvedValueOnce(
247+
createMockResponse({
248+
ok: false,
249+
status: 429,
250+
text: JSON.stringify({
251+
error: {
252+
code: 429,
253+
message: "Quota exceeded",
254+
status: "RESOURCE_EXHAUSTED",
255+
},
256+
}),
257+
})
258+
)
259+
);
260+
261+
try {
262+
await fs.request("https://www.googleapis.com/drive/v3/files");
263+
throw new Error("Expected request to fail");
264+
} catch (error) {
265+
expect(error).toBeInstanceOf(FileSystemError);
266+
expect(isRateLimitError(error)).toBe(true);
267+
expect(error).toMatchObject({
268+
provider: "googledrive",
269+
status: 429,
270+
retryable: true,
271+
rateLimit: true,
272+
});
273+
}
274+
});
62275
});

packages/filesystem/googledrive/googledrive.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AuthVerify } from "../auth";
2+
import { FileSystemError } from "../error";
23
import type FileSystem from "../filesystem";
34
import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem";
45
import { joinPath } from "../utils";
@@ -115,6 +116,48 @@ export default class GoogleDriveFileSystem implements FileSystem {
115116
};
116117
}
117118

119+
private createRequestError(raw: unknown, status?: number): FileSystemError {
120+
const errorBody =
121+
raw && typeof raw === "object" && "error" in raw ? (raw as { error?: Record<string, unknown> }).error : undefined;
122+
const googleStatus = typeof errorBody?.code === "number" ? errorBody.code : status;
123+
const code =
124+
typeof errorBody?.status === "string"
125+
? errorBody.status
126+
: typeof errorBody?.code === "number"
127+
? String(errorBody.code)
128+
: undefined;
129+
const message =
130+
typeof errorBody?.message === "string"
131+
? errorBody.message
132+
: typeof raw === "string" && raw
133+
? raw
134+
: `Google Drive request failed${googleStatus ? ` with status ${googleStatus}` : ""}`;
135+
136+
return new FileSystemError({
137+
provider: "googledrive",
138+
message,
139+
status: googleStatus,
140+
code,
141+
auth: googleStatus === 401,
142+
notFound: googleStatus === 404,
143+
conflict: googleStatus === 409 || googleStatus === 412,
144+
rateLimit: googleStatus === 429,
145+
retryable: googleStatus === 429 || (googleStatus !== undefined && googleStatus >= 500),
146+
raw,
147+
});
148+
}
149+
150+
private async createResponseError(resp: Response): Promise<FileSystemError> {
151+
const text = await resp.text();
152+
let raw;
153+
try {
154+
raw = text ? JSON.parse(text) : "";
155+
} catch {
156+
raw = text;
157+
}
158+
return this.createRequestError(raw, resp.status);
159+
}
160+
118161
request(url: string, config?: RequestInit, nothen?: boolean) {
119162
config = config || {};
120163
const headers = <Headers>config.headers || new Headers();
@@ -141,7 +184,7 @@ export default class GoogleDriveFileSystem implements FileSystem {
141184
resp = await retryWithFreshToken();
142185
}
143186
if (!resp.ok) {
144-
throw new Error(await resp.text());
187+
throw await this.createResponseError(resp);
145188
}
146189
return resp.json();
147190
})
@@ -152,18 +195,18 @@ export default class GoogleDriveFileSystem implements FileSystem {
152195
return retryWithFreshToken()
153196
.then(async (retryResp) => {
154197
if (!retryResp.ok) {
155-
throw new Error(await retryResp.text());
198+
throw await this.createResponseError(retryResp);
156199
}
157200
return retryResp.json();
158201
})
159202
.then((retryData) => {
160203
if (retryData.error) {
161-
throw new Error(JSON.stringify(retryData));
204+
throw this.createRequestError(retryData);
162205
}
163206
return retryData;
164207
});
165208
}
166-
throw new Error(JSON.stringify(data));
209+
throw this.createRequestError(data);
167210
}
168211
return data;
169212
});

0 commit comments

Comments
 (0)