60

I'm writing a Javascript Promise that finds the final redirect URL of a link.

What I'm doing is making a HEAD request in a Promise using an XMLHttpRequest. Then, on load, check the HTTP Status for something in the 300 range, or if it has a responseURL attached to the object and that url is different than the it was one handed.

If neither of these are true, I resolve(url). Otherwise, I recursively call getRedirectUrl() on the response URL, and resolve().

Here's my code:

function getRedirectUrl(url, maxRedirects) {
    maxRedirects = maxRedirects || 0;
    if (maxRedirects > 10) {
        throw new Error("Redirected too many times.");
    }

    var xhr = new XMLHttpRequest();
    var p = new Promise(function (resolve) {
        xhr.onload = function () {
            var redirectsTo;
            if (this.status < 400 && this.status >= 300) {
                redirectsTo = this.getResponseHeader("Location");
            } else if (this.responseURL && this.responseURL != url) {
                redirectsTo = this.responseURL;
            }

            if (redirectsTo) {
                // check that redirect address doesn't redirect again
                // **problem line**
                p.then(function () { self.getRedirectUrl(redirectsTo, maxRedirects + 1); });
                resolve();
            } else {
                resolve(url);
            }
        }

        xhr.open('HEAD', url, true);
        xhr.send();
    });

    return p;
}

Then to use this function I do something like:

getRedirectUrl(myUrl).then(function (url) { ... });

The issue is that resolve(); in getRedirectUrl will call the then() from the calling function before it calls the getRedirectUrl recursive call, and at that point, the URL is undefined.

I tried, rather than p.then(...getRedirectUrl...) doing return self.getRedirectUrl(...) but this will never resolve.

My guess is that the pattern I'm using (that I basically came up with on the fly) isn't right, altogether.

dx_over_dt
  • 13,240
  • 17
  • 54
  • 102
  • 2
    `p.then(...)` with a function that does not produce any observable side effects and `return`s nothing is meaningless. – zerkms Mar 12 '15 at 21:43
  • As a general rule, try to avoid doing so much work in the promise constructor. It's rarely what you want, and the problem becomes clearer with regular chains of `then()` and multiple functions. – Evert Apr 27 '19 at 19:42

7 Answers7

62

The problem is that the promise you return from getRedirectUrl() needs to include the entire chain of logic to get to the URL. You're just returning a promise for the very first request. The .then() you're using in the midst of your function isn't doing anything.

To fix this:

Create a promise that resolves to redirectUrl for a redirect, or null otherwise:

function getRedirectsTo(xhr) {
    if (xhr.status < 400 && xhr.status >= 300) {
        return xhr.getResponseHeader("Location");
    }
    if (xhr.responseURL && xhr.responseURL != url) {
        return xhr.responseURL;
    }

    return null;
}

var p = new Promise(function (resolve) {
    var xhr = new XMLHttpRequest();

    xhr.onload = function () {
        resolve(getRedirectsTo(xhr));
    };

    xhr.open('HEAD', url, true);
    xhr.send();
});

Use .then() on that to return the recursive call, or not, as needed:

return p.then(function (redirectsTo) {
    return redirectsTo
        ? getRedirectUrl(redirectsTo, redirectCount+ 1)
        : url;
});

Full solution:

function getRedirectsTo(xhr) {
    if (xhr.status < 400 && xhr.status >= 300) {
        return xhr.getResponseHeader("Location");
    }
    if (xhr.responseURL && xhr.responseURL != url) {
        return xhr.responseURL;
    }

    return null;
}

function getRedirectUrl(url, redirectCount) {
    redirectCount = redirectCount || 0;

    if (redirectCount > 10) {
        throw new Error("Redirected too many times.");
    }

    return new Promise(function (resolve) {
        var xhr = new XMLHttpRequest();

        xhr.onload = function () {
            resolve(getRedirectsTo(xhr));
        };

        xhr.open('HEAD', url, true);
        xhr.send();
    })
    .then(function (redirectsTo) {
        return redirectsTo
            ? getRedirectUrl(redirectsTo, redirectCount + 1)
            : url;
    });
}
JLRishe
  • 99,490
  • 19
  • 131
  • 169
  • 2
    I'm confused! I don't see how anything happens here. If I call getRedirectUrl() it returns a promise, and that promise has a resolve() function that does some stuff. And when that stuff is done, may recurse to follow a redirect. But! How does the first xhr.open(); xhr.send() ever happen if the only place it occurs is in the Promise resolve() function? I am still new to promises and JavaScript so may be missing something here. – happybeing Jun 06 '16 at 18:59
  • @theWebalyst The function with the `xhr.open()` and `xhr.send()` is called before `getRedirectUrl()` returns. The function passed into the `Promise` constructor is called immediately by the `Promise` constructor. – JLRishe Jun 07 '16 at 20:50
  • 2
    @theWebalyst Subtle detail here is that the `resolve` function is actually an argument to the handler function passed to the `Promise` constructor. The error in your thinking that causes the confusion is *that promise has a resolve() function that does some stuff.*. Actually, the handler function does some stuff and calls the `resolve` function it was passed when it is done. You could add a second argument named `reject` and call that instead of `throw`ing when there are too many redirects. The effect is the same. – Stijn de Witt Sep 14 '17 at 14:35
  • This is pretty smart, tnx much for advice – Jiro Matchonson Jan 18 '20 at 20:18
  • 1
    Totally saved me, 5 years later. Thanks! – James Baker Mar 20 '20 at 21:59
40

Here's the simplified solution:

const recursiveCall = (index) => {
    return new Promise((resolve) => {
        console.log(index);
        return resolve(index)
    }).then(idx => {
        if (idx < 3) {
            return recursiveCall(++idx)
        } else {
            return idx
        }
    })
}

recursiveCall(0).then(() => console.log('done'));
cuddlemeister
  • 1,586
  • 12
  • 15
  • 3
    if index is big enough, you will hit a "Maximum call stack size exceeded" error. – balazs Oct 11 '19 at 08:26
  • 2
    @balazs well, you can change this `return resolve(recursiveCall(++index))` to `return setTimeout(() => resolve(recursiveCall(++index)), 0);` and you are good to go. But this is not related to the question directly and might be confusing in the simplified example – cuddlemeister Oct 12 '19 at 17:38
  • Sure, for the use case of the question it won't hit that limit, but for those who want a more general solution (the title of the question sounds general for me) I think it's good to know the limitations. – balazs Mar 25 '20 at 22:49
  • @balazs totally agree – cuddlemeister Mar 26 '20 at 08:07
  • Fantastic, I am having the same issue in different way and on basis of it, it is working fine. Thank you. – Raghbendra Nayak Jan 12 '23 at 10:48
  • it works but it does not. This piles up nested promises. for me it crashes around 2k recursions. The recursion should be inside the `.then()`. – Kameneth Feb 01 '23 at 18:29
  • @Kameneth thanks, edited according your suggestion – cuddlemeister Feb 02 '23 at 07:35
8

The following has two functions:

  • _getRedirectUrl - which is a setTimeout object simulation for looking up a single step lookup of a redirected URL (this is equivalent to a single instance of your XMLHttpRequest HEAD request)
  • getRedirectUrl - which is recursive calls Promises to lookup the redirect URL

The secret sauce is the sub Promise whose's successful completion will trigger a call to resolve() from the parent promise.

function _getRedirectUrl( url ) {
    return new Promise( function (resolve) {
        const redirectUrl = {
            "https://mary"   : "https://had",
            "https://had"    : "https://a",
            "https://a"      : "https://little",
            "https://little" : "https://lamb",
        }[ url ];
        setTimeout( resolve, 500, redirectUrl || url );
    } );
}

function getRedirectUrl( url ) {
    return new Promise( function (resolve) {
        console.log("* url: ", url );
        _getRedirectUrl( url ).then( function (redirectUrl) {
            // console.log( "* redirectUrl: ", redirectUrl );
            if ( url === redirectUrl ) {
                resolve( url );
                return;
            }
            getRedirectUrl( redirectUrl ).then( resolve );
        } );
    } );
}

function run() {
    let inputUrl = $( "#inputUrl" ).val();
    console.log( "inputUrl: ", inputUrl );
    $( "#inputUrl" ).prop( "disabled", true );
    $( "#runButton" ).prop( "disabled", true );
    $( "#outputLabel" ).text( "" );
    
    getRedirectUrl( inputUrl )
    .then( function ( data ) {
        console.log( "output: ", data);
        $( "#inputUrl" ).prop( "disabled", false );
        $( "#runButton" ).prop( "disabled", false );
        $( "#outputLabel").text( data );
    } );

}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

Input:

<select id="inputUrl">
    <option value="https://mary">https://mary</option>
    <option value="https://had">https://had</option>
    <option value="https://a">https://a</option>
    <option value="https://little">https://little</option>
    <option value="https://lamb">https://lamb</option>
</select>

Output:

<label id="outputLabel"></label>

<button id="runButton" onclick="run()">Run</button>

As another illustration of recursive Promises, I used it to solve a maze. The Solve() function is invoked recursively to advance one step in a solution to a maze, else it backtracks when it encounters a dead end. The setTimeout function is used to set the animation of the solution to 100ms per frame (i.e. 10hz frame rate).

const MazeWidth = 9
const MazeHeight = 9

let Maze = [
    "# #######",
    "#   #   #",
    "# ### # #",
    "# #   # #",
    "# # # ###",
    "#   # # #",
    "# ### # #",
    "#   #   #",
    "####### #"
].map(line => line.split(''));

const Wall = '#'
const Free = ' '
const SomeDude = '*'

const StartingPoint = [1, 0]
const EndingPoint = [7, 8]

function PrintDaMaze()
{
    //Maze.forEach(line => console.log(line.join('')))
    let txt = Maze.reduce((p, c) => p += c.join('') + '\n', '')
    let html = txt.replace(/[*]/g, c => '<font color=red>*</font>')
    $('#mazeOutput').html(html)
}

function Solve(X, Y) {

    return new Promise( function (resolve) {
    
        if ( X < 0 || X >= MazeWidth || Y < 0 || Y >= MazeHeight ) {
            resolve( false );
            return;
        }
        
        if ( Maze[Y][X] !== Free ) {
            resolve( false );
            return;
        }

        setTimeout( function () {
        
            // Make the move (if it's wrong, we will backtrack later)
            Maze[Y][X] = SomeDude;
            PrintDaMaze()

            // Check if we have reached our goal.
            if (X == EndingPoint[0] && Y == EndingPoint[1]) {
                resolve(true);
                return;
            }

            // Recursively search for our goal.
            Solve(X - 1, Y)
            .then( function (solved) {
                if (solved) return Promise.resolve(solved);
                return Solve(X + 1, Y);
            } )
            .then( function (solved) {
                if (solved) return Promise.resolve(solved);
                return Solve(X, Y - 1);
             } )
             .then( function (solved) {
                if (solved) return Promise.resolve(solved);
                return Solve(X, Y + 1);
             } )
             .then( function (solved) {
                 if (solved) {
                     resolve(true);
                     return;
                 }

                 // Backtrack
                 setTimeout( function () {
                     Maze[Y][X] = Free;
                     PrintDaMaze()
                     resolve(false);
                 }, 100);
                 
             } );

        }, 100 );
    } );
}

Solve(StartingPoint[0], StartingPoint[1])
.then( function (solved) {
    if (solved) {
        console.log("Solved!")
        PrintDaMaze()
    }
    else
    {
        console.log("Cannot solve. :-(")
    }
} );
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

<pre id="mazeOutput">
</pre>
Stephen Quan
  • 21,481
  • 4
  • 88
  • 75
5

Please check below example it will return factorial of a given number as we did in many programming languages.

I have implemented below example using JavaScript promises.

let code = (function(){
 let getFactorial = n =>{
  return new Promise((resolve,reject)=>{
   if(n<=1){
    resolve(1);
   }
   resolve(
    getFactorial(n-1).then(fact => {
     return fact * n;
    })
   )
  });
 }
 return {
  factorial: function(number){
   getFactorial(number).then(
    response => console.log(response)
   )
  }
 }
})();
code.factorial(5);
code.factorial(6);
code.factorial(7);
Mayur Shedage
  • 1,027
  • 2
  • 11
  • 19
4

If you're in an environment that supports async/await (virtually all modern environments do), you can write an async function that looks a bit more like a recursive function pattern we all know and love. It's not possible to completely avoid a Promise due to the nature of XMLHttpRequest only retrieving a value via the load event (rather than exposing a Promise itself), but the recursive nature of the function that makes the call should look familiar.

Having four more years of JavaScript experience than I had when I originally wrote this question, I cleaned up the code a bit, but it works essentially the same way.

// creates a simple Promise that resolves the xhr once it has finished loading
function createXHRPromise(url) {
    return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();

        // addEventListener('load', ...) is basically the same as setting
        // xhr.onload, but is better practice
        xhr.addEventListener('load', () => resolve(xhr));

        // throw in some error handling so that the calling function 
        // won't hang
        xhr.addEventListener('error', reject);
        xhr.addEventListener('abort', reject);

        xhr.open('HEAD', url, true);
        xhr.send();
    });
}

async function getRedirectUrl(url, maxRetries = 10) {
    if (maxRetries <= 0) {
        throw new Error('Redirected too many times');
    }

    const xhr = await createXHRPromise(url);
    if (xhr.status >= 300 && xhr.status < 400) {
        return getRedirectUrl(xhr.getResponseHeader("Location"), maxRetries - 1);
    } else if (xhr.responseURL && xhr.responseURL !== url) {
        return getRedirectUrl(xhr.responseURL, maxRetries - 1);
    }

    return url;
}

A brief explanation of async/await

  • an async function is syntactic sugar for a Promise
  • await is syntactic sugar for Promise.then()
  • return within an async function is syntactic sugar for resolve()
  • throw within an async function is syntactic sugar for reject()

If an async function returns either another async function call or a Promise, the function/promise will resolve before the original call resolves, exactly the same way that resolving a Promise would in the Promise pattern.

So, you can call getRedirectUrl(someUrl).then(...).catch(...) exactly the same way the original question would have.

It should probably be noted that using an XHR to resolve a redirected URL will fail for any URL that doesn't include the proper CORS header.


As an added bonus, async/await makes an iterative approach trivial.

async function getRedirectUrl(url, maxRetries = 10) {
    for (let i = 0; i < maxRetries; i++) {
        const xhr = await createXHRPromise(url);
        if (xhr.status >= 300 && xhr.status < 400) {
            url = xhr.getResponseHeader("Location");
        } else if (xhr.responseURL && xhr.responseURL !== url) {
            url = xhr.responseURL;
        } else {
            return url;
        }
    }

    throw new Error('Redirected too many times');
}

Another note: modern browsers have a fetch() function that does essentially what createXHRPromise() does, but is more versatile. It's not supported in node, but there is an npm package called node-fetch.

dx_over_dt
  • 13,240
  • 17
  • 54
  • 102
0

Please check below example for Recursive Promise in javascript/typescript, It will not resolve the promise until number incremented to greater then 13.

below code is suitable for typescript and modify it slightly for javascript.

async iterate(number: number): Promise<any> {
        return new Promise((resolve, reject) => {
            let number = 0;
            if (number > 13) {
                // recursive terminate condition
                resolve(number);
                return;
            } else {
                number = number + 1;
                // recursive call
                this.iterate(number).then(resolve);
            }

        });
    }




this.iterate().then((resolvedData: any) => {
           // wait until number is not greater than 13
           console.log(resolvedData);
    });
Ravindra Vairagi
  • 1,055
  • 15
  • 22
0

If you have a nested array structure with asynchronous calls, this solution (built on previous answers) might help. The example runs a setTimeout() for each value it finds in a (possibly) nested array and resolves when it's done with all of them:

const recursiveCall = (obj) => {
    return new Promise((resolve) => {
        if(obj instanceof Array){
            let cnt = obj.length;
            obj.forEach(el => {
                recursiveCall(el)
                .then(() => {
                    if(!--cnt)return resolve();
                })
                
            });
        } else {
            setTimeout(() => {
                console.log(obj);
                return resolve();
            }, obj);
            
        }
    })
}

recursiveCall([100,50,[10,[200, 300],30],1]).then(() => console.log('done'));

>1
>10
>30
>50
>100
>200
>300
>done
  • Based on your code, I'm guessing you're fairly new to JavaScript? If you're interested, I wrote up some commented examples of the "JavaScript way to write this" using declarative functional programming rather than imperative procedural programming. https://codepen.io/dfoverdx/pen/bGreoXK?editors=1111 – dx_over_dt Oct 19 '21 at 16:00