Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

if a Query is stopped using AbortController, it cannot be executed again. #4150

Closed
gabor opened this issue Nov 16, 2018 · 23 comments · May be fixed by apollographql/apollo-link#880
Closed

if a Query is stopped using AbortController, it cannot be executed again. #4150

gabor opened this issue Nov 16, 2018 · 23 comments · May be fixed by apollographql/apollo-link#880

Comments

@gabor
Copy link

gabor commented Nov 16, 2018

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
@Phaster911
Copy link

any updates on this?

@charan432
Copy link

I am also facing the same issue.. Does anyone have an update on this??

@AW-TaylorBett
Copy link

AW-TaylorBett commented Mar 26, 2019

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

@gabor
Copy link
Author

gabor commented Mar 26, 2019

@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.

@Peterabsolon
Copy link

Peterabsolon commented Mar 28, 2019

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...

@helfer
Copy link
Contributor

helfer commented Apr 28, 2019

@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 Collection helpers #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).

@gabor
Copy link
Author

gabor commented Apr 29, 2019

@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

@gabor
Copy link
Author

gabor commented Apr 29, 2019

@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.

@francoisromain
Copy link

francoisromain commented May 30, 2019

@gabor could you show a code example of how to implement this from an existing query please?

@gabor
Copy link
Author

gabor commented May 31, 2019

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.

@dylanwulf
Copy link
Contributor

@helfer Do you know of any way to cancel a fetchMore request? The unsubscribe method doesn't seem to work on requests initiated through fetchMore

@leebenson
Copy link

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

@hwillson
Copy link
Member

#4150 (comment) should help answer this. Thanks!

@javier-garcia-meteologica
Copy link
Contributor

javier-garcia-meteologica commented Oct 13, 2020

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.

@javier-garcia-meteologica
Copy link
Contributor

javier-garcia-meteologica commented Oct 14, 2020

#6985 explains why it doesn't work in apollo client 3.

@bedcoding
Copy link

bedcoding commented Oct 16, 2020

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...

@Covmon
Copy link

Covmon commented Oct 28, 2020

Anyone come up with a solution in Apollo 3?

@Morphexe
Copy link

Also in dire need of being able to cancel a long running query based on user input using Apollo 3.

@brendanmoore
Copy link

I believe that part of the issue will be fixed in 3.3+. If you use your own watchQuery and manually unsubscribe. I am using this approach: https://gist.github.com/brendanmoore/8cd239c5b35983936581b61922b09b2e and it works flawlessly in 3.3.0-beta.16 as Apollo does use its own AbortController internally when you unsubscribe - (when using HttpLink).

@donegjookim
Copy link

I was able to work around this issue by integrating AbortController with fetchMore method.

This function is a member of the ObservableQuery object returned by client.watchQuery, and it's also included in the object returned by the useQuery hook (fetchMore Doc Link)

@robert-king
Copy link

robert-king commented May 24, 2022

problematic scenarios:
if user turns off their wifi and then turns their wifi on, or if you're testing offline mode using chrome devtools network throttling, and then when you come back online and refresh buttons are broken. In these scenarios i think the request is cancelled by the browser and it's hard to hook into correct apollo unsubscribe method / stack unwinding.

@LiamKarlMitchell
Copy link

createHttpLink has an unhandled TypeError for me when in offline mode and retry-link doesn't get the event.

@claycoleman
Copy link

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

@leebenson 's answer still works for me using ApolloClient 3.4.11. For my use case, queries could still be cancelled without using queryDeduplication: false, which I wanted to avoid.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 1, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet