@equinor/fusion-query
Version:
Reactive data fetching and caching library with observable streams and comprehensive event system
187 lines (174 loc) • 7.13 kB
text/typescript
import type { Flow } from '@equinor/fusion-observable';
import { actions, type Actions } from './actions';
import type { QueryClientState, QueryFn, RetryOptions } from './types';
import {
filter,
mergeMap,
from,
map,
catchError,
of,
takeUntil,
withLatestFrom,
timer,
type Observable,
tap,
} from 'rxjs';
import { QueryClientError } from './QueryClientError';
/**
* Handles incoming request actions by transforming them into execute actions.
*
* @param action$ - The stream of actions being dispatched in the system.
* @returns An Observable that emits execute actions for each request action.
*/
export const handleRequests: Flow<Actions, QueryClientState> = (action$) =>
action$.pipe(
filter(actions.request.match),
map((action) => actions.execute(action.meta.transaction)),
);
/**
* Handles the execution of a query.
*
* @param fetch - The function used to execute the query.
* @returns A flow that handles the execution of the query.
*/
export const handleExecution =
<TType, TArgs>(fetch: QueryFn<TType, TArgs>): Flow<Actions, QueryClientState<TArgs>> =>
(action$, state$) =>
action$.pipe(
filter(actions.execute.match),
withLatestFrom(state$),
mergeMap(([action, state]) => {
const transaction = action.payload;
const request = state[transaction];
// Create an AbortController instance to manage cancellation.
const controller = new AbortController();
// Listen for cancel actions specifically targeting this transaction.
const cancel$ = action$.pipe(
filter(actions.cancel.match),
filter((next) => next.payload.transaction === transaction),
tap(() => {
// If the request hasn't been aborted yet, abort it.
if (!controller.signal.aborted) {
controller.abort();
}
}),
);
try {
return from(fetch(request.args, controller.signal)).pipe(
map((value) =>
actions.execute.success({
...request,
status: 'complete',
completed: Date.now(),
value,
}),
),
catchError((err) => of(actions.execute.failure(err, transaction))),
takeUntil(cancel$), // Complete the observable chain if a cancel action is received.
);
} catch (err) {
// Normally errors thrown during the execution of the fetch function are caught by the `catchError` operator.
// However, if the fetch function itself throws an error, it will be caught here.
// This can happen if the fetch function is not a function or if it throws synchronously.
return of(actions.execute.failure(err as Error, transaction));
}
}),
);
/**
* Handles execution failure by scheduling retries according to the provided retry options.
* If the maximum number of retries is exceeded or no retry options are provided, it emits an error action.
* Otherwise, it schedules a retry after a delay determined by the retry options.
*
* @param config - Optional configuration for retry behavior, including the maximum number of retries and delay between retries.
*/
export const handleFailure = (config?: Partial<RetryOptions>): Flow<Actions, QueryClientState> => {
return (action$, state$) => {
return action$.pipe(
// Filter for actions that indicate an execution failure.
filter(actions.execute.failure.match),
// Combine each failure action with the latest state.
withLatestFrom(state$),
// Handle the retry logic for each failure.
mergeMap(([action, state]) => {
// Extract the transaction identifier from the action metadata.
const { transaction } = action.meta;
// Retrieve the corresponding request from the state.
const request = state[transaction];
// If the request no longer exists in the state, it cannot be retried.
if (!request) {
return of(
actions.error(
transaction,
new QueryClientError('error', {
message:
'request not found, cannot retry request, most likely removed while scheduled for retry!',
}),
),
);
}
// Count the number of execution attempts made for this request.
const executions = request.execution.length;
// Merge the provided configuration with the request-specific retry options.
const retryOptions = Object.assign({}, config, request.options?.retry) as RetryOptions;
// Identify the cause of the failure, either the last known error or a generic message.
const cause =
request.errors?.slice(-1)[0] ??
new QueryClientError('error', {
message: 'no errors registered for request!',
request,
});
// If the maximum number of retries has been reached, report an error.
if (executions > retryOptions.count) {
const error =
// if retry is disabled return the last error, else return all registered errors
retryOptions.count === 0
? cause
: new QueryClientError('error', {
message: 'maximum retries executed!',
cause: request.errors,
request,
});
return of(actions.error(transaction, error));
}
// Create an Observable that emits after the specified delay for the retry.
const delay$ =
typeof retryOptions.delay === 'function'
? from(retryOptions.delay(cause)).pipe(
// Catch errors that occur within the delay calculation.
catchError((cause) =>
of(
actions.error(
transaction,
new QueryClientError('error', {
message: 'retry delay callback failed!',
cause: [...(request.errors ?? []), cause],
request,
}),
),
),
),
)
: (timer(retryOptions.delay) as unknown as Observable<void>);
// After the delay, check if the request is still present and initiate a retry.
return delay$.pipe(
withLatestFrom(state$),
// Ensure the request has not been removed from the state during the delay.
filter(([_, state]) => !!state[transaction]),
// If the request has been removed, throw an error.
tap(([, state]) => {
if (!state[transaction]) {
throw new QueryClientError('error', {
message: 'request not found, most likely removed while scheduled for retry',
cause: request.errors,
request,
});
}
}),
// Emit an action to re-execute the request.
map(() => actions.execute(transaction)),
);
}),
);
};
};