Retry failed API call in for...of loop
Asked Answered
C

3

6

I am trying to build a wrapper over Notion JS SDK's iteratePaginatedAPI that handles errors as well. I feel particularly lost on how do I catch API errors in such a way that I can actually retry them (aka retry the iteration that failed). Here's my attempt:

async function* queryNotion(listFn, firstPageArgs) {
  try {
    for await (const result of iteratePaginatedAPI(listFn, firstPageArgs)) {
      yield* result
    }
  } catch (error) {
    if (error.code === APIErrorCode.RateLimited) {
      console.log('rate_limited');
      console.log(error);
      sleep(1);
      // How would I retry the last iteration?
    }
  }
}

Coming from the Ruby world, there is a retry in a rescue block. Any help would be appreciated!

Czernowitz answered 14/2, 2023 at 21:37 Comment(0)
H
2

Very interesting problem. The issue is that the exception comes from the for await itself, not from its body, so you cannot catch it there. When the exception hits, loops are over.

Note that the iterator might be done after a rejection/exception, in which case there is nothing you can do except starting a new one.

That said, you can always call Iterator.next() yourself and process the result manually. The next() call of an async iterator will return an object like {value: Promise<any>, done: boolean}, and when running it in a loop, you can await the promise in a try..catch and only exit the loop when done becomes true:

async function* queryNotion(listFn, firstPageArgs) {
  const asyncGenerator = mockIteratePaginatedAPI(listFn, firstPageArgs)
  while (true) {
    const current = asyncGenerator.next()
    if (current.done) {
      break
    }
    try {
      yield* await current.value
    } catch (e) {
      console.log(`got exception: "${e}" - trying again`)
      continue
    }
  }
}

function* mockIteratePaginatedAPI(a, b) {
  for (let i = 0; i < 8; i++) {
    yield new Promise((resolve, reject) => setTimeout(() => [3, 5].includes(i) ? reject(`le error at ${i}`) : resolve([i]), 500))
  }
}

(async function() {
  for await (const n of queryNotion('foo', 'bar')) {
    console.log(n)
  }
})()

If we keep a reference to the generator, we can also put it back into a for async. This might be easier to read, however a for await ...of will call the iterator's return() when exiting the loop early, likely finishing it, in which case, this will not work:

async function* queryNotion(listFn, firstPageArgs) {
  const asyncGenerator = mockIteratePaginatedAPI(listFn, firstPageArgs)
  while (true) {
    try {
      for await (const result of asyncGenerator) {
        yield* result
      }
      break
    } catch (e) {
      console.log('got exception:', e, 'trying again')
    }
  }
}

function* mockIteratePaginatedAPI(a, b) {
  for (let i = 0; i < 8; i++) {
    yield new Promise((resolve, reject) => setTimeout(() => [3, 5].includes(i) ? reject(`le error at ${i}`) : resolve([i]), 500))
  }
}

(async function () {
  for await (const n of queryNotion('foo', 'bar')) {
    console.log(n)
  }
})()
Horrible answered 14/2, 2023 at 23:36 Comment(6)
In your second snippet, would asyncGenerator retry the last iteration when the while(true) loop activates? As far as I understand, next() will always be called on it, and without a "rewind" or something the like, it will never retry the same iteration.Czernowitz
@Czernowitz I think that is the same with both snippets, it solely depends on how the iteratePaginatedAPI is implemented. In the end, that one is doing the iterating.Horrible
@Czernowitz I think that is the same with both snippets, it solely depends on how the iteratePaginatedAPI is implemented. In the end, that one is doing the iterating and resolves or rejects the promises. If it keeps counting up even if a request failed, you might be able to build a new generator that starts at the failed position.Horrible
Right, you are right. iteratePaginatedAPI is detailed here: github.com/makenotion/notion-sdk-js/blob/main/src/…. From what I get, nextCursor is lost in case of an error, so would it mean that when I retry inside while(true) I will start iterating from the start on the Notion results?Czernowitz
@Czernowitz I think it is not just losing nextCursor but the whole iterator is dead when a request fails, as it doesn't yield anything anymore. So while the loop does another round, the generator would just say that it is empty and the function finishes. Looks like you have three options: 1) create your own iteratePaginatedAPI which handles failures 2) Somehow keep track of nextCursor and create a new iterator starting at the last nextCursor on fail 3) Have the listFn() you pass to the generator handle failures. My guess is that the last one is easiest.Horrible
Now it all adds up. Thanks for your patience!Czernowitz
A
0

If we ignore the generators here, below is generic retry routine in TypeScript that can be used anywhere:

// retry-status object:
//  - "index": retry index, starting from 0
//  - "duration": retry overall duration, in ms
//  - "error": last error, if available
type RetryStatus = { index: number, duration: number, error?: any };

// retry-status callback;
type RetryCB<T> = (s: RetryStatus) => T;

type RetryOptions = {
    // maximum number of retries (infinite by default),
    // or a callback to indicate the need for another retry;
    retry?: number | RetryCB<Boolean>,

    // retry delays, in ms, or a callback that returns them;
    delay?: number | RetryCB<number>,

    // error notifications;
    error?: RetryCB<void>
};

// retries async operation returned from "func" callback, according to options;
// note that "func()" will receive "error" = undefined when "index" = 0.
function retryAsync<T>(func: RetryCB<Promise<T>>, options?: RetryOptions) {
    const start = Date.now();
    let index = 0, e: any;
    let {retry = Number.POSITIVE_INFINITY, delay = -1, error} = options ?? {};
    const s = () => ({index, duration: Date.now() - start, error: e});
    const c: () => Promise<T> = () => func(s()).catch(err => {
        e = err;
        typeof error === 'function' && error(s());
        const r = typeof retry === 'function' ? retry(s()) : retry--;
        const d = typeof delay === 'function' ? delay(s()) : delay;
        index++;
        const t = () => r ? c() : Promise.reject(e);
        return d >= 0 ? (new Promise(a => setTimeout(a, d))).then(t) : t();
    });
    return c();
}

You just pass it a function that creates an asynchronous operation, plus some optional parameters, and that's it. It is way more generic and reusable than the solutions proposed so far.

The code above was taken from this gist.

Astrid answered 9/8, 2024 at 21:33 Comment(0)
B
-1

Simply add a continue statement inside your if

async function* queryNotion(listFn, firstPageArgs) {
  try {
    for await (const result of iteratePaginatedAPI(listFn, firstPageArgs)) {
      yield* result
    }
  } catch (error) {
    if (error.code === APIErrorCode.RateLimited) {
      console.log('rate_limited');
      console.log(error);
      await sleep(1);
      continue; // retry the last iteration
    }
  }
}
Box answered 14/2, 2023 at 22:5 Comment(1)
the break statement is outside the for, I don't think this worksHorrible

© 2022 - 2025 — McMap. All rights reserved.