Skip to content

Commit

Permalink
Merge pull request #1694 from silx-kit/refact-api
Browse files Browse the repository at this point in the history
Remove axios from `DataProviderApi` base class and refactor axios error handling
  • Loading branch information
axelboc authored Aug 21, 2024
2 parents 641f32a + 47a4e9c commit cb4ef80
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 188 deletions.
40 changes: 22 additions & 18 deletions packages/app/src/ErrorFallback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<p className={`${styles.error} ${className}`}>
{CANCELLED_ERROR_MSG}
<span></span>
<button
className={styles.retryBtn}
type="button"
onClick={() => resetErrorBoundary()}
>
Retry?
</button>
</p>
);
}

if (
error.cause &&
error.cause instanceof Error &&
error.message !== error.cause.message
) {
const { message } = error.cause;
return (
<details className={`${styles.detailedError} ${className}`}>
Expand All @@ -20,23 +40,7 @@ function ErrorFallback(props: Props) {
);
}

return (
<p className={`${styles.error} ${className}`}>
{error.message}
{error.message === CANCELLED_ERROR_MSG && (
<>
<span></span>
<button
className={styles.retryBtn}
type="button"
onClick={() => resetErrorBoundary()}
>
Retry?
</button>
</>
)}
</p>
);
return <p className={`${styles.error} ${className}`}>{error.message}</p>;
}

export default ErrorFallback;
74 changes: 14 additions & 60 deletions packages/app/src/providers/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering */
import type {
ArrayShape,
AttributeValues,
Expand All @@ -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<ProvidedEntity>;

public abstract getValue(
params: ValuesStoreParams,
signal?: AbortSignal,
onProgress?: OnProgress,
): Promise<unknown>;

public constructor(
public readonly filepath: string,
config?: AxiosRequestConfig,
) {
this.client = axios.create(config);
}
public abstract getAttrValues(entity: Entity): Promise<AttributeValues>;

/**
* 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<URL>` Provider generates single-use export URLs (i.e. signed one-time tokens)
* - `() => Promise<Blob>` Export is to be generated client-side
* - `() => Promise<Blob>` Export is generated client-side
* - `undefined` Export scenario is not supported
*/
public getExportURL?<D extends Dataset<ArrayShape>>(
public getExportURL?<D extends Dataset<ArrayShape>>( // optional, so can't be abstract
format: ExportFormat,
dataset: D,
selection: string | undefined,
value: Value<D>,
): ExportURL;

public getSearchablePaths?(path: string): Promise<string[]>;

protected async cancellableFetchValue(
endpoint: string,
queryParams: Record<string, string | boolean | undefined>,
signal?: AbortSignal,
onProgress?: OnProgress,
responseType?: ResponseType,
): Promise<AxiosResponse> {
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<ProvidedEntity>;

public abstract getValue(
params: ValuesStoreParams,
signal?: AbortSignal,
onProgress?: OnProgress,
): Promise<unknown>;

public abstract getAttrValues(entity: Entity): Promise<AttributeValues>;
public getSearchablePaths?(path: string): Promise<string[]>; // optional, so can't be abstract
}
156 changes: 91 additions & 65 deletions packages/app/src/providers/h5grove/h5grove-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,19 +23,27 @@ import type {
} from './models';
import {
h5groveTypedArrayFromDType,
hasErrorMessage,
isH5GroveError,
parseEntity,
} from './utils';

export class H5GroveApi extends DataProviderApi {
private readonly client: AxiosInstance;

/* API compatible with [email protected] */
public constructor(
url: string,
filepath: string,
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<ProvidedEntity> {
Expand All @@ -49,25 +58,38 @@ export class H5GroveApi extends DataProviderApi {
): Promise<H5GroveDataResponse> {
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(
Expand Down Expand Up @@ -114,32 +136,40 @@ export class H5GroveApi extends DataProviderApi {
}

private async fetchEntity(path: string): Promise<H5GroveEntityResponse> {
const { data } = await handleAxiosError(
() =>
this.client.get<H5GroveEntityResponse>(`/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<H5GroveEntityResponse>(`/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(
Expand All @@ -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<H5GroveDataResponse> {
const { data } = await this.cancellableFetchValue(
`/data/`,
{
const { data } = await this.client.get<H5GroveDataResponse>('/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<ArrayBuffer> {
const { data } = await this.cancellableFetchValue(
'/data/',
{
const { data } = await this.client.get<ArrayBuffer>('/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;
}
}
4 changes: 4 additions & 0 deletions packages/app/src/providers/h5grove/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit cb4ef80

Please sign in to comment.