urql-graphql / urql

The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
https://urql.dev/goto/docs
MIT License
8.57k stars 444 forks source link

Suggestion: Clarify that there can be only one `retryExchange` in docs when calling `createClient`. #3348

Open rfrankspk opened 1 year ago

rfrankspk commented 1 year ago

Describe the bug

When calling createClient with an array of exchanges, if there are multiple retryExchanges for errors, then an infinite loop is caused when an error is encountered.

https://github.com/urql-graphql/urql/assets/72031388/2668bf77-8245-4c30-abf9-991165df8e59

Suggestion: The documents provide many great examples of retryExchange usage but it didn't seem to explain the caveat clearly that only one is allowed. Perhaps make this clearer in the documentation, or perhaps it is a bug or unintended consequence.

Here is the doc page we followed... https://formidable.com/open-source/urql/docs/advanced/retry-operations/

Would love to help if I can! We love the library!

What we tried

/**
 * This exchange is used to retry a GraphQL request if it fails due to a canceled context error.
 * @param opts the options for the retry exchange
 * @returns   An exchange that will retry a GraphQL request if it fails due to a canceled context error
 */
export const canceledContextErrorRetryExchange = ({
  initialDelayMs = 1000,
  maxDelayMs = 15000,
  randomDelay = true,
  maxNumberAttempts = 2,
}): Exchange => {
  return retryExchange({
    initialDelayMs,
    maxDelayMs,
    randomDelay,
    maxNumberAttempts,
    retryIf: (err: any) => err && err.message?.includes("context canceled"),
  });
};

/**
 * This exchange is used to retry a GraphQL request if it fails due to a GraphQL API error.
 * @param opts the options for the retry exchange
 * @returns   An exchange that will retry a GraphQL request if it fails due to a GraphQL API error
 */
export const graphQLErrorRetryExchange = ({
  initialDelayMs = 1000,
  maxDelayMs = 15000,
  randomDelay = true,
  maxNumberAttempts = 2,
}): Exchange => {
  return retryExchange({
    initialDelayMs,
    maxDelayMs,
    randomDelay,
    maxNumberAttempts,
    retryIf: (err: any) => err && err.graphQLErrors?.length > 0,
  });
};

/**
 * This exchange is used to retry a GraphQL request if it fails due to a network error.
 * @param opts the options for the retry exchange
 * @returns   An exchange that will retry a GraphQL request if it fails due to a network error
 */
export const networkErrorRetryExchange = ({
  initialDelayMs = 1000,
  maxDelayMs = 15000,
  randomDelay = true,
  maxNumberAttempts = 2,
}): Exchange => {
  return retryExchange({
    initialDelayMs,
    maxDelayMs,
    randomDelay,
    maxNumberAttempts,
    retryIf: (err: any) => err && err.networkError,
  });
};

export const generateGraphQLClient = (
  configs: DataProviderConfig[],
  environment?: string,
  appId?: string,
  onError?: (error: CombinedError, operation: Operation) => void
): Client => {
  const defaultConfig = configs[0];
  const { url: defaultUrl } = defaultConfig;

  return createClient({
    url: defaultUrl || "http://localhost:4002/graphql",
    fetch: async (input, init) => {
      const controller = new AbortController();

      // timeout long running requests after 30 seconds
      const timeout = setTimeout(() => {
        controller.abort();

        onError?.(
          new CombinedError({
            networkError: new Error("Client timed out request to GraphQL API"),
            graphQLErrors: [],
          }),
          null
        );
      }, 30000);

      const response = await fetch(defaultUrl, {
        ...init,
        signal: controller.signal,
      });

      clearInterval(timeout);

      return handleAuthRedirect(response);
    },
    fetchOptions: () => {
      appId = sessionStorage.getItem("application-id") || appId;

      const headers = Object.assign(
        {},
        appId ? { "x-application-id": appId } : {}
      );

      return { headers };
    },
    exchanges: [
      // cacheExchange,
      oktaAuthExchange({ environment }),
      errorExchange({
        onError(error: CombinedError, operation: Operation) {
          // call the onError callback if it exists, which is responsible for bubbling up the error to the nearest error boundary
          onError?.(error, operation);
        },
      }),
      debugExchange({ environment }),
      multipleDataProviderExchange(configs),
      canceledContextErrorRetryExchange({}),
      graphQLErrorRetryExchange({}),
      networkErrorRetryExchange({}),
      fetchExchange,
    ],
  });
};

What ended up working, but we would like finer grained control by providing multiple error retryExchanges.

/**
 * This exchange is used to retry a GraphQL request if it fails due to a network error, context canceled error, or GraphQL API error.
 * @param opts the options for the retry exchange
 * @returns   An exchange that will retry a GraphQL request if it fails due to a specific error
 */
 export const errorRetryExchange = ({
  initialDelayMs = 1000,
  maxDelayMs = 15000,
  randomDelay = true,
  maxNumberAttempts = 2,
}): Exchange => {
  const errorConditions = [
    (err: any)  => err && err.message?.includes("context canceled"), // canceledContextError,
    (err: any) => err && err.graphQLErrors?.length > 0, // graphQLErrorRetryExchange,
    (err: any) => err && err.networkError, // networkErrorRetryExchange,
  ]
  return retryExchange({
    initialDelayMs,
    maxDelayMs,
    randomDelay,
    maxNumberAttempts,
    retryIf: (err: any) => errorConditions.some((condition) => condition(err)),
  });
};

Reproduction

https://github.com/urql-graphql/urql/tree/main/examples/with-retry

Urql version

   "@urql/exchange-auth": "^2.1.5",
    "@urql/exchange-retry": "^1.2.0",
    "urql": "^4.0.4",
    "wonka": "^6.2.3"

Validations