apollo-client: if a Query is stopped using AbortController, it cannot be executed again.

Intended outcome:

i use react with apollo. i want to cancel a running graphql query using the AbortController approach to stop the fetch request. later i want to run the query again.

Actual outcome:

i can cancel the query using an AbortController.but, if i execute the query again, no http-request is sent. maybe some cleanup did not happen and apollo still considers the query running? no idea. i can start other queries. so for example, if this graphql query has a parameter, i can start the query with param1, cancel it, then start it with param2 and it works. but if i start it again with param1, no http request is sent.

How to reproduce the issue:

  • create a react-component
  • wrap it in withApollo
  • somewhere in the component do a handler to a button-click or something that you can do multiple times:
handleButtonClicked() {
    const abortController = new AbortController();
    const { signal } = abortController;

    this.props.client
      .query({
        query: QUERY,
        variables: {
          .
          .
          .
        },
        context: {
          fetchOptions: {
            signal,
          },
        },
        fetchPolicy: 'network-only',
      })
      .then(() => {console.log('done');});

    setTimeout(() => {
        abortController.abort();
    }, 3000);
}
  • the QUERY should go to something slow (like, response in 20seconds)

  • open the network-tab in the browser (i tried in google-chrome)

  • click the button, and verify that the query is first pending and then later canceled

  • click the button again, and there will be no new line in the network-tab

Versions

  System:
    OS: macOS 10.14.1
  Binaries:
    Node: 8.12.0 - /usr/local/opt/node@8/bin/node
    Yarn: 1.12.1 - /usr/local/bin/yarn
    npm: 6.4.1 - /usr/local/opt/node@8/bin/npm
  Browsers:
    Chrome: 70.0.3538.102
    Firefox: 63.0.1
    Safari: 12.0.1

About this issue

  • Original URL
  • State: closed
  • Created 6 years ago
  • Reactions: 28
  • Comments: 23 (5 by maintainers)

Most upvoted comments

I spent some time this weekend debugging this problem, and I found it was necessary to set both queryDeduplication: false on the ApolloClient and use .watchQuery instead of .query + an explicit AbortController.signal.

I wound up with something like…

Creating the Apollo client:

const client = new ApolloClient({
  // Regular options here, such as...
  cache,
  links: ApolloLink.from([links,go,here]),

  // This is what enables cancelation
  queryDeduplication: false
})

Then to issue/cancel a query…

// Issue the query
const query = client.watchQuery<SomeQuery, SomeQueryVariables>({
  // Usual stuff, no need for a custom `AbortController.signal`
  query: SomeQueryDocument,
  fetchPolicy: "network-only",
  variables,
});

// Subscribe to it, and do something with the data
const observable = query.subscribe(({ data }) => {
  // do something with `data`
  // ...
})

// Then somewhere you want to cancel it...
observable.unsubscribe(); // <-- will implicitly cancel the fetch

That should successfully cancel a query, and enable re-sending, e.g. in this case where I canceled a query twice, and let it succeed the third time:

Screenshot 2019-06-08 at 15 17 48

@gabor where did you read about the AbortController approach? I think it’s the wrong way to do this, because reaching into the link/fetch and cancelling the request breaks all the abstractions that ApolloClient and apollo link set up for you. If you ask ApolloClient to run a query, then you should tell ApolloClient if you no longer wish to run that query, so the stack can be unwound in an orderly fashion. If you called client.query to run it, you should use that same interface to cancel the query. Unfortunately that’s not possible at the moment, but it should be possible by making the promise cancellable.

Under the hood client.query calls watchQuery, which supports unsubscribing. If I’m not mistaken, unsubscribing from a watch query will propagate through the link stack and abort the fetch request in browsers that support it, so this is the proper way to go.

If you want to make query cancellable, you could consider making a PR to apollo client that adds a cancel function to the promise returned.

As a workaround for the time being, here are two other options:

  1. Use watchQuery and unsubscribe from it when you want to cancel the request. You can turn an observable into a promise pretty easily. I’m not 100% sure if this will work, but it should work.
  2. If #1 doesn’t work or if you don’t really need the query to go through ApolloClient’s cache, you can use client.link directly to make the request by calling execute(link, operation), as described here: https://www.apollographql.com/docs/link/#standalone If you unsubscribe from the observable there (by calling the function returned from observable.subscribe), the link stack should be properly unwound, and your fetch request will get cancelled if it’s still pending.

PS: The reason the second request hangs if the first one was aborted is precisely because assumptions were violated by breaking the abstraction. As you can see here, the authors originally assumed that the only way a request would get aborted is via unsubscribing. If you’re aborting it directly, that assumption is obviously violated, and the error doesn’t propagate through the stack. This leaves other links and subscribers in the stack hanging, because they received neither an error, nor were they unsubscribed from. In your specific case, the dedup link is left hanging, so when a second identical request comes in, it ends up being deduplicated and your second requests waits for the first one to complete, not knowing that it was aborted. The simplest workaround in that case is to just passing queryDeduplication: false when you initially instantiate ApolloClient. Keep in mind however that this might now result in multiple identical queries being in flight at the same time if your client makes the same query more than once in a short interval.

PPS: Looking at your code, it seems what you really want is just a query timeout. If so, then the right solution would be to use a timeout link (eg. apollo-link-timeout. Although that particular implementation issues and also breaks assumptions, it should fix your problem for now).

@helfer thanks for the response. there is a lot of info in your comment, so i will try to react to it’s parts:

  • generally, what i want to achieve is to cancel the query. it does not really matter for me how it is achieved as long as it works
  • it is not a timeout-based situation. (if we want to go with an example-situation, imagine i start a long-running search-query, but in the middle i reconsider and i want to search for something else).
  • the reason i went with the AbortController approach is because:
    • i could not find any documentation about how to cancel a running apollo-query.
    • fetch requests are cancelled using an AbortController
    • apollo allows passing extra options to the fetch call using fetchOptions (https://www.apollographql.com/docs/link/links/http)
  • i am totally ok with doing it a different way, but please note apollo should probably reject overrides to the abortController in fetch, or at least document that it should not be overridden, because currently it is not obvious that this breaks any abstractions.
  • the watchQuery approach should be fine for my use-case, i will try it if it works, and get back to you with the result

any updates on this?

@helfer i tried it out, and the watchQuery + unsubscribe approach does work, thanks a lot! would be nice to have this mentioned somewhere in the documentation.

Anyone come up with a solution in Apollo 3?

@AW-TaylorBett thank you for the info. unfortunately the situation is somewhat different. the problem-scenario is this:

  1. start request
  2. abort request
  3. start request

and the request in [3] does not happen.

I used the solution proposed by @leebenson in apollo client 2 and it worked fine. But it’s not working in apollo client 3.

I am using queryDeduplication: false, fetchPolicy: 'network-only' and I’ve checked that unsubscribe tears down the query, but somehow the query is not aborted.

I have a similar problem. I made a search cancellation like this. However, there is a problem that’words that have already been canceled’ are not recalled.

EX) If “apple” is cancelled, it searches up to “appl”, but “apple” does not re call api.

  • I don’t speak English, so I use Google Translate.

[process] A. Whenever a new event occurs, cancel the previous abortController. B. Reassign a new abortController.

[reason]

  • abortController.abort() only worked the first time.
  • because abortController only looks at the ‘first called API’.
  • So, every time I call the API, I allocate abortController again.

스크린샷 2020-10-16 오후 3 29 10

[logic1]

   const [_controller, set_controller] = useState(new AbortController());   // state

[logic2]

      const { GET_MY_NAME } = query;
      const [getMyData] = useLazyQuery(GET_MY_NAME, {
        fetchPolicy: "no-cache",
        variables: { myword: myword },
        context: {
          fetchOptions: {
            signal: _controller.signal
          }
        },
         
        onCompleted: (data) => {
            // get data!!    ex) dispatch(nameActions.setName(data.name));
        }
     });

[logic3]

  let timer = null;

  const onChange = (e) => {
     // 1. kill old API
      _controller.abort();  

      // 2. Initialize setTimeout when new input comes in
      clearTimeout(timer);  
      
     // 3. state update, API start
      timer = setTimeout(async (_) => {
          await set_controller(new AbortController());  
          await getMyData();
      }, 500);
  }

[logic4]

          <input
            onChange={onChange}
          />

[Method currently being considered]

I am thinking of a method of separating components only in the search box and fetching components whenever they are called…

sorry @francoisromain , i do not have access to that code anymore. it went roughly like this:

  • you have the code that does client.query(params).then(...)
  • you switch to const thing = client.watchQuery(params)
  • somewhere on thing there is a way to subscribe a callback-function to it. you will get the query-result that way
  • if, during the execution of the query, you unsubscribe the callback-function from the thing, the ajax-request will stop.

unfortunately i do not remember the exact api-calls, but there is some way to go from that thing to something that has the api like zen-observable ( https://github.com/zenparsing/zen-observable ). perhaps, if you use typescript the auto-complete can navigate you to it. or maybe someone else can link to a code-example.

Same issue here. The third request never fires, even despite executing with a new, unaborted AbortController.

A ugly workaround I use for now is that I pass some additional variable foo with Math.random() to distinguish the queries…

You need to recreate a new AbortController for the second instance of the request. A single AbortController is associated with a single request via its AbortSignal. Hence your AbortController is still associated with the original request instance.

See https://developer.mozilla.org/en-US/docs/Web/API/AbortController

Edit: Seems like you should be doing that already with your code. There may be something in chrome that is actually recognising and reusing the same req. or AbortController/AbortSignal.

You may also consider that the browser could be automatically retrying the request on account of not receiving any status in response from the server. See https://blogs.oracle.com/ravello/beware-http-requests-automatic-retries