diff --git a/packages/app/src/ErrorFallback.tsx b/packages/app/src/ErrorFallback.tsx index a889a6911..5a9b70d71 100644 --- a/packages/app/src/ErrorFallback.tsx +++ b/packages/app/src/ErrorFallback.tsx @@ -10,7 +10,27 @@ interface Props extends FallbackProps { function ErrorFallback(props: Props) { const { className = '', error, resetErrorBoundary } = props; - if (error.cause || error.cause instanceof Error) { + if (error.message === CANCELLED_ERROR_MSG) { + return ( +

+ {CANCELLED_ERROR_MSG} + + +

+ ); + } + + if ( + error.cause && + error.cause instanceof Error && + error.message !== error.cause.message + ) { const { message } = error.cause; return (
@@ -20,23 +40,7 @@ function ErrorFallback(props: Props) { ); } - return ( -

- {error.message} - {error.message === CANCELLED_ERROR_MSG && ( - <> - - - - )} -

- ); + return

{error.message}

; } export default ErrorFallback; diff --git a/packages/app/src/providers/api.ts b/packages/app/src/providers/api.ts index 03b11080f..07d43c9a5 100644 --- a/packages/app/src/providers/api.ts +++ b/packages/app/src/providers/api.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/member-ordering */ import type { ArrayShape, AttributeValues, @@ -7,83 +8,36 @@ import type { Value, } from '@h5web/shared/hdf5-models'; import type { OnProgress } from '@h5web/shared/react-suspense-fetch'; -import type { - AxiosInstance, - AxiosProgressEvent, - AxiosRequestConfig, - AxiosResponse, - ResponseType, -} from 'axios'; -import axios from 'axios'; import type { ExportFormat, ExportURL, ValuesStoreParams } from './models'; export abstract class DataProviderApi { - protected readonly client: AxiosInstance; + public constructor(public readonly filepath: string) {} + + public abstract getEntity(path: string): Promise; + + public abstract getValue( + params: ValuesStoreParams, + signal?: AbortSignal, + onProgress?: OnProgress, + ): Promise; - public constructor( - public readonly filepath: string, - config?: AxiosRequestConfig, - ) { - this.client = axios.create(config); - } + public abstract getAttrValues(entity: Entity): Promise; /** * Provide an export URL for the given format and dataset/slice. * The following return types are supported: * - `URL` Provider has dedicated endpoint for generating server-side exports * - `() => Promise` Provider generates single-use export URLs (i.e. signed one-time tokens) - * - `() => Promise` Export is to be generated client-side + * - `() => Promise` Export is generated client-side * - `undefined` Export scenario is not supported */ - public getExportURL?>( + public getExportURL?>( // optional, so can't be abstract format: ExportFormat, dataset: D, selection: string | undefined, value: Value, ): ExportURL; - public getSearchablePaths?(path: string): Promise; - - protected async cancellableFetchValue( - endpoint: string, - queryParams: Record, - signal?: AbortSignal, - onProgress?: OnProgress, - responseType?: ResponseType, - ): Promise { - try { - return await this.client.get(endpoint, { - signal, - params: queryParams, - responseType, - onDownloadProgress: - onProgress && - ((evt: AxiosProgressEvent) => { - if (evt.total !== undefined && evt.total > 0) { - onProgress(evt.loaded / evt.total); - } - }), - }); - } catch (error) { - if (axios.isCancel(error)) { - // Throw abort reason instead of axios `CancelError` - // https://github.com/axios/axios/issues/5758 - throw new Error( - typeof signal?.reason === 'string' ? signal.reason : 'cancelled', - ); - } - throw error; - } - } - - public abstract getEntity(path: string): Promise; - - public abstract getValue( - params: ValuesStoreParams, - signal?: AbortSignal, - onProgress?: OnProgress, - ): Promise; - - public abstract getAttrValues(entity: Entity): Promise; + public getSearchablePaths?(path: string): Promise; // optional, so can't be abstract } diff --git a/packages/app/src/providers/h5grove/h5grove-api.ts b/packages/app/src/providers/h5grove/h5grove-api.ts index 355f7758e..6a13d9896 100644 --- a/packages/app/src/providers/h5grove/h5grove-api.ts +++ b/packages/app/src/providers/h5grove/h5grove-api.ts @@ -9,11 +9,12 @@ import type { } from '@h5web/shared/hdf5-models'; import { DTypeClass } from '@h5web/shared/hdf5-models'; import type { OnProgress } from '@h5web/shared/react-suspense-fetch'; -import type { AxiosRequestConfig } from 'axios'; +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios from 'axios'; import { DataProviderApi } from '../api'; import type { ExportFormat, ExportURL, ValuesStoreParams } from '../models'; -import { handleAxiosError } from '../utils'; +import { createAxiosProgressHandler, processAxiosError } from '../utils'; import type { H5GroveAttrValuesResponse, H5GroveDataResponse, @@ -22,11 +23,13 @@ import type { } from './models'; import { h5groveTypedArrayFromDType, - hasErrorMessage, parseEntity, + processFetchEntityError, } from './utils'; export class H5GroveApi extends DataProviderApi { + private readonly client: AxiosInstance; + /* API compatible with h5grove@2.1.0 */ public constructor( url: string, @@ -34,7 +37,13 @@ export class H5GroveApi extends DataProviderApi { axiosConfig?: AxiosRequestConfig, private readonly _getExportURL?: DataProviderApi['getExportURL'], ) { - super(filepath, { adapter: 'fetch', baseURL: url, ...axiosConfig }); + super(filepath); + + this.client = axios.create({ + adapter: 'fetch', + baseURL: url, + ...axiosConfig, + }); } public override async getEntity(path: string): Promise { @@ -49,25 +58,36 @@ export class H5GroveApi extends DataProviderApi { ): Promise { const { dataset } = params; - if (dataset.type.class === DTypeClass.Opaque) { - return new Uint8Array( - await this.fetchBinaryData(params, signal, onProgress), - ); - } - - const DTypedArray = h5groveTypedArrayFromDType(dataset.type); - if (DTypedArray) { - const buffer = await this.fetchBinaryData( - params, - signal, - onProgress, - true, - ); - const array = new DTypedArray(buffer); - return hasScalarShape(dataset) ? array[0] : array; + try { + if (dataset.type.class === DTypeClass.Opaque) { + return new Uint8Array( + await this.fetchBinaryData(params, signal, onProgress), + ); + } + + const DTypedArray = h5groveTypedArrayFromDType(dataset.type); + if (DTypedArray) { + const buffer = await this.fetchBinaryData( + params, + signal, + onProgress, + true, + ); + const array = new DTypedArray(buffer); + return hasScalarShape(dataset) ? array[0] : array; + } + + return await this.fetchData(params, signal, onProgress); + } catch (error) { + throw processAxiosError(error, (axiosError) => { + return ( + axios.isCancel(axiosError) && + // Throw abort reason instead of axios `CancelError` + // https://github.com/axios/axios/issues/5758 + (typeof signal?.reason === 'string' ? signal.reason : 'cancelled') + ); + }); } - - return this.fetchData(params, signal, onProgress); } public override async getAttrValues( @@ -114,32 +134,14 @@ export class H5GroveApi extends DataProviderApi { } private async fetchEntity(path: string): Promise { - const { data } = await handleAxiosError( - () => - this.client.get(`/meta/`, { params: { path } }), - (_, errorData) => { - if (!hasErrorMessage(errorData)) { - return undefined; - } - const { message } = errorData; - - if (message.includes('File not found')) { - return `File not found: '${this.filepath}'`; - } - if (message.includes('Permission denied')) { - return `Cannot read file '${this.filepath}': Permission denied`; - } - if (message.includes('not a valid path')) { - return `No entity found at ${path}`; - } - if (message.includes('Cannot resolve')) { - return `Could not resolve soft link at ${path}`; - } - - return undefined; - }, - ); - return data; + try { + const { data } = await this.client.get(`/meta/`, { + params: { path }, + }); + return data; + } catch (error) { + throw processFetchEntityError(error, path, this.filepath); + } } private async fetchAttrValues( @@ -154,42 +156,38 @@ export class H5GroveApi extends DataProviderApi { private async fetchData( params: ValuesStoreParams, - signal?: AbortSignal, - onProgress?: OnProgress, + signal: AbortSignal | undefined, + onProgress: OnProgress | undefined, ): Promise { - const { data } = await this.cancellableFetchValue( - `/data/`, - { + const { data } = await this.client.get('/data/', { + params: { path: params.dataset.path, selection: params.selection, flatten: true, }, signal, - onProgress, - ); - + onDownloadProgress: createAxiosProgressHandler(onProgress), + }); return data; } private async fetchBinaryData( params: ValuesStoreParams, - signal?: AbortSignal, - onProgress?: OnProgress, + signal: AbortSignal | undefined, + onProgress: OnProgress | undefined, safe = false, ): Promise { - const { data } = await this.cancellableFetchValue( - '/data/', - { + const { data } = await this.client.get('/data/', { + responseType: 'arraybuffer', + params: { path: params.dataset.path, selection: params.selection, format: 'bin', dtype: safe ? 'safe' : undefined, }, signal, - onProgress, - 'arraybuffer', - ); - + onDownloadProgress: createAxiosProgressHandler(onProgress), + }); return data; } } diff --git a/packages/app/src/providers/h5grove/models.ts b/packages/app/src/providers/h5grove/models.ts index 0fd244ba5..b47ea5222 100644 --- a/packages/app/src/providers/h5grove/models.ts +++ b/packages/app/src/providers/h5grove/models.ts @@ -9,6 +9,10 @@ export type H5GroveDataResponse = unknown; export type H5GroveAttrValuesResponse = AttributeValues; export type H5GrovePathsResponse = string[]; +export interface H5GroveErrorResponse { + message: string; +} + export type H5GroveEntity = | H5GroveGroup | H5GroveDataset diff --git a/packages/app/src/providers/h5grove/utils.ts b/packages/app/src/providers/h5grove/utils.ts index 989ebd688..4fe036249 100644 --- a/packages/app/src/providers/h5grove/utils.ts +++ b/packages/app/src/providers/h5grove/utils.ts @@ -24,8 +24,13 @@ import { } from '@h5web/shared/hdf5-utils'; import type { TypedArrayConstructor } from '@h5web/shared/vis-models'; -import { typedArrayFromDType } from '../utils'; -import type { H5GroveAttribute, H5GroveEntity, H5GroveType } from './models'; +import { processAxiosError, typedArrayFromDType } from '../utils'; +import type { + H5GroveAttribute, + H5GroveEntity, + H5GroveErrorResponse, + H5GroveType, +} from './models'; export function parseEntity( path: string, @@ -129,8 +134,46 @@ function parseAttributes(attrsMetadata: H5GroveAttribute[]): Attribute[] { })); } -export function hasErrorMessage(error: unknown): error is { message: string } { - return !!error && typeof error === 'object' && 'message' in error; +function isH5GroveError(payload: unknown): payload is H5GroveErrorResponse { + return ( + !!payload && + typeof payload === 'object' && + 'message' in payload && + typeof payload.message === 'string' + ); +} + +export function processFetchEntityError( + error: unknown, + entityPath: string, + filePath: string, +): unknown { + return processAxiosError(error, (axiosError) => { + if (!axiosError.response) { + return undefined; + } + + const { data } = axiosError.response; + if (!isH5GroveError(data)) { + return undefined; + } + + const { message } = data; + if (message.includes('File not found')) { + return `File not found: '${filePath}'`; + } + if (message.includes('Permission denied')) { + return `Cannot read file '${filePath}': Permission denied`; + } + if (message.includes('not a valid path')) { + return `No entity found at ${entityPath}`; + } + if (message.includes('Cannot resolve')) { + return `Could not resolve soft link at ${entityPath}`; + } + + return undefined; + }); } export function parseDType(type: H5GroveType): DType { diff --git a/packages/app/src/providers/hsds/hsds-api.ts b/packages/app/src/providers/hsds/hsds-api.ts index 4580a831e..e32d48766 100644 --- a/packages/app/src/providers/hsds/hsds-api.ts +++ b/packages/app/src/providers/hsds/hsds-api.ts @@ -18,10 +18,12 @@ import type { import { EntityKind } from '@h5web/shared/hdf5-models'; import { buildEntityPath, getChildEntity } from '@h5web/shared/hdf5-utils'; import type { OnProgress } from '@h5web/shared/react-suspense-fetch'; +import type { AxiosInstance } from 'axios'; +import axios from 'axios'; import { DataProviderApi } from '../api'; import type { ExportFormat, ExportURL, ValuesStoreParams } from '../models'; -import { handleAxiosError } from '../utils'; +import { createAxiosProgressHandler, processAxiosError } from '../utils'; import type { BaseHsdsEntity, HsdsAttribute, @@ -50,6 +52,7 @@ import { export class HsdsApi extends DataProviderApi { private readonly entities = new Map>(); + private readonly client: AxiosInstance; /* API compatible with HSDS@6717a7bb8c2245492090be34ec3ccd63ecb20b70 */ public constructor( @@ -59,7 +62,9 @@ export class HsdsApi extends DataProviderApi { filepath: string, private readonly _getExportURL?: DataProviderApi['getExportURL'], ) { - super(filepath, { + super(filepath); + + this.client = axios.create({ adapter: 'fetch', baseURL: url, params: { domain: filepath }, @@ -105,9 +110,9 @@ export class HsdsApi extends DataProviderApi { } /* HSDS doesn't allow fetching entities by path. - We need to fetch every ascendant group right up to the root group - in order to find the ID of the entity at the requested path. - Entities are cached along the way for efficiency. */ + * We need to fetch every ascendant group right up to the root group + * in order to find the ID of the entity at the requested path. + * Entities are cached along the way for efficiency. */ const parentPath = path.slice(0, path.lastIndexOf('/')) || '/'; const parentGroup = await this.getEntity(parentPath); assertGroup(parentGroup); @@ -135,9 +140,9 @@ export class HsdsApi extends DataProviderApi { const value = await this.fetchValue(dataset.id, params, signal, onProgress); - // https://github.com/HDFGroup/hsds/issues/88 - // HSDS does not reduce the number of dimensions when selecting indices - // Therefore the flattening must be done on all dimensions regardless of the selection + /* HSDS doesn't reduce the number of dimensions when selecting indices, + * so the flattening must be done on all dimensions regardless of the selection. + * https://github.com/HDFGroup/hsds/issues/88 */ return hasArrayShape(dataset) ? flattenValue(value, dataset) : value; } @@ -171,12 +176,14 @@ export class HsdsApi extends DataProviderApi { } private async fetchRootId(): Promise { - const { data } = await handleAxiosError( - () => this.client.get('/'), - (status) => - status === 400 ? `File not found: ${this.filepath}` : undefined, - ); - return data.root; + try { + const { data } = await this.client.get('/'); + return data.root; + } catch (error) { + throw processAxiosError(error, (axiosError) => { + return axiosError.status === 400 && `File not found: ${this.filepath}`; + }); + } } private async fetchDataset(id: HsdsId): Promise { @@ -222,13 +229,24 @@ export class HsdsApi extends DataProviderApi { onProgress?: OnProgress, ): Promise { const { selection } = params; - const { data } = await this.cancellableFetchValue( - `/datasets/${entityId}/value`, - { select: selection && `[${selection}]` }, - signal, - onProgress, - ); - return data.value; + + try { + const { data } = await this.client.get(`/datasets/${entityId}/value`, { + params: { select: selection ? `[${selection}]` : undefined }, + signal, + onDownloadProgress: createAxiosProgressHandler(onProgress), + }); + return data.value; + } catch (error) { + throw processAxiosError(error, (axiosError) => { + return ( + axios.isCancel(axiosError) && + // Throw abort reason instead of axios `CancelError` + // https://github.com/axios/axios/issues/5758 + (typeof signal?.reason === 'string' ? signal.reason : 'cancelled') + ); + }); + } } private async fetchAttributeWithValue( diff --git a/packages/app/src/providers/utils.ts b/packages/app/src/providers/utils.ts index f7c15a487..fd917ee44 100644 --- a/packages/app/src/providers/utils.ts +++ b/packages/app/src/providers/utils.ts @@ -6,32 +6,14 @@ import type { ScalarShape, } from '@h5web/shared/hdf5-models'; import { DTypeClass } from '@h5web/shared/hdf5-models'; +import type { OnProgress } from '@h5web/shared/react-suspense-fetch'; +import type { AxiosProgressEvent } from 'axios'; import { AxiosError } from 'axios'; import type { DataProviderApi } from './api'; export const CANCELLED_ERROR_MSG = 'Request cancelled'; -export async function handleAxiosError( - func: () => Promise, - getErrorToThrow: (status: number, errorData: unknown) => string | undefined, -): Promise { - try { - return await func(); - } catch (error) { - if (error instanceof AxiosError && error.response) { - const { status, data } = error.response; - const errorToThrow = getErrorToThrow(status, data); - - if (errorToThrow) { - throw new Error(errorToThrow); - } - } - - throw error; - } -} - export function typedArrayFromDType(dtype: DType) { if (isEnumType(dtype)) { return typedArrayFromDType(dtype.base); @@ -94,3 +76,28 @@ export async function getValueOrError( return error; } } + +export function processAxiosError( + error: unknown, + getMessageToThrow: (error: AxiosError) => string | false | undefined, +): unknown { + if (error instanceof AxiosError) { + const messageToThrow = getMessageToThrow(error); + if (messageToThrow) { + return new Error(messageToThrow, { cause: error }); + } + } + + return error; +} + +export function createAxiosProgressHandler(onProgress: OnProgress | undefined) { + return ( + onProgress && + ((evt: AxiosProgressEvent) => { + if (evt.total !== undefined && evt.total > 0) { + onProgress(evt.loaded / evt.total); + } + }) + ); +}