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..d79726b0d 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, { AxiosError } from 'axios';
import { DataProviderApi } from '../api';
import type { ExportFormat, ExportURL, ValuesStoreParams } from '../models';
-import { handleAxiosError } from '../utils';
+import { createAxiosProgressHandler } from '../utils';
import type {
H5GroveAttrValuesResponse,
H5GroveDataResponse,
@@ -22,11 +23,13 @@ import type {
} from './models';
import {
h5groveTypedArrayFromDType,
- hasErrorMessage,
+ isH5GroveError,
parseEntity,
} 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,38 @@ 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) {
+ if (error instanceof AxiosError && 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',
+ { cause: error },
+ );
+ }
+
+ throw error;
}
-
- return this.fetchData(params, signal, onProgress);
}
public override async getAttrValues(
@@ -114,32 +136,40 @@ 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) {
+ if (
+ !(error instanceof AxiosError) ||
+ !isH5GroveError(error.response?.data)
+ ) {
+ throw error;
+ }
+
+ const { message } = error.response.data;
+ if (message.includes('File not found')) {
+ throw new Error(`File not found: '${this.filepath}'`, { cause: error });
+ }
+ if (message.includes('Permission denied')) {
+ throw new Error(
+ `Cannot read file '${this.filepath}': Permission denied`,
+ { cause: error },
+ );
+ }
+ if (message.includes('not a valid path')) {
+ throw new Error(`No entity found at ${path}`, { cause: error });
+ }
+ if (message.includes('Cannot resolve')) {
+ throw new Error(`Could not resolve soft link at ${path}`, {
+ cause: error,
+ });
+ }
+
+ throw error;
+ }
}
private async fetchAttrValues(
@@ -154,42 +184,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..94e87e016 100644
--- a/packages/app/src/providers/h5grove/utils.ts
+++ b/packages/app/src/providers/h5grove/utils.ts
@@ -25,7 +25,12 @@ import {
import type { TypedArrayConstructor } from '@h5web/shared/vis-models';
import { typedArrayFromDType } from '../utils';
-import type { H5GroveAttribute, H5GroveEntity, H5GroveType } from './models';
+import type {
+ H5GroveAttribute,
+ H5GroveEntity,
+ H5GroveErrorResponse,
+ H5GroveType,
+} from './models';
export function parseEntity(
path: string,
@@ -129,8 +134,15 @@ function parseAttributes(attrsMetadata: H5GroveAttribute[]): Attribute[] {
}));
}
-export function hasErrorMessage(error: unknown): error is { message: string } {
- return !!error && typeof error === 'object' && 'message' in error;
+export function isH5GroveError(
+ payload: unknown,
+): payload is H5GroveErrorResponse {
+ return (
+ !!payload &&
+ typeof payload === 'object' &&
+ 'message' in payload &&
+ typeof payload.message === 'string'
+ );
}
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..22197fed1 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, { AxiosError } from 'axios';
import { DataProviderApi } from '../api';
import type { ExportFormat, ExportURL, ValuesStoreParams } from '../models';
-import { handleAxiosError } from '../utils';
+import { createAxiosProgressHandler } 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,16 @@ 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) {
+ if (error instanceof AxiosError && error.status === 400) {
+ throw new Error(`File not found: ${this.filepath}`, { cause: error });
+ }
+
+ throw error;
+ }
}
private async fetchDataset(id: HsdsId): Promise {
@@ -222,13 +231,27 @@ 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) {
+ if (error instanceof AxiosError && 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',
+ { cause: error },
+ );
+ }
+
+ throw error;
+ }
}
private async fetchAttributeWithValue(
diff --git a/packages/app/src/providers/utils.ts b/packages/app/src/providers/utils.ts
index f7c15a487..262d43e90 100644
--- a/packages/app/src/providers/utils.ts
+++ b/packages/app/src/providers/utils.ts
@@ -6,32 +6,13 @@ import type {
ScalarShape,
} from '@h5web/shared/hdf5-models';
import { DTypeClass } from '@h5web/shared/hdf5-models';
-import { AxiosError } from 'axios';
+import type { OnProgress } from '@h5web/shared/react-suspense-fetch';
+import type { AxiosProgressEvent } 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 +75,14 @@ export async function getValueOrError(
return error;
}
}
+
+export function createAxiosProgressHandler(onProgress: OnProgress | undefined) {
+ return (
+ onProgress &&
+ ((evt: AxiosProgressEvent) => {
+ if (evt.total !== undefined && evt.total > 0) {
+ onProgress(evt.loaded / evt.total);
+ }
+ })
+ );
+}