Skip to content

[业务产出] axios management 最佳实践 #44

@PeterChen1997

Description

@PeterChen1997
// 常量
const TRANSACTION_ID_IN_HEADER = 'X-Transaction-ID';
const REQUEST_START_AT_IN_HEADER = 'X-Request-Start-At';
const RETRY_COUNT = 2;

// 不记录请求列表(此处使用白名单过滤请求,添加后请确认是否生效)
const ignoreUrlList = ['/test/not/record'];

export const isRequestNeedRecord = (config: AxiosRequestConfig) => {
  if (!config.url) {
    return false;
  }

  for (const url of ignoreUrlList) {
    if (config.url.startsWith(url)) {
      return false;
    }
  }

  return true;
};

// 注册 axios retry 逻辑 - 这个库的逻辑编写的一般,可以自行编写替换
const registerAxiosRetry = (newInstance: AxiosInstance, isExternalRequest?: boolean) => {
  axiosRetry(newInstance, {
    retries: RETRY_COUNT,
    shouldResetTimeout: true,
    retryDelay: (retryCount) => retryCount * 1000,
    retryCondition: async (error: Error | AxiosError) => {
      if (!axios.isAxiosError(error)) {
        return false;
      }

      const check = async () => {
        // 默认只 retry 5xx 和 network error
        if (isNetworkOrIdempotentRequestError(error)) {
          return true;
        }

        // retry ECONNABORTED 类型的错误
        if (isConnectAbortedError(error)) {
          return true;
        }

        // 认证失败后,先尝试 renew token,再进行 retry
        if (!isExternalRequest && isAuthExpired(error)) {
          return await renewAuthToken();
        }

        return false;
      };

      /*
       * 此处需要包装函数用于兼容 axiosRetry 的逻辑,在 promise 类型的回调中,只有 throw error 才能终止 retry
       * 详见 https://github.com/softonic/axios-retry/pull/196
       */
      const needRetry = await check();

      if (!needRetry) {
        throw new Error('should not retry');
      }

      return true;
    },
  });
};

const registerRequestInterceptor = (newInstance: AxiosInstance, isExternalRequest?: boolean) => {
  newInstance.interceptors.request.use(
    (config: AxiosRequestConfig) => {
      const transactionId = v4();
      const startAt = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
			const authContent = 'xxx'

      return {
        ...config,
        headers: {
          ...config.headers,
          // add authorization info
          ...(!isExternalRequest
            ? { Authorization: `Bearer ${authContent}` }
            : undefined),
          // add transaction id
          [TRANSACTION_ID_IN_HEADER]: transactionId,
          [REQUEST_START_AT_IN_HEADER]: startAt,
        },
      };
    },
    (error: Error | AxiosError) => {
      // log request error
      if (axios.isAxiosError(error)) {
        const transactionId = getTargetKeyFromConfig(error.config, TRANSACTION_ID_IN_HEADER);
        const startAt = getTargetKeyFromConfig(error.config, REQUEST_START_AT_IN_HEADER);

		    // 各类日志软件选其一即可
        logEvent('Send Request Error (request)', {
          ...error.config,
          error: safeStringify(error.toJSON()),
          transactionId,
          startAt,
        });
      } else {
        logEvent('Send Request Error (native)', {
          ...error,
        });
      }

      return Promise.reject(error);
    },
  );
};

const registerResponseInterceptor = (newInstance: AxiosInstance, isExternalRequest?: boolean) => {
  newInstance.interceptors.response.use(
    (response: AxiosResponse) => {
      // 2xx 范围内的状态码都会触发该函数

      // 请求成功时,无有效信息的请求不上报日志
      if (isRequestNeedRecord(response.config)) {
        // log response end
        logEvent('Receive Response', {
          ...response.config,
          transactionId: getTargetKeyFromConfig(response.config, TRANSACTION_ID_IN_HEADER),
          startAt: getTargetKeyFromConfig(response.config, REQUEST_START_AT_IN_HEADER),
        });
      }

      return Promise.resolve(response);
    },
    (error: Error | AxiosError) => {
      // log response error
      if (axios.isAxiosError(error)) {
        const transactionId = getTargetKeyFromConfig(error.config, TRANSACTION_ID_IN_HEADER);
        const startAt = getTargetKeyFromConfig(error.config, REQUEST_START_AT_IN_HEADER);

        logEvent('Receive Response Error (request)', {
          ...error.config,
          error: safeStringify(error.toJSON()),
          transactionId,
          startAt,
        });
      } else {
        logEvent('Receive Response Error (native)', {
          ...error,
        });
      }

      return Promise.reject(error);
    },
  );
};

export const axiosErrorHandler = async <T>({
  error,
  captureErrorInSentry = true,
  silenceError,
  config,
  isExternalRequest,
}: {
  config: AxiosRequestConfig;
  error: Error | AxiosError;
  silenceError?: boolean;
  captureErrorInSentry?: boolean;
  isExternalRequest?: boolean;
}): Promise<RequestResult<T>> => {
  // cancel 的请求直接返回
  const cancelled = axios.isCancel(error);

  if (cancelled) {
    return [undefined, { cancelled }];
  }

  // native 错误
  if (!axios.isAxiosError(error)) {
    return handleNativeError(error, config);
  }

  // axios 错误
  const isAuthFail = isAuthExpired(error);
  const isTimeout = isTimeoutError(error);
  const status = error.response?.status;
  const displayMessage = error.response?.data?.message;
  const transactionId = getTargetKeyFromConfig(error.config, TRANSACTION_ID_IN_HEADER);
  const requestStartTime = getTargetKeyFromConfig(error.config, REQUEST_START_AT_IN_HEADER);
  const networkStatusAtRequestStart = !!error.config[NETWORK_STATUS_AT_REQUEST_START];

  const intervalBetweenRequestAndResponse = dayjs().diff(dayjs(requestStartTime));

  const errorContext = {
    requestStartTime,
    method: config.method ?? 'N/A',
    endpoint: config.url ?? 'N/A',
    responseCode: status,
    message: displayMessage ?? error.message,
    networkStatusAtRequestStart,
  };

  // auth 失败的请求直接返回
  if (isAuthFail) {
    return [undefined, { cancelled }];
  }

  // 处理错误提示
  if (!silenceError) {
    if (isNetworkError(error)) {
      message.error('network_error');
    } else if (status === 404) {
      message.error(displayMessage ?? 'error_404');
    } else if (status === 403) {
      console.warn(displayMessage);
    } else {
      message.error(displayMessage);
    }
  }

  // 处理错误上报
  if (captureErrorInSentry) {
    if (error.response) {
      // 请求成功发出且服务器也响应了状态码,但状态代码超出了 2xx 的范围
      if (!isExternalRequest && status === 400) {
        sendWarningLog(error, transactionId);
      } else {
        captureError({
          error,
          context: errorContext,
          transactionId,
        });
      }
    } else if (error.request) {
      // 请求已经成功发起,但没有收到响应

      logEvent(isTimeout ? 'Receive Response Timeout' : 'Receive Request Error', {
        ...errorContext,
        error: safeStringify(error.toJSON()),
      });

      // 发起请求时网络正常 && 发起请求时间和报错时间间隔不超过两倍的 timeout,才上报至 Sentry,避免无效问题误报
      if (
        networkStatusAtRequestStart &&
        intervalBetweenRequestAndResponse < 2 * DEFAULT_REQUEST_TIMEOUT
      ) {
        captureError({
          error,
          context: errorContext,
          transactionId,
        });
      }
    } else {
      // 发送请求过程中出现问题
      captureError({
        context: errorContext,
        transactionId,
      });
    }
  }

  return [
    undefined,
    {
      error,
      cancelled,
    },
  ];
};

export const createAxios = (axiosConfig?: AxiosRequestConfig, isExternalRequest?: boolean) => {
  const newInstance = axios.create(axiosConfig);

  // register plugin
  registerAxiosRetry(newInstance, isExternalRequest);

  // register request interceptor
  registerRequestInterceptor(newInstance, isExternalRequest);

  // register response interceptor
  registerResponseInterceptor(newInstance, isExternalRequest);

  return newInstance;
};

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions