apollographql / apollo-client

:rocket:  A fully-featured, production ready caching GraphQL client for every UI framework and GraphQL server.
https://apollographql.com/client
MIT License
19.4k stars 2.66k forks source link

Better documentation for async handling inside apollo link #11143

Open jerelmiller opened 1 year ago

jerelmiller commented 1 year ago

We've seen a few cases where users struggle with async handling inside of Apollo Link. The link chain expects returned observables from the request handlers, but typically devs want to execute async code. setContext is one of the rare links that allow you to pass an async function and operate as expected. I've also seen devs try and return async functions from onError, but this will not work because that link expects that if you want to retry an operation, you will return an observable. This causes issues however because promises returned by an async onError won't correctly be handled by the retry functionality.

We should update our documentation and provide a guide or troubleshooting tips on how to add async functions within an apollo link to help engineers who typically need this functionality.

arahman4710 commented 1 year ago

@jerelmiller, this is the exact issue i'm facing right now. Could you point me to a couple examples of issues users have fixed and how it was resolved? Thanks!

jerelmiller commented 1 year ago

Hey @arahman4710 👋

I'd recommend taking a look at the implementation of the setContext link. The trick here is to return an Observable from the ApolloLink that waits to call forward(operation) until the promise has resolved. Hope this helps!

usmansbk commented 11 months ago
const errorLink = onError(({ networkError, forward, operation }) => {
        operation.setContext(async ({ headers = {} }) => {
           const token = await getNewToken();

         if (token) {
            return {
              headers: {
                ...headers,
                Authorization: token ? `Bearer ${token}` : "",
              },
            };
          } else {
             // logout
          }
        });

        return forward(operation);
});
jerelmiller commented 11 months ago

Hey @usmansbk 👋

Appreciate the code snippet, but I'm afraid there are a few things that won't work with that implementation.

operation.setContext expects the value returned from the function to be a synchronous value, which is then spread onto the existing context. Using an async function here means that a promise will be returned, so you'd be spreading the promise properties instead of the new context object.

Additionally, even if this were to work correctly, forward(operation) is going to execute first, which means the async function passed to setContext would execute too late in the process and the request would start before the async function could be resolved.

What I'm specifically looking for in this issue is to both document how to implement a custom link yourself that needs to use async code inside its request handler function and to better document how you might use async data within onError since an async function here won't work correctly. In other words, how people can do this kind of thing:

const customLink = new ApolloLink((operation, forward) => {
  // do some async stuff in here
})

Just wanted to make sure that you were aware that the snippet above won't execute as you'd expect!

usmansbk commented 11 months ago

Hey @usmansbk 👋

Appreciate the code snippet, but I'm afraid there are a few things that won't work with that implementation.

operation.setContext expects the value returned from the function to be a synchronous value, which is then spread onto the existing context. Using an async function here means that a promise will be returned, so you'd be spreading the promise properties instead of the new context object.

Additionally, even if this were to work correctly, forward(operation) is going to execute first, which means the async function passed to setContext would execute too late in the process and the request would start before the async function could be resolved.

What I'm specifically looking for in this issue is to both document how to implement a custom link yourself that needs to use async code inside its request handler function and to better document how you might use async data within onError since an async function here won't work correctly. In other words, how people can do this kind of thing:

const customLink = new ApolloLink((operation, forward) => {
  // do some async stuff in here
})

Just wanted to make sure that you were aware that the snippet above won't execute as you'd expect!

It works perfectly for me. 🤔 But thanks for the clarification. I will research more and see.

jerelmiller commented 11 months ago

@usmansbk interesting! I would think it would have some problems, but now I'm curious to try it out myself!

usmansbk commented 11 months ago

You should. This is the complete snippet on how I implemented refreshTokenLink.

const retryLink = new RetryLink();

const httpLink = new HttpLink({
  uri: env.graphqlApiEndpoint,
});

const refreshTokenLink = onError(({ networkError, forward, operation }) => {
  if (networkError) {
    const error = networkError as ServerNetworkError;

    if (error.statusCode === 401) {
      const authState = cache.readQuery({
        query: AuthState,
      });

      if (authState?.auth) {
        const { accessToken, refreshToken } = authState.auth;

        if (accessToken && refreshToken) {
          operation.setContext(async () => {
            const response = await fetch(
              `${env.restApiEndpoint}/auth/token/refresh`,
              {
                method: "POST",
                headers: {
                  access_token: accessToken,
                  refresh_token: refreshToken,
                },
              }
            );

            if (response.status === 200) {
              const { accessToken, refreshToken } = (await response.json()) as {
                accessToken: string;
                refreshToken: string;
              };

              if (accessToken && refreshToken) {
                cache.writeQuery({
                  query: AuthState,
                  data: {
                    auth: {
                      accessToken,
                      refreshToken,
                    },
                  },
                });
              }
            } else if (response.status === 401) {
              cache.writeQuery({
                query: AuthState,
                data: {
                  auth: null,
                },
              });
            }
          });

          return forward(operation);
        }
      }
    }
  }
});

const authLink = setContext((_, { headers }) => {
  const authState = cache.readQuery({
    query: AuthState,
  });

  const token = authState?.auth?.accessToken;

  return {
    headers: {
      ...headers,
      Authorization: token ? `Bearer ${token}` : "",
    },
  };
});

const client = new ApolloClient({
  cache,
  link: from([retryLink, refreshTokenLink, authLink, httpLink]),
});
FaizanMostafa commented 5 months ago

You should. This is the complete snippet on how I implemented refreshTokenLink.

const retryLink = new RetryLink();

const httpLink = new HttpLink({
  uri: env.graphqlApiEndpoint,
});

const refreshTokenLink = onError(({ networkError, forward, operation }) => {
  if (networkError) {
    const error = networkError as ServerNetworkError;

    if (error.statusCode === 401) {
      const authState = cache.readQuery({
        query: AuthState,
      });

      if (authState?.auth) {
        const { accessToken, refreshToken } = authState.auth;

        if (accessToken && refreshToken) {
          operation.setContext(async () => {
            const response = await fetch(
              `${env.restApiEndpoint}/auth/token/refresh`,
              {
                method: "POST",
                headers: {
                  access_token: accessToken,
                  refresh_token: refreshToken,
                },
              }
            );

            if (response.status === 200) {
              const { accessToken, refreshToken } = (await response.json()) as {
                accessToken: string;
                refreshToken: string;
              };

              if (accessToken && refreshToken) {
                cache.writeQuery({
                  query: AuthState,
                  data: {
                    auth: {
                      accessToken,
                      refreshToken,
                    },
                  },
                });
              }
            } else if (response.status === 401) {
              cache.writeQuery({
                query: AuthState,
                data: {
                  auth: null,
                },
              });
            }
          });

          return forward(operation);
        }
      }
    }
  }
});

const authLink = setContext((_, { headers }) => {
  const authState = cache.readQuery({
    query: AuthState,
  });

  const token = authState?.auth?.accessToken;

  return {
    headers: {
      ...headers,
      Authorization: token ? `Bearer ${token}` : "",
    },
  };
});

const client = new ApolloClient({
  cache,
  link: from([retryLink, refreshTokenLink, authLink, httpLink]),
});

I have an implementation to refresh the jwt token where I am processing graphQLErrors instead of networkError and my implementation is breaking(stop refreshing jwt) randomly in prod. My code is attached, any insights?

const errorLink = onError(({ graphQLErrors, operation, forward }): any => {
    if (graphQLErrors && graphQLErrors.length > 0) {
      for (const error of graphQLErrors) {
        if (error.extensions && error.extensions.code && error.extensions.code === 'UNAUTHENTICATED') {
          let forward$;

          if (!isRefreshing) {
            isRefreshing = true;
            forward$ = fromPromise(
              getNewToken()
                .then((accessToken) => {
                  // Store the new tokens for your auth link
                  resolvePendingRequests();
                  isRefreshing = false;
                  return accessToken;
                })
                .catch((error) => {
                  // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
                  logout(error);
                  return;
                }),
            ).filter((value) => Boolean(value));
          } else {
            // Will only emit once the Promise is resolved
            forward$ = fromPromise(
              new Promise((resolve) => {
                pendingRequests.push(() => resolve(true));
              }),
            );
          }

          return forward$.flatMap(() => forward(operation));
        } else if (error.message) {
          console.log(`[GraphQL error]: Message: ${error.message} - Operation: ${operation.operationName}`);
        }
      }
    }
  });
jerelmiller commented 5 months ago

Hey @FaizanMostafa 👋

At a glance your code seems fine, but without being able to run this or use it in context with the other links in your link chain, its difficult to tell what might be wrong.

I'd suggest reaching out in our discord channel where others in the community might be able to provide some pointers on how they handle auth errors/refreshes.