import axios, { type AxiosPromise, type AxiosResponse, type InternalAxiosRequestConfig, type AxiosError, type AxiosRequestConfig, } from "axios"; import { LedgerAPI4xx, LedgerAPI5xx, NetworkDown } from "./errors"; import { getNetworkState } from "./state"; import type { NetworkState } from "./state"; import { retry } from "@ledgerhq/live-promise"; import { log } from "@ledgerhq/logs"; type Metadata = { startTime: number }; type ExtendedAxiosRequestConfig = InternalAxiosRequestConfig & { metadata?: Metadata }; export const requestInterceptor = ( request: ExtendedAxiosRequestConfig, ): ExtendedAxiosRequestConfig => { if (!getNetworkState().enableNetworkLogs) { return request; } const { baseURL, url, method = "", data } = request; log("network", `${method} ${baseURL || ""}${url}`, { data }); request.metadata = { startTime: Date.now(), }; return request; }; type ExtendedAxiosResponse = AxiosResponse & { config: { metadata?: Metadata } }; export const responseInterceptor = (response: ExtendedAxiosResponse): ExtendedAxiosResponse => { if (!getNetworkState().enableNetworkLogs) { return response; } const { baseURL, url, method, metadata } = response.config; const { startTime = 0 } = metadata || {}; log( "network-success", `${response.status} ${method} ${baseURL || ""}${url} (${(Date.now() - startTime).toFixed( 0, )}ms)`, { data: response.data }, ); return response; }; type InterceptedResponse = AxiosResponse & { config: ExtendedAxiosRequestConfig; }; type InterceptedError = AxiosError & { response?: InterceptedResponse; }; export const errorInterceptor = (error: InterceptedError): InterceptedError => { const config = error?.response?.config; if (!config) throw error; const { baseURL, url, method = "", metadata } = config as ExtendedAxiosRequestConfig; const { startTime = 0 } = metadata || {}; const duration = `${(Date.now() - startTime).toFixed(0)}ms`; let errorToThrow: Error; if (error.response) { // The request was made and the server responded with a status code // that falls out of the range of 2xx const { data, status } = error.response; let msg; try { if (data && typeof data === "string") { msg = extractErrorMessage(data); } else if (data && typeof data === "object") { msg = getErrorMessage(data); } } catch (e) { log("warn", "can't parse server result " + String(e)); } if (msg) { errorToThrow = makeError(msg, status, url, method); } else { errorToThrow = makeError(`API HTTP ${status} ${url}`, status, url, method); } log( "network-error", `${status} ${method} ${baseURL || ""}${url} (${duration}): ${errorToThrow.message}`, getNetworkState().debugHttpResponse ? { data: data } : {}, ); throw errorToThrow; } else if (error.request) { log("network-down", `DOWN ${method} ${baseURL || ""}${url} (${duration})`); throw new NetworkDown(); } throw error; }; axios.interceptors.request.use(requestInterceptor); axios.interceptors.response.use(responseInterceptor, errorInterceptor); /** * Only enable HTTPS keepAlive on Node.js (excludes React Native / browser) and * for non-LLM builds (llm- prefix = Ledger Live Mobile client version). */ const NETWORK_USE_HTTPS_KEEP_ALIVE = typeof process !== "undefined" && process.release?.name === "node" && !getNetworkState().ledgerClientVersion?.startsWith("llm-"); if (NETWORK_USE_HTTPS_KEEP_ALIVE) { // the keepAlive is necessary when we make a lot of request in in parallel, especially for bitcoin sync. Otherwise, it may raise "connect ETIMEDOUT" error // this should only be needed in Windows as UNIX systems reuse TCP packets by default // refer to https://stackoverflow.com/questions/63064393/getting-axios-error-connect-etimedout-when-making-high-volume-of-calls // eslint-disable-next-line @typescript-eslint/no-var-requires const https = require("https"); axios.defaults.httpsAgent = new https.Agent({ keepAlive: true }); } const makeError = (msg: string, status: number, url: string | undefined, method: string) => { const obj = { status, url, method, msg, }; return (status || "").toString().startsWith("4") ? new LedgerAPI4xx(msg, obj) : new LedgerAPI5xx(msg, obj); }; const getErrorMessage = (data: Record): string | null | undefined => { if (!data) return ""; if (typeof data === "string") return data; if (data.errors) { return getErrorMessage(data.errors[0]); } return data.message || data.error_message || data.error || data.msg; }; const extractErrorMessage = (raw: string): string | undefined => { let data = JSON.parse(raw); if (data && Array.isArray(data)) data = data[0]; let msg = getErrorMessage(data); if (typeof msg === "string") { const m = msg.match(/^JsDefined\((.*)\)$/); const innerPart = m ? m[1] : msg; const r = JSON.parse(innerPart); let message = r.message; if (typeof message === "object") { message = message.message; } if (typeof message === "string") { msg = message; } return msg ? String(msg) : undefined; } return; }; export type LiveNetworkRequest = { url?: string; method?: "GET" | "POST" | "PUT" | "DELETE"; headers?: Record; baseURL?: string; params?: unknown; data?: T; timeout?: number; }; export type LiveNetworkResponse = { headers?: AxiosResponse["headers"]; data: T; status: number; }; // inspired by https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#retry const retryableHttpStatusCodes = [408, 413, 429, 500, 502, 503, 504, 521, 522, 524]; /** * Network call * @param request * @returns */ export const newImplementation = async ( request: LiveNetworkRequest, ): Promise> => { let response: AxiosResponse; if (!("method" in request)) { request.method = "GET"; } if (request.method === "GET") { if (!("timeout" in request)) { request.timeout = getNetworkState().getCallsTimeout; } response = await retry(() => axios(request), { maxRetry: getNetworkState().getCallsRetry, retryCondition: error => { if (error && error.status) { // not all status codes are retryable return retryableHttpStatusCodes.includes(error.status); } return true; }, }); } else { response = await axios(request); } const { data, status, headers } = response; return { data, status, headers }; }; /** * Network call * @deprecated Use new method by updating your import: `@ledgerhq/live-network` * @param arg Axios request type * @returns Axios response type */ const implementation = (arg: AxiosRequestConfig): AxiosPromise => { let promise: AxiosPromise; if (arg.method === "GET") { if (!("timeout" in arg)) { arg.timeout = getNetworkState().getCallsTimeout; } promise = retry(() => axios(arg), { maxRetry: getNetworkState().getCallsRetry, retryCondition: error => { if (error && error.status) { // A 422 shouldn't be retried without change as explained in this documentation // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/422 return error.status !== 422; } return true; }, }); } else { promise = axios(arg); } return promise; }; const canSetUserAgentHeader = (): boolean => typeof globalThis?.window === "undefined" && typeof process !== "undefined" && process.release?.name === "node"; // attach the env "LEDGER_CLIENT_VERSION" to set the header globally for axios function setAxiosLedgerClientVersionHeader(value: string) { if (value) { axios.defaults.headers.common["X-Ledger-Client-Version"] = value; if (canSetUserAgentHeader()) { axios.defaults.headers.common["User-Agent"] = value; } } else { delete axios.defaults.headers.common["X-Ledger-Client-Version"]; if (canSetUserAgentHeader()) { delete axios.defaults.headers.common["User-Agent"]; } } } setAxiosLedgerClientVersionHeader(getNetworkState().ledgerClientVersion); export function setNetworkState(partial: Partial): void { const state = getNetworkState() as NetworkState; if (partial.enableNetworkLogs !== undefined) state.enableNetworkLogs = partial.enableNetworkLogs; if (partial.debugHttpResponse !== undefined) state.debugHttpResponse = partial.debugHttpResponse; if (partial.ledgerClientVersion !== undefined) { state.ledgerClientVersion = partial.ledgerClientVersion; setAxiosLedgerClientVersionHeader(partial.ledgerClientVersion); } if (partial.getCallsTimeout !== undefined) state.getCallsTimeout = partial.getCallsTimeout; if (partial.getCallsRetry !== undefined) state.getCallsRetry = partial.getCallsRetry; } export type { NetworkState }; export default implementation;