diff --git a/packages/filesystem/error.ts b/packages/filesystem/error.ts index 67b744da0..f6bcd1f53 100644 --- a/packages/filesystem/error.ts +++ b/packages/filesystem/error.ts @@ -21,3 +21,68 @@ export class WarpNetworkError { export function isNetworkError(error: any): error is WarpNetworkError { return error instanceof WarpNetworkError; } + +export type FileSystemProvider = "googledrive" | "onedrive" | "dropbox" | "baidu" | "webdav" | "s3" | "zip"; + +export type FileSystemErrorOptions = { + provider: FileSystemProvider; + message: string; + status?: number; + code?: string; + retryable?: boolean; + conflict?: boolean; + auth?: boolean; + notFound?: boolean; + rateLimit?: boolean; + raw?: unknown; +}; + +export class FileSystemError extends Error { + provider: FileSystemProvider; + + status?: number; + + code?: string; + + retryable: boolean; + + conflict: boolean; + + auth: boolean; + + notFound: boolean; + + rateLimit: boolean; + + raw?: unknown; + + constructor(options: FileSystemErrorOptions) { + super(options.message); + this.name = "FileSystemError"; + this.provider = options.provider; + this.status = options.status; + this.code = options.code; + this.retryable = options.retryable ?? false; + this.conflict = options.conflict ?? false; + this.auth = options.auth ?? false; + this.notFound = options.notFound ?? false; + this.rateLimit = options.rateLimit ?? false; + this.raw = options.raw; + } +} + +export function isNotFoundError(error: unknown): error is FileSystemError { + return error instanceof FileSystemError && error.notFound; +} + +export function isConflictError(error: unknown): error is FileSystemError { + return error instanceof FileSystemError && error.conflict; +} + +export function isRateLimitError(error: unknown): error is FileSystemError { + return error instanceof FileSystemError && error.rateLimit; +} + +export function isAuthError(error: unknown): error is FileSystemError | WarpTokenError { + return error instanceof FileSystemError ? error.auth : isWarpTokenError(error); +} diff --git a/packages/filesystem/googledrive/googledrive.test.ts b/packages/filesystem/googledrive/googledrive.test.ts index ccd276a92..2f08656bb 100644 --- a/packages/filesystem/googledrive/googledrive.test.ts +++ b/packages/filesystem/googledrive/googledrive.test.ts @@ -1,9 +1,31 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LocalStorageDAO } from "@App/app/repo/localStorage"; +import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error"; import GoogleDriveFileSystem from "./googledrive"; +function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response { + const { ok = true, status = 200, text = "", json = {} } = options; + return { + ok, + status, + text: vi.fn().mockResolvedValue(text), + json: vi.fn().mockResolvedValue(json), + headers: new Headers(), + } as unknown as Response; +} + describe("GoogleDriveFileSystem", () => { - beforeEach(() => { + const localStorageDAO = new LocalStorageDAO(); + let originalFetch: typeof fetch; + + beforeEach(async () => { vi.clearAllMocks(); + await chrome.storage.local.clear(); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + vi.stubGlobal("fetch", originalFetch); }); it("delete should be idempotent when file id is missing", async () => { @@ -59,4 +81,195 @@ describe("GoogleDriveFileSystem", () => { expect(findSpy).toHaveBeenCalledWith("file.txt", "base-id"); expect(requestSpy).toHaveBeenCalledTimes(1); }); + + it("request should return retry result after token refresh", async () => { + await localStorageDAO.saveValue("netdisk:token:googledrive", { + accessToken: "expired-token", + refreshToken: "refresh-token", + createtime: Date.now(), + }); + + const fs = new GoogleDriveFileSystem("/", "expired-token"); + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 401, + text: JSON.stringify({ + error: { + code: 401, + message: "Invalid Credentials", + status: "UNAUTHENTICATED", + }, + }), + }) + ) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + code: 0, + data: { + token: { + access_token: "fresh-token", + refresh_token: "fresh-refresh-token", + }, + }, + }), + } as unknown as Response) + .mockResolvedValueOnce( + createMockResponse({ + json: { + files: [{ id: "ok" }], + }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + + const data = await fs.request("https://www.googleapis.com/drive/v3/files"); + + expect(data.files).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("request should throw auth error when retry still gets 401", async () => { + await localStorageDAO.saveValue("netdisk:token:googledrive", { + accessToken: "expired-token", + refreshToken: "refresh-token", + createtime: Date.now(), + }); + + const fs = new GoogleDriveFileSystem("/", "expired-token"); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "expired" })) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + code: 0, + data: { + token: { + access_token: "fresh-token", + refresh_token: "fresh-refresh-token", + }, + }, + }), + } as unknown as Response) + .mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "still expired" })) + ); + + try { + await fs.request("https://www.googleapis.com/drive/v3/files"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isAuthError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "googledrive", + status: 401, + auth: true, + }); + } + }); + + it("request should throw typed not found error", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + text: JSON.stringify({ + error: { + code: 404, + message: "File not found", + status: "NOT_FOUND", + }, + }), + }) + ) + ); + + try { + await fs.request("https://www.googleapis.com/drive/v3/files/missing"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isNotFoundError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "googledrive", + status: 404, + code: "NOT_FOUND", + notFound: true, + }); + } + }); + + it.each([409, 412])("request should throw typed conflict error for status %s", async (status) => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + createMockResponse({ + ok: false, + status, + text: JSON.stringify({ + error: { + code: status, + message: "Conflict", + status: status === 409 ? "ABORTED" : "FAILED_PRECONDITION", + }, + }), + }) + ) + ); + + try { + await fs.request("https://www.googleapis.com/drive/v3/files/conflict"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isConflictError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "googledrive", + status, + conflict: true, + }); + } + }); + + it("request should throw typed rate-limit error", async () => { + const fs = new GoogleDriveFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: 429, + message: "Quota exceeded", + status: "RESOURCE_EXHAUSTED", + }, + }), + }) + ) + ); + + try { + await fs.request("https://www.googleapis.com/drive/v3/files"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isRateLimitError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "googledrive", + status: 429, + retryable: true, + rateLimit: true, + }); + } + }); }); diff --git a/packages/filesystem/googledrive/googledrive.ts b/packages/filesystem/googledrive/googledrive.ts index fc3821787..27b4cfb57 100644 --- a/packages/filesystem/googledrive/googledrive.ts +++ b/packages/filesystem/googledrive/googledrive.ts @@ -1,4 +1,5 @@ import { AuthVerify } from "../auth"; +import { FileSystemError } from "../error"; import type FileSystem from "../filesystem"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; @@ -115,6 +116,48 @@ export default class GoogleDriveFileSystem implements FileSystem { }; } + private createRequestError(raw: unknown, status?: number): FileSystemError { + const errorBody = + raw && typeof raw === "object" && "error" in raw ? (raw as { error?: Record }).error : undefined; + const googleStatus = typeof errorBody?.code === "number" ? errorBody.code : status; + const code = + typeof errorBody?.status === "string" + ? errorBody.status + : typeof errorBody?.code === "number" + ? String(errorBody.code) + : undefined; + const message = + typeof errorBody?.message === "string" + ? errorBody.message + : typeof raw === "string" && raw + ? raw + : `Google Drive request failed${googleStatus ? ` with status ${googleStatus}` : ""}`; + + return new FileSystemError({ + provider: "googledrive", + message, + status: googleStatus, + code, + auth: googleStatus === 401, + notFound: googleStatus === 404, + conflict: googleStatus === 409 || googleStatus === 412, + rateLimit: googleStatus === 429, + retryable: googleStatus === 429 || (googleStatus !== undefined && googleStatus >= 500), + raw, + }); + } + + private async createResponseError(resp: Response): Promise { + const text = await resp.text(); + let raw; + try { + raw = text ? JSON.parse(text) : ""; + } catch { + raw = text; + } + return this.createRequestError(raw, resp.status); + } + request(url: string, config?: RequestInit, nothen?: boolean) { config = config || {}; const headers = config.headers || new Headers(); @@ -141,7 +184,7 @@ export default class GoogleDriveFileSystem implements FileSystem { resp = await retryWithFreshToken(); } if (!resp.ok) { - throw new Error(await resp.text()); + throw await this.createResponseError(resp); } return resp.json(); }) @@ -152,18 +195,18 @@ export default class GoogleDriveFileSystem implements FileSystem { return retryWithFreshToken() .then(async (retryResp) => { if (!retryResp.ok) { - throw new Error(await retryResp.text()); + throw await this.createResponseError(retryResp); } return retryResp.json(); }) .then((retryData) => { if (retryData.error) { - throw new Error(JSON.stringify(retryData)); + throw this.createRequestError(retryData); } return retryData; }); } - throw new Error(JSON.stringify(data)); + throw this.createRequestError(data); } return data; }); diff --git a/packages/filesystem/onedrive/onedrive.test.ts b/packages/filesystem/onedrive/onedrive.test.ts index b9c1deca4..231135163 100644 --- a/packages/filesystem/onedrive/onedrive.test.ts +++ b/packages/filesystem/onedrive/onedrive.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import OneDriveFileSystem from "./onedrive"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; +import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error"; function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response { const { ok = true, status = 200, text = "", json = {} } = options; @@ -117,7 +118,16 @@ describe("OneDriveFileSystem", () => { const fs = new OneDriveFileSystem("/", "token"); const requestSpy = vi .spyOn(fs, "request") - .mockRejectedValueOnce(new Error('{"error":{"code":"nameAlreadyExists"}}')) + .mockRejectedValueOnce( + new FileSystemError({ + provider: "onedrive", + message: "already exists", + status: 409, + code: "nameAlreadyExists", + conflict: true, + raw: { error: { code: "nameAlreadyExists" } }, + }) + ) .mockResolvedValueOnce({}); await expect(fs.createDir("A/B")).resolves.toBeUndefined(); @@ -127,4 +137,147 @@ describe("OneDriveFileSystem", () => { name: "B", }); }); + + it("request should throw auth error when retry still gets 401", async () => { + await localStorageDAO.saveValue("netdisk:token:onedrive", { + accessToken: "expired-token", + refreshToken: "refresh-token", + createtime: Date.now(), + }); + + const fs = new OneDriveFileSystem("/", "expired-token"); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "expired" })) + .mockResolvedValueOnce({ + json: vi.fn().mockResolvedValue({ + code: 0, + data: { + token: { + access_token: "fresh-token", + refresh_token: "fresh-refresh-token", + }, + }, + }), + } as unknown as Response) + .mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "still expired" })) + ); + + try { + await fs.request("https://graph.microsoft.com/v1.0/me/drive/special/approot/children"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isAuthError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "onedrive", + status: 401, + auth: true, + }); + } + }); + + it("request should throw typed not found error", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + text: JSON.stringify({ + error: { + code: "itemNotFound", + message: "Item not found", + }, + }), + }) + ) + ); + + try { + await fs.request("https://graph.microsoft.com/v1.0/me/drive/special/approot:/missing"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isNotFoundError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "onedrive", + status: 404, + code: "itemNotFound", + notFound: true, + }); + } + }); + + it.each([ + [409, "nameAlreadyExists"], + [412, "PreconditionFailed"], + ])("request should throw typed conflict error for status %s", async (status, code) => { + const fs = new OneDriveFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + createMockResponse({ + ok: false, + status, + text: JSON.stringify({ + error: { + code, + message: "Conflict", + }, + }), + }) + ) + ); + + try { + await fs.request("https://graph.microsoft.com/v1.0/me/drive/special/approot:/conflict"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isConflictError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "onedrive", + status, + code, + conflict: true, + }); + } + }); + + it("request should throw typed rate-limit error", async () => { + const fs = new OneDriveFileSystem("/", "token"); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 429, + text: JSON.stringify({ + error: { + code: "TooManyRequests", + message: "Too many requests", + }, + }), + }) + ) + ); + + try { + await fs.request("https://graph.microsoft.com/v1.0/me/drive/special/approot/children"); + throw new Error("Expected request to fail"); + } catch (error) { + expect(error).toBeInstanceOf(FileSystemError); + expect(isRateLimitError(error)).toBe(true); + expect(error).toMatchObject({ + provider: "onedrive", + status: 429, + retryable: true, + rateLimit: true, + }); + } + }); }); diff --git a/packages/filesystem/onedrive/onedrive.ts b/packages/filesystem/onedrive/onedrive.ts index 0a0f686b1..3a2bef2b0 100644 --- a/packages/filesystem/onedrive/onedrive.ts +++ b/packages/filesystem/onedrive/onedrive.ts @@ -1,4 +1,5 @@ import { AuthVerify } from "../auth"; +import { FileSystemError } from "../error"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import type FileSystem from "../filesystem"; import { joinPath } from "../utils"; @@ -72,10 +73,58 @@ export default class OneDriveFileSystem implements FileSystem { } private isDirectoryAlreadyExistsError(error: unknown): boolean { + if (error instanceof FileSystemError && error.conflict) { + return true; + } const msg = String(error); return msg.includes("nameAlreadyExists") || msg.includes("itemAlreadyExists"); } + private createRequestError(raw: unknown, status?: number): FileSystemError { + const errorBody = + raw && typeof raw === "object" && "error" in raw ? (raw as { error?: Record }).error : undefined; + const code = typeof errorBody?.code === "string" ? errorBody.code : undefined; + const message = + typeof errorBody?.message === "string" + ? errorBody.message + : typeof raw === "string" && raw + ? raw + : `OneDrive request failed${status ? ` with status ${status}` : ""}`; + const auth = status === 401 || code === "InvalidAuthenticationToken"; + const notFound = status === 404 || code === "itemNotFound"; + const conflict = + status === 409 || + status === 412 || + code === "nameAlreadyExists" || + code === "itemAlreadyExists" || + code === "PreconditionFailed"; + const rateLimit = status === 429; + + return new FileSystemError({ + provider: "onedrive", + message, + status, + code, + auth, + notFound, + conflict, + rateLimit, + retryable: rateLimit || (status !== undefined && status >= 500), + raw, + }); + } + + private async createResponseError(resp: Response): Promise { + const text = await resp.text(); + let raw; + try { + raw = text ? JSON.parse(text) : ""; + } catch { + raw = text; + } + return this.createRequestError(raw, resp.status); + } + request(url: string, config?: RequestInit, nothen?: boolean): Promise { config = config || {}; const headers = config.headers || new Headers(); @@ -106,7 +155,7 @@ export default class OneDriveFileSystem implements FileSystem { resp = await retryWithFreshToken(); } if (!resp.ok) { - throw new Error(await resp.text()); + throw await this.createResponseError(resp); } return resp.json(); }) @@ -116,18 +165,18 @@ export default class OneDriveFileSystem implements FileSystem { return retryWithFreshToken() .then(async (retryResp) => { if (!retryResp.ok) { - throw new Error(await retryResp.text()); + throw await this.createResponseError(retryResp); } return retryResp.json(); }) .then((retryData) => { if (retryData.error) { - throw new Error(JSON.stringify(retryData)); + throw this.createRequestError(retryData); } return retryData; }); } - throw new Error(JSON.stringify(data)); + throw this.createRequestError(data); } return data; });