Reputation: 309
I'm curious on how API retry and timeouts should be implemented. Sometimes simply awaiting an api call then catching any error that come up isn't sufficient. If there's a chain of async requests I need to make, like so:
await client
.callA()
.then(async () => await callB())
.then(async () => await callC())
.catch(err => console.error(err));
and if one of the promise fails midchain, I want to attempt to the request again after a few seconds until attempts run out.
This is my attempt at making a retry wrapper.
async function retry (fn, undo, attempts, wait = 5000) {
await fn().catch(async (err) => {
console.error(err.message + `\n retrying in ${wait/1000} seconds...`);
if (attempts !== 0) {
// async timeout
await new Promise((resolve) => {
setTimeout(() => resolve(retry(fn, undo, attempts - 1)), wait);
})
} else {
await undo()
}
})
}
await retry(calls, undoCalls, 10)
callA -> callB -> callC
Say callA()
succeeds, but callB()
fails, I want the wrapper to retry callB()
at an interval instead of starting over again.
Then either:
callB()
eventually succeeds within the allowed attempts, move onto callC()
.callB()
runs out of attempts, call undoCallA()
to revert the changes previously made.Repeat the above until end of chain.
I'd like some insight on how this is implemented or if there's a library that does something similar. Thanks!
Upvotes: 2
Views: 2412
Reputation: 1597
Here's a Live Demo that uses a custom Promise:
import { CPromise } from "c-promise2";
const callA = async () => console.log(`Call A`);
const callB = async () => {
console.log(`Call B`);
throw Error("ooops");
};
const callC = async () => console.log(`Call C`);
const undoA = async () => console.log(`Undo A`);
const undoB = async () => console.log(`Undo B`);
const undoC = async () => console.log(`Undo C`);
const promise = CPromise.retry(() => callA())
.finally((v, isRejected) => isRejected && undoA())
.then(() =>
CPromise.retry(() => callB()).finally(
(v, isRejected) => isRejected && undoB()
)
)
.then(() =>
CPromise.retry(() => callC()).finally(
(v, isRejected) => isRejected && undoC()
)
)
.then(
(v) => console.log(`Done: ${v}`),
(err) => console.error(`Fail: ${err}`)
);
// promise.pause()
// promise.resume()
// promise.cancel()
Console log:
Call A
Call B
Call B
Call B
Undo B
Error: ooops
One more example with retrying the axios request::
import { CPromise } from "c-promise2";
import cpAxios from "cp-axios";
CPromise.retry(
(attempt) => {
console.log(`Attempt [${attempt}]`);
return cpAxios(url).timeout(attempt * 1000 + 500);
},
{ retries: 3, delay: (attempt) => attempt * 1000 }
).then(
(response) => console.log(`Response: ${JSON.stringify(response.data)}`),
(err) => console.warn(`Fail: ${err}`)
);
Upvotes: 0
Reputation: 135396
Functions should be simple and do just one thing. I would start with a generic sleep
-
const sleep = ms =>
new Promise(r => setTimeout(r, ms))
Using simple functions we can build more sophisticated ones, like timeout
-
const timeout = (p, ms) =>
Promise.race([ p, sleep(ms).then(_ => { throw Error("timeout") }) ])
Now let's say we have a task, myTask
that takes up to 4 seconds to run. It returns successfully if it generates an odd number. Otherwise it rejects, "X is not odd" -
async function myTask () {
await sleep(Math.random() * 4000)
const x = Math.floor(Math.random() * 100)
if (x % 2 == 0) throw Error(`${x} is not odd`)
return x
}
Now let's say we want to run myTask
with a timeout
of two (2) seconds and retry
a maximum of three (3) times -
retry(_ => timeout(myTask(), 2000), 3)
.then(console.log, console.error)
Error: 48 is not odd (retry 1/3)
Error: timeout (retry 2/3)
79
It's possible myTask
could produce an odd number on the first attempt. Or it's possible that it could exhaust all attempts before emitting a final error -
Error: timeout (retry 1/3)
Error: timeout (retry 2/3)
Error: 34 is not odd (retry 3/3)
Error: timeout
Error: failed after 3 retries
Now we implement retry
. We can use a simple for
loop -
async function retry (f, count = 5, ms = 1000) {
for (let attempt = 1; attempt <= count; attempt++) {
try {
return await f()
}
catch (err) {
if (attempt <= count) {
console.error(err.message, `(retry ${attempt}/${count})`)
await sleep(ms)
}
else {
console.error(err.message)
}
}
}
throw Error(`failed after ${count} retries`)
}
Now that we see how retry
works, let's write a more complex example that retries multiple tasks -
async function pick3 () {
const a = await retry(_ => timeout(myTask(), 3000))
console.log("first pick:", a)
const b = await retry(_ => timeout(myTask(), 3000))
console.log("second pick:", b)
const c = await retry(_ => timeout(myTask(), 3000))
console.log("third pick:", c)
return [a, b, c]
}
pick3()
.then(JSON.stringify)
.then(console.log, console.error)
Error: timeout (retry 1/5)
Error: timeout (retry 2/5)
first pick: 37
Error: 16 is not odd (retry 1/5)
second pick: 13
Error: 60 is not odd (retry 1/5)
Error: timeout (retry 2/5)
third pick: 15
[37,13,15]
Expand the snippet below to verify the result in your browser -
const sleep = ms =>
new Promise(r => setTimeout(r, ms))
const timeout = (p, ms) =>
Promise.race([ p, sleep(ms).then(_ => { throw Error("timeout") }) ])
async function retry (f, count = 5, ms = 1000) {
for (let attempt = 0; attempt <= count; attempt++) {
try {
return await f()
}
catch (err) {
if (attempt < count) {
console.error(err.message, `(retry ${attempt + 1}/${count})`)
await sleep(ms)
}
else {
console.error(err.message)
}
}
}
throw Error(`failed after ${count} retries`)
}
async function myTask () {
await sleep(Math.random() * 4000)
const x = Math.floor(Math.random() * 100)
if (x % 2 == 0) throw Error(`${x} is not odd`)
return x
}
async function pick3 () {
const a = await retry(_ => timeout(myTask(), 3000))
console.log("first", a)
const b = await retry(_ => timeout(myTask(), 3000))
console.log("second", b)
const c = await retry(_ => timeout(myTask(), 3000))
console.log("third", c)
return [a, b, c]
}
pick3()
.then(JSON.stringify)
.then(console.log, console.error)
And because timeout
is decoupled from retry
, we can achieve different program semantics. By contrast, the following example not timeout individual tasks but will retry if myTask
returns an even number -
async function pick3 () {
const a = await retry(myTask)
const b = await retry(myTask)
const c = await retry(myTask)
return [a, b, c]
}
And we could now say timeout
pick3
if it takes longer than ten (10) seconds, and retry
the entire pick if it does -
retry(_ => timeout(pick3(), 10000))
.then(JSON.stringify)
.then(console.log, console.error)
This ability to combine simple functions in a variety of ways is what makes them more powerful than one big complex function that tries to do everything on its own.
Of course this means we can apply retry
directly to the example code in your question -
async function main () {
await retry(callA, ...)
await retry(callB, ...)
await retry(callC, ...)
return "done"
}
main().then(console.log, console.error)
You can either apply timeout
to the individual calls -
async function main () {
await retry(_ => timeout(callA(), 3000), ...)
await retry(_ => timeout(callB(), 3000), ...)
await retry(_ => timeout(callC(), 3000), ...)
return "done"
}
main().then(console.log, console.error)
Or apply timeout
to each retry
-
async function main () {
await timeout(retry(callA, ...), 10000)
await timeout(retry(callB, ...), 10000)
await timeout(retry(callC, ...), 10000)
return "done"
}
main().then(console.log, console.error)
Or maybe apply timeout
to the entire process -
async function main () {
await retry(callA, ...)
await retry(callB, ...)
await retry(callC, ...)
return "done"
}
timeout(main(), 30000).then(console.log, console.error)
Or any other combination that matches your actual intention!
Upvotes: 9
Reputation: 136164
I just wouldn't be trying to chain these together. It gets too complex too quickly, instead have a simpler retry
logic, and use try...catch
to determine if something went wrong and call the appropriate undo
method. Kind of in this pattern
await retry(callA, 10, 5000)
try{
await retry(callB, 10, 5000);
}
catch {
await undoCallA()
}
The retry
method for this pattern can be much simpler, just retrying for the number of attempts allowed and rejecting if that number is exhausted. This will then raise an error back to the caller allowing it to be catch
ed.
A simple example:
const delay = (ms) => new Promise(resolve => setTimeout(resolve,ms));
async function retry(fn, attempts, wait = 1000) {
let count=0;
while(count++<attempts) {
try{
const result = await fn();
return result;
}
catch(e){
if(count == attempts){
console.log("throw");
throw e;
}
await delay(wait);
console.log("retry");
}
}
}
function callA(){
return new Promise( (resolve,reject) => {
if(Math.random() < 0.6) reject("random fail A");
else resolve("CallA")
})
}
async function undoCallA(){ console.log("Undo A") }
function callB(){
return new Promise( (resolve,reject) => {
if(Math.random() < 0.99) reject("random fail B");
else resolve("CallB")
})
}
(async function(){
console.log(await retry(callA,10));
try{
console.log(await retry(callB,10))
}
catch(e) {
console.log(e);
await undoCallA();
}
})()
Upvotes: 0