Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions packages/filesystem/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
217 changes: 215 additions & 2 deletions packages/filesystem/googledrive/googledrive.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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,
});
}
});
});
51 changes: 47 additions & 4 deletions packages/filesystem/googledrive/googledrive.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, unknown> }).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<FileSystemError> {
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 = <Headers>config.headers || new Headers();
Expand All @@ -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();
})
Expand All @@ -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;
});
Expand Down
Loading
Loading