@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
178 lines (160 loc) • 6.57 kB
text/typescript
import {
CantOpenDevice,
DisconnectedDevice,
LockedDeviceError,
TransportRaceCondition,
UnresponsiveDeviceError,
TransportStatusErrorClassType,
CustomErrorClassType,
} from "@ledgerhq/errors";
import { Observable, from, of, throwError, timer } from "rxjs";
import { catchError, concatMap, retry, switchMap, timeout } from "rxjs/operators";
import { Transport, TransportRef } from "../transports/core";
import { DmkError } from "@ledgerhq/device-management-kit";
export type SharedTaskEvent = { type: "error"; error: Error; retrying: boolean };
export const NO_RESPONSE_TIMEOUT_MS = 30000;
export const RETRY_ON_ERROR_DELAY_MS = 500;
export const LOG_TYPE = "device-sdk-task";
/**
* Wraps a task function to add some common logic to it:
* - Timeout for no response
* - Retry strategy on specific errors: those errors are fixed for all tasks
* - Catch errors and emit them as events
*
* @param task The task function to wrap
* @returns A wrapped task function
*/
export function sharedLogicTaskWrapper<TaskArgsType, TaskEventsType>(
task: (args: TaskArgsType) => Observable<TaskEventsType | SharedTaskEvent>,
defaultTimeoutOverride?: number,
) {
return (args: TaskArgsType): Observable<TaskEventsType | SharedTaskEvent> => {
return new Observable(subscriber => {
return task(args)
.pipe(
timeout(defaultTimeoutOverride ?? NO_RESPONSE_TIMEOUT_MS),
retry({
delay: error => {
let acceptedError = false;
// - LockedDeviceError and UnresponsiveDeviceError: on every transport if there is a device but it is locked
// - CantOpenDevice: it can come from hw-transport-node-hid-singleton/TransportNodeHid
// or react-native-hw-transport-ble/BleTransport when no device is found
// - DisconnectedDevice: it can come from TransportNodeHid while switching app
if (
error instanceof LockedDeviceError ||
error instanceof UnresponsiveDeviceError ||
error instanceof CantOpenDevice ||
error instanceof DisconnectedDevice ||
error instanceof TransportRaceCondition
) {
// Emits to the action an error event so it is aware of it (for ex locked device) before retrying
const event: SharedTaskEvent = {
type: "error",
error,
retrying: true,
};
subscriber.next(event);
acceptedError = true;
}
return acceptedError ? timer(RETRY_ON_ERROR_DELAY_MS) : throwError(() => error);
},
}),
catchError((error: Error) => {
// Emits the error to the action, without throwing
return of<SharedTaskEvent>({ type: "error", error, retrying: false });
}),
)
.subscribe(subscriber);
});
};
}
type ErrorClass = CustomErrorClassType | TransportStatusErrorClassType;
// To be able to retry a command, the command needs to take an object containing a transport as its argument
type CommandTransportArgs = { transport: Transport };
export const isDmkError = (error: any): error is DmkError => error && "_tag" in error;
/**
* Calls a command and retries it on given errors. The transport is refreshed before each retry.
*
* The no response timeout is handled at the task level
*
* @param command the command to wrap, it should take as argument an object containing a transport
* @param transportRef a reference to the transport, that can be updated/refreshed
* @param allowedErrors a list of errors to retry on
* - errorClass: the error class to retry on
* - maxRetries: the maximum number of retries for this error
*/
export function retryOnErrorsCommandWrapper<CommandArgsWithoutTransportType, CommandEventsType>({
command,
allowedErrors,
allowedDmkErrors = [],
}: {
command: (
args: CommandArgsWithoutTransportType & CommandTransportArgs,
) => Observable<CommandEventsType>;
allowedErrors: {
errorClass: ErrorClass;
maxRetries: number | "infinite";
}[];
allowedDmkErrors?: DmkError[];
}) {
// Returns the command wrapped into the retry mechanism
// No need to pass the transport to the wrapped command
return (
transportRef: TransportRef,
argsWithoutTransport: CommandArgsWithoutTransportType,
): Observable<CommandEventsType> => {
let sameErrorInARowCount = 0;
let shouldRefreshTransport = false;
let latestErrorName: string | null = null;
// It cannot start with the command itself because of the retry and the transport reference:
// the retry will be chained on the observable returned before the pipe and if it is the command itself,
// it would retry the command with the same transport and not the refreshed one
return of(1).pipe(
switchMap(() => {
if (shouldRefreshTransport) {
// if we pass through this code again it means that some error happened during the command
// execution, therefore we'll then, and only then, start refreshing the transport
// before trying the command again
return from(transportRef.refreshTransport());
}
shouldRefreshTransport = true;
return of(1);
}),
// Overrides the transport instance so it can be refreshed
concatMap(() => {
return command({
...argsWithoutTransport,
transport: transportRef.current,
});
}),
retry({
delay: error => {
let isAllowedError = false;
if (latestErrorName !== error.name) {
sameErrorInARowCount = 0;
} else {
sameErrorInARowCount++;
}
latestErrorName = error.name;
for (const { errorClass, maxRetries } of allowedErrors) {
if (error instanceof errorClass) {
if (maxRetries === "infinite" || sameErrorInARowCount < maxRetries) {
isAllowedError = true;
}
break;
}
}
if (isDmkError(error)) {
isAllowedError = allowedDmkErrors.some(dmkError => dmkError._tag === error._tag);
}
if (isAllowedError) {
// Retries the whole pipe chain after the delay
return timer(RETRY_ON_ERROR_DELAY_MS);
}
// If the error is not part of the allowed errors, it is thrown
return throwError(() => error);
},
}),
);
};
}