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
11 changes: 4 additions & 7 deletions src/services/drive/drive-file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,21 @@ export class DriveFileService {

const [getFileMetadata] = storageClient.getFile(uuid);

const fileMetadata = await getFileMetadata;
if (fileMetadata?.status !== FileStatus.EXISTS) {
const driveFileItem = DriveUtils.driveFileMetaToItem(await getFileMetadata);
if (driveFileItem.status !== FileStatus.EXISTS) {
throw new NotFoundError(`File with uuid ${uuid} not found`);
}
const driveFileItem = DriveUtils.driveFileMetaToItem(fileMetadata);

return driveFileItem;
};

public getFileMetadataByPath = async (path: string): Promise<DriveFileItem> => {
const storageClient = SdkManager.instance.getStorage();

const fileMetadata = await storageClient.getFileByPath(path);

if (fileMetadata?.status !== FileStatus.EXISTS) {
const driveFileItem = DriveUtils.driveFileMetaToItem(await storageClient.getFileByPath(path));
if (driveFileItem.status !== FileStatus.EXISTS) {
throw new NotFoundError(`File with path ${path} not found`);
}
const driveFileItem = DriveUtils.driveFileMetaToItem(fileMetadata);

return driveFileItem;
};
Expand Down
2 changes: 1 addition & 1 deletion src/services/drive/drive-folder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class DriveFolderService {
const storageClient = SdkManager.instance.getStorage();
const folderMeta = await storageClient.getFolderMeta(uuid);
const folderItem = DriveUtils.driveFolderMetaToItem(folderMeta);
if (folderItem?.status !== FileStatus.EXISTS) {
if (folderItem.status !== FileStatus.EXISTS) {
throw new NotFoundError(`Folder with uuid ${uuid} not found`);
}
return folderItem;
Expand Down
39 changes: 32 additions & 7 deletions src/services/drive/drive-item.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DriveItemRepository } from '../database/drive-item/drive-item.repository';
import { DriveFileItem, DriveFolderItem } from '../../types/drive.types';
import { ErrorUtils, NotFoundError } from '../../utils/errors.utils';
import { BadGatewayError, ErrorUtils, NotFoundError, ServiceUnavailableError } from '../../utils/errors.utils';
import { webdavLogger } from '../../utils/logger.utils';
import { DriveFileService } from './drive-file.service';
import { DriveFolderService } from './drive-folder.service';
Expand All @@ -9,6 +9,31 @@ import { DriveItemBD } from '../database/drive-item/drive-item.domain';
export class DriveItemService {
static readonly instance = new DriveItemService();

/** Only drop the cached uuid when the API confirms the item is gone; evicting on a timeout
* pushes every later request onto the slow path lookup. */
private readonly dropCacheIfGone = async (uuid: string, error: unknown): Promise<void> => {
if (!ErrorUtils.isNotFoundError(error)) return;
await DriveItemRepository.instance.delete([uuid]);
};

private readonly asLookupError = (itemType: 'File' | 'Folder', path: string, error: unknown): Error => {
switch (ErrorUtils.classifyLookupError(error)) {
case 'auth':
return new BadGatewayError(
ErrorUtils.withRequestId(
`The Internxt API rejected this session while looking up ${path}, log in again with 'internxt login'`,
error,
),
);
case 'inconclusive':
return new ServiceUnavailableError(
ErrorUtils.withRequestId(`${itemType} lookup at path ${path} could not be completed, retry later`, error),
);
case 'not-found':
return new NotFoundError(`${itemType} not found at path: ${path}`);
}
};

private readonly tryGetFileByUuid = async (cached: DriveItemBD, path: string): Promise<DriveFileItem | undefined> => {
try {
const item = await DriveFileService.instance.getFileMetadata(cached.uuid);
Expand All @@ -27,7 +52,7 @@ export class DriveItemService {
path,
uuid: cached.uuid,
});
await DriveItemRepository.instance.delete([cached.uuid]);
await this.dropCacheIfGone(cached.uuid, error);
}
};

Expand All @@ -52,14 +77,14 @@ export class DriveItemService {
ErrorUtils.withRequestId('Folder metadata by uuid failed, falling back to path lookup', error),
{ path, uuid: cached.uuid },
);
await DriveItemRepository.instance.delete([cached.uuid]);
await this.dropCacheIfGone(cached.uuid, error);
}
};

public getFileByPath = async (path: string): Promise<DriveFileItem> => {
const cached = await DriveItemRepository.instance.getByPath(path);

if (cached) {
if (cached?.type === 'file') {
const item = await this.tryGetFileByUuid(cached, path);
if (item) return item;
}
Expand All @@ -78,14 +103,14 @@ export class DriveItemService {
return item;
} catch (error) {
ErrorUtils.logIfUnexpected(webdavLogger, 'File lookup by path failed', error, { path });
throw new NotFoundError(`File not found at path: ${path}`);
throw this.asLookupError('File', path, error);
}
};

public getFolderByPath = async (path: string): Promise<DriveFolderItem> => {
const cached = await DriveItemRepository.instance.getByPath(path);

if (cached) {
if (cached?.type === 'folder') {
const item = await this.tryGetFolderByUuid(cached, path);
if (item) return item;
}
Expand All @@ -104,7 +129,7 @@ export class DriveItemService {
return item;
} catch (error) {
ErrorUtils.logIfUnexpected(webdavLogger, 'Folder lookup by path failed', error, { path });
throw new NotFoundError(`Folder not found at path: ${path}`);
throw this.asLookupError('Folder', path, error);
}
};
}
12 changes: 11 additions & 1 deletion src/utils/drive.utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { FileMeta, FolderMeta, CreateFolderResponse, FileStatus } from '@internxt/sdk/dist/drive/storage/types';
import { DriveFileItem, DriveFolderItem } from '../types/drive.types';
import { ServiceUnavailableError } from './errors.utils';

export class DriveUtils {

private static assertUsableMeta(meta: { uuid?: string }, kind: 'file' | 'folder'): void {
if (!meta?.uuid) {
throw new ServiceUnavailableError(`Unusable ${kind} metadata received from the API`);
}
}

// WebDAV clients parse getcontentlength/Content-Length as an integer, a literal
// "NaN" in the response breaks them, so any non-numeric size degrades to 0.
static parseFileSize(size: string | number): number {
Expand All @@ -10,9 +18,10 @@ export class DriveUtils {
}

static driveFileMetaToItem(fileMeta: FileMeta): DriveFileItem {
DriveUtils.assertUsableMeta(fileMeta, 'file');
return {
itemType: 'file',
uuid: fileMeta.uuid ?? '',
uuid: fileMeta.uuid,
status: fileMeta.status,
folderUuid: fileMeta.folderUuid,
size: DriveUtils.parseFileSize(fileMeta.size),
Expand All @@ -28,6 +37,7 @@ export class DriveUtils {
}

static driveFolderMetaToItem(folderMeta: FolderMeta): DriveFolderItem {
DriveUtils.assertUsableMeta(folderMeta, 'folder');
return {
itemType: 'folder',
uuid: folderMeta.uuid,
Expand Down
89 changes: 80 additions & 9 deletions src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Logger } from 'winston';
import { logger } from './logger.utils';

export type LookupErrorKind = 'not-found' | 'auth' | 'inconclusive';

export const DEFAULT_RETRY_AFTER_SECONDS = 5;

export class ErrorUtils {
static readonly isError = (error: unknown): error is Error => {
return typeof Error.isError === 'function'
Expand Down Expand Up @@ -51,18 +55,60 @@ export class ErrorUtils {
return requestId ? `${message} (requestId: ${requestId})` : message;
};

static readonly getStatusCode = (error: unknown, key?: 'statusCode' | 'status'): number | undefined => {
if (typeof error !== 'object' || error === null) return undefined;

const source = error as Record<string, unknown>;
const value = key ? source[key] : (source.statusCode ?? source.status);
return typeof value === 'number' && !Number.isNaN(value) ? value : undefined;
};

static readonly isNotFoundError = (error: unknown): boolean => {
if (typeof error !== 'object' || error === null) return false;
const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
return status === 404 || statusCode === 404;
return this.getStatusCode(error) === 404;
};

private static readonly hasResponseBody = (error: unknown): boolean => {
return typeof error === 'object' && error !== null && 'data' in error;
};

static readonly logIfUnexpected = (
log: Logger,
message: string,
error: unknown,
meta?: Record<string, unknown>,
) => {
static readonly classifyLookupError = (error: unknown): LookupErrorKind => {
const status = this.getStatusCode(error);

if (status === 404) return 'not-found';
if (status === undefined || !this.hasResponseBody(error)) return 'inconclusive';

// The API rejects a malformed or over-long path with 400, which we cannot tell apart from the item being absent
if (status === 400 || status === 414 || status === 422) return 'not-found';

if (this.isAuthStatus(status)) return 'auth';
return 'inconclusive';
};

private static readonly isAuthStatus = (status: number): boolean => status === 401 || status === 403;

private static readonly isRetryableStatus = (status: number): boolean =>
status === 408 || status === 425 || status === 429 || status >= 500;

static readonly toWebDavStatus = (error: unknown): { statusCode: number; retryAfter?: number } => {
const ownStatus = this.getStatusCode(error, 'statusCode');
if (ownStatus !== undefined) {
return error instanceof ServiceUnavailableError
? { statusCode: ownStatus, retryAfter: error.retryAfter }
: { statusCode: ownStatus };
}

const apiStatus = this.getStatusCode(error, 'status');
if (apiStatus === undefined) return { statusCode: 500 };

if (!this.hasResponseBody(error) || this.isRetryableStatus(apiStatus)) {
return { statusCode: 503, retryAfter: DEFAULT_RETRY_AFTER_SECONDS };
}
if (this.isAuthStatus(apiStatus)) return { statusCode: 502 };

return { statusCode: apiStatus };
};

static readonly logIfUnexpected = (log: Logger, message: string, error: unknown, meta?: Record<string, unknown>) => {
if (this.isNotFoundError(error)) return;
const errorMessage = this.isError(error) ? error.message : String(error);
log.warn(this.withRequestId(`${message}: ${errorMessage}`, error), meta);
Expand All @@ -89,6 +135,31 @@ export class NotFoundError extends Error {
}
}

/** The resource's state could not be determined; 503 asks the client to retry instead of
* telling it the resource is gone, which makes clients re-create it. */
export class ServiceUnavailableError extends Error {
public statusCode = 503;
public retryAfter: number;

constructor(message: string, retryAfter = DEFAULT_RETRY_AFTER_SECONDS) {
super(message);
this.name = 'ServiceUnavailableError';
this.retryAfter = retryAfter;
Object.setPrototypeOf(this, ServiceUnavailableError.prototype);
}
}

/** The CLI reached the WebDAV client but not the Internxt API on its behalf. */
export class BadGatewayError extends Error {
public statusCode = 502;

constructor(message: string) {
super(message);
this.name = 'BadGatewayError';
Object.setPrototypeOf(this, BadGatewayError.prototype);
}
}

export class BadRequestError extends Error {
public statusCode = 400;

Expand Down
40 changes: 21 additions & 19 deletions src/utils/webdav.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConfigService } from '../services/config.service';
import { TrashService } from '../services/drive/trash.service';
import { FormatUtils } from './format.utils';
import { DriveItemRepository } from '../services/database/drive-item/drive-item.repository';
import { ErrorUtils, ServiceUnavailableError } from './errors.utils';

export class WebDavUtils {
static joinURL(...pathComponents: string[]): string {
Expand Down Expand Up @@ -59,38 +60,39 @@ export class WebDavUtils {
static async getDriveFileFromResource(url: string): Promise<DriveFileItem | undefined> {
try {
return await DriveItemService.instance.getFileByPath(url);
} catch {
// no op
} catch (error) {
if (ErrorUtils.isNotFoundError(error)) return undefined;
throw error;
}
}

static async getDriveFolderFromResource(url: string): Promise<DriveFolderItem | undefined> {
try {
return await DriveItemService.instance.getFolderByPath(url);
} catch {
// no op
} catch (error) {
if (ErrorUtils.isNotFoundError(error)) return undefined;
throw error;
}
}

static async getDriveItemFromResource(resource: WebDavRequestedResource): Promise<DriveItem | undefined> {
let item: DriveItem | undefined = undefined;

const isFolder = resource.url.endsWith('/');
if (resource.url.endsWith('/')) {
return await this.getDriveFolderFromResource(resource.url);
}

let fileLookupError: ServiceUnavailableError | undefined;
try {
if (isFolder) {
item = await DriveItemService.instance.getFolderByPath(resource.url);
} else {
try {
item = await DriveItemService.instance.getFileByPath(resource.url);
} catch {
item = await DriveItemService.instance.getFolderByPath(resource.url);
}
}
} catch {
//no op
const file = await this.getDriveFileFromResource(resource.url);
if (file) return file;
} catch (error) {
if (!(error instanceof ServiceUnavailableError)) throw error;
fileLookupError = error;
}
return item;

const folder = await this.getDriveFolderFromResource(resource.url);
if (folder) return folder;
if (fileLookupError) throw fileLookupError;
return undefined;
}

static async deleteOrTrashItem<T extends { itemType: 'file' | 'folder'; uuid: string }>(driveItem: T) {
Expand Down
19 changes: 5 additions & 14 deletions src/webdav/middewares/errors.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,6 @@ const getErrorDetail = (err: unknown): string | undefined => {
return undefined;
};

/**
* The CLI's own errors (BadRequestError, NotFoundError, ...) expose `statusCode`,
* but errors normalized by @internxt/sdk's HttpClient expose `status` instead.
*/
const getErrorStatusCode = (err: unknown): number | undefined => {
if (typeof err !== 'object' || err === null) return undefined;

const { statusCode, status } = err as { statusCode?: unknown; status?: unknown };
if (typeof statusCode === 'number' && !Number.isNaN(statusCode)) return statusCode;
if (typeof status === 'number' && !Number.isNaN(status)) return status;
return undefined;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const ErrorHandlingMiddleware: ErrorRequestHandler = (err, req, res, _) => {
let message = ErrorUtils.isError(err) ? err.message : 'Something went wrong';
Expand Down Expand Up @@ -59,7 +46,11 @@ export const ErrorHandlingMiddleware: ErrorRequestHandler = (err, req, res, _) =
'error',
);

const statusCode = getErrorStatusCode(err) ?? 500;
const { statusCode, retryAfter } = ErrorUtils.toWebDavStatus(err);

if (retryAfter !== undefined) {
res.set('Retry-After', String(retryAfter));
}

res.set('Content-Type', 'application/xml; charset="utf-8"');
res.status(statusCode).send(errorBodyXML);
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/errors.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AxiosResponseError, AxiosUnknownError } from '@internxt/sdk/dist/shared/types/errors';
import { AxiosError } from 'axios';

/** An error as @internxt/sdk's HttpClient raises it from a real API response. */
export const newApiError = (
status: number,
{ requestId, request = 'GET files/meta', data = {} }: { requestId?: string; request?: string; data?: unknown } = {},
) =>
new AxiosResponseError(`Request failed with status code ${status}`, request, {
status,
data,
headers: requestId ? { 'x-request-id': requestId } : {},
statusText: '',
// @ts-expect-error partial AxiosResponse fixture, only the fields read by AxiosResponseError are needed
config: {},
});

/** The SDK's error when no response arrived; it invents a status -- 500 if sent, 400 if not. */
export const newNetworkError = ({ sent = true } = {}) =>
new AxiosUnknownError('socket hang up', 'GET files/meta', { request: sent ? {} : undefined } as AxiosError);
Loading
Loading