Sitelet https://github.com/PeterChen1997/MyBlog/issues/44
Skip to content

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

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;
};

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions