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.33k stars 2.65k forks source link

useSubscription has no way to re-connect after error #11304

Closed objectiveSee closed 1 month ago

objectiveSee commented 11 months ago

I am testing my app when the internet is bad. My subscription fails and I get an error, but there is no function exported by the subscription hook that let's me retry the connection. Similar to the refetch property in useQuery. Currently, I am unable to have my application reconnect the subscription once it has failed. This seems to be a pretty major oversight in how subscriptions work, so hoping that there's a workaround.

Is there some way to trigger a re-subscription attempt?

  // Setup subscription
  // TODO: If the subscription fails (eg. no internet) then there isn't a way for it to succeed again :/
  // This needs to be fixed otherwise user will be stuck on profile page thinking that the Retry button doesn't work
  // because the subscription is never retried.
  const { data: subscriptionData, error: subscriptionError } =
  useSubscription<OnUserUpdatedSubscription, OnUserUpdatedSubscriptionVariables>(
    onUserUpdatedSubscription,
    {
      variables: { id: userId },
      onError: (error: ApolloError) => {
        console.log(`[APOLLO ERROR] User Subscription Failed: ${JSON.stringify(error)}`)
      }
    }
  );

  // Query for user data on mount
  const { data: queryData, loading: queryLoading, error: queryError, refetch } =
  useQuery<GetUserQuery, GetUserQueryVariables>(getUserQuery, {
    variables: { userId },
    fetchPolicy: 'network-only',
    notifyOnNetworkStatusChange: true,
    errorPolicy: 'none',
  });

I am using:

    "@apollo/client": "^3.6.9",
    "aws-appsync-subscription-link": "^3.1.2",
  const httpLinkWithAuth = ApolloLink.from([
    errorLink,
    authMiddleware,
    createAuthLink({ url, region, auth }),
    createSubscriptionHandshakeLink({ url, region, auth }, httpLink)
  ]);

  client = new ApolloClient({
    link: httpLinkWithAuth,
bignimbus commented 11 months ago

Hi @objectiveSee 👋🏻 there are several different protocols that can handle GraphQL subscriptions, some of which are handled by modules we officially support. AWS AppSync packages are provided by a third party, however. The core Apollo Client API itself is not opinionated about how to handle Websockets connections, so I recommend looking at the aws-appsync-subscription-link docs for guidance on how to handle disconnect events in your applications.

objectiveSee commented 10 months ago

@bignimbus do you have any perspective on this from Apollo's standpoint? I am curious how Apollo client handles re-connecting the underlying socket(s) that support the subscription. Does any link you have in mind handle this or is there additional code needed to support reconnecting? I am looking for alternatives in case AppSync doesn't work. It appear that the topic of re-connecting subscriptions is not discussed a lot.

bignimbus commented 10 months ago

We'd like to make the experience around reconnecting more clear for users while accounting for the fact that different protocols/libraries will expose different and sometimes not completely analogous interfaces. Expect to see some improvements to the developer experience along these lines in future releases, but we have no concrete plans yet.

jamiter commented 10 months ago

The action could be simply be a "hard refresh" and restart the subscription, independent of the underlying protocol or library. Just like useSubscription is implementation independent. Something like this:

const { reconnect } = useSubscription(
    onUserUpdatedSubscription,
    {
      variables: { id: userId },
      onError: (error: ApolloError) => {
        if (error === 'something specific') {
          reconnect()
        }
      }
    }
  );

Maybe it should be called restart or reconnect or resubscribe. The result should be a new subscription, as if useSubscription was called for the first time.

jamiter commented 10 months ago

I think what I would like to achieve is what the shouldResubscribe option does, but manually.

To quote the docs:

Determines if your subscription should be unsubscribed and subscribed again when an input to the hook (such as subscription or variables) changes.

So onError or onComplete I could resubscribe again. The skip might do for now, but a more standardised way would be preferred of course.

More background info on my use case What I'm implementing is a Subscription not based on WebSockets, but HTTP multipart requests, like [documented here]( https://www.apollographql.com/docs/router/executing-operations/subscription-multipart-protocol/#heartbeats). This is handled nicely by Apollo Client, but I have to implemented this manually in my NextJS backend. Maybe I'll open source it someday. The thing is, when hosting on Vercel, the HTTP request will timeout after a maximum of 5 minutes on the Pro plan. If this happens, I'll need to resubscribe. I still have to determine if this errors or completes the subscription, but I would then like to simply resubscribe using a new HTTP request. Once I've figured this all out I'll make sure to update here.
objectiveSee commented 10 months ago

Thank you for the response @jamiter. I agree that adding a reconnect property to the hook would be really helpful. What would it take to get this added into the Apollo library? In the meantime, I wrote a custom hook that handles the reconnect logic through the skip hack that I mentioned. I added the ability to automatically reconnect as well as exposing a reconnect function. Here is the code for anyone who is interested.

Separately, I am dealing with an issue where the websocket implementation that I am using does not support the onComplete callback which is causing its own issues 🤯


import { ApolloError, DocumentNode, OperationVariables, SubscriptionHookOptions, TypedDocumentNode, useSubscription } from '@apollo/client';
import { useCallback, useState } from 'react';

import { delay } from '../utilities/delay';

// ms between reconnect attempts
const RECONNECT_TIME = 10000;
const DELAY_2 = 1000;   // ms skip is set to true before reconnecting
const DELAY_1 = RECONNECT_TIME - DELAY_2; // ms before skip is set to false pre-skip

export function useReconnectingSubscription<TData = any, TVariables extends OperationVariables = OperationVariables>(
  subscription: DocumentNode | TypedDocumentNode<TData, TVariables>,
  options?: SubscriptionHookOptions<TData, TVariables>
) {
  const [internalSkip, setInternalSkip] = useState(false);

  const reconnect = useCallback(async () => {
    // Toggle the value of `skip` to force the subscription to reconnect.
    // The value must be true then false for the reconnect to work.
    await delay(DELAY_1);  // allow 1sec for the error to show up. Once skip:true is set, the error will be cleared.
    setInternalSkip(true);
    console.log('🔌⭐️ Delaying reconnect');
    await delay(DELAY_2);
    console.log('🔌⭐️ Reconnecting');
    setInternalSkip(false);
  }, []);

  // Internally handle errors by reconnecting and calling the user-provided onError callback
  const onError = useCallback(async (error: ApolloError) => {
    console.log(`[useReconnectingSubscription] Subscription Failed: ${JSON.stringify(error)}`);
    if ( options.onError ) options.onError(error);
    return reconnect();
  }, [options, reconnect]);

  // NOTE: AWS AppSync subscription implementation doesn't call onComplete
  // See: https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/759
  const onComplete = useCallback(() => {
    // TODO: Implement exponential backoff of retries once we can determine when a
    // successful subscription has been established. (eg. to reset the exponential backoff)
    console.log('😵😵😵😵😵😵😵😵😵 This never happends');
    if ( options.onComplete ) {
      options.onComplete();
    }
  }, [options]);

  // Merge customized options with the user-provided options
  const mergedOptions = {
    ...options,
    skip: options?.skip || internalSkip,
    onError,
    onComplete
  };

  const { data, loading, error } = useSubscription<TData, TVariables>(subscription, mergedOptions);

  return {
    data,
    loading,
    error,
    reconnect
  };
}
silverprize commented 7 months ago

Sadly I just recreate instance of apollo client for reconnect.

phryneas commented 2 months ago

This will be added in 3.11 with #11927

phryneas commented 1 month ago

Version 3.11 has been released with this feature.

github-actions[bot] commented 1 month ago

Do you have any feedback for the maintainers? Please tell us by taking a one-minute survey. Your responses will help us understand Apollo Client usage and allow us to serve you better.

github-actions[bot] commented 3 weeks ago

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. For general questions, we recommend using StackOverflow or our discord server.