A especificação ES2015 (ES6) adicionou o suporte a Promises, recurso poderoso para lidar com o resultado futuro de uma ação. Porém, o ES6 não trouxe nenhum mecanismo que permita cancelá-las automaticamente dentro de uma janela de tempo. Nesse artigo implementaremos o recurso de timeout em Promises.

O problema

Temos um simples HTML que importa o módulo js/app.js utilizando o recurso de importação nativa de módulos disponível no Google Chrome. Se quiser saber mais sobre esse recurso, você pode consultar o artigo “Importação nativa de módulos no browser” deste mesmo autor.

Vamos ao HTML:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width">
        <title>App</title>
    </head>
    <body>
        <script type="module" src="js/app.js"></script>
    </body>
</html>

Vejamos o código do módulo js/app.js:

// js/app.js

import { fetchHandler } from './promise-util.js';
/* 
    Assim que o módulo é carregado a API é acessada
*/
const getNegociacoes = () => 
    fetch('http://localhost:3000/negociacoes/semana')
    .then(fetchHandler);

getNegociacoes()
.then(negotiations => {
    console.log(negotiations);
    alert('Operation complete!');
})
.catch(err => {
    console.log(err);
    alert('Operation failed!');
});  

O módulo app.js importa a função fetchHandler do módulo promise-util.js. Vejamos o conteúdo deste módulo:

// js/promise-util.js

export const fetchHandler = res => {  
    if(!res.ok) throw Error(res.statusText);
    return res.json();
};

Essa função é utilizada para lidar com o código de status da resposta e também para convertê-la no formato JSON.

Excelente! Voltando nosso olhar para app.js, vemos que a função fetchHandler é utilizada pela API fetch que busca as negociações da semana. Importante destacar que a API fetch retorna uma Promise, razão pela qual pudemos encadear chamadas às funções then e catch.

Tudo muito bom, mas o que aconteceria se o usuário estivesse executando a operação em uma rede muita lenta e congestionada, por exemplo, uma rede 3G? Por mais que possamos exibir um ajax status para o usuário alertando-o que a operação ainda esta sendo executada, precisamos de alguma maneira definir um limite máximo de espera e, caso esse limite seja excedido, cancelamos a operação.

O problema é que o ES2015 não suporta Promises com timeout, diferente de bibliotecas como bluebird que aderem à especificação Promise adicionando novos recursos.

A boa notícia é que não há limites para um Cangaceiro JavaScript e neste artigo implementaremos um mecanismo de timeout através de Promise.race. Aliás, este é o momento ideal para conhecermos mais sobre essa função.

Entendendo Promise.race

Temos duas promises criadas através de new Promise():

const promise1 = new Promise((resolve, reject) => 
    setTimeout(() => resolve('promise 1 resolved'), 3000));

const promise2 = new Promise((resolve, reject) => 
    setTimeout(() => resolve('promise 2 resolved'), 1000));

A primeira, só será resolvida depois de três segundos, já a segunda depois de um segundo. Todavia, queremos realizar uma “corrida” (race) entre as promises. Nessa corrida, só queremos o resultado daquela que for resolvida primeiro. É aí que entra Promise.race.

A função Promise.race recebe uma lista de promises e assim que uma delas for resolvida, receberemos imediatamente seu resultado na próxima chamada encadeada à then . As demais promises são ignoradas:

// exemplo isolado
const promise1 = new Promise((resolve, reject) => 
    setTimeout(() => resolve('promise 1 resolvida'), 3000));

const promise2 = new Promise((resolve, reject) => 
    setTimeout(() => resolve('promise 2 resolvida'), 1000));

// vai exbir no console "promise 2 resolvida";
Promise.race([
    promise1, 
    promise2
])
.then(result => console.log(result))
.catch(err => console.log(err));

É importante estar atento que qualquer rejeição que aconteça durante a resolução das Promises direcionará o fluxo da aplicação para dentro a função catch. Em suma, estamos interessados no resultado da primeira promise resolvida, mas se algum erro acontecer antes de qualquer resultado válido, caímos dentro do catch.

Vejam que Promise.race é muito diferente de Promise.all, esta última, retorna dentro de um array o resultado de todas as promises.

Uma das soluções para se implementar o timeout de promises é através do uso da função Promise.race. Veremos a lógica que utilizaremos a seguir.

A lógica de timeout

Para termos uma Promise que suporte timeout, precisamos emparelhá-la com uma Promise que será rejeitada dentro de um tempo estipulável. Isso significa que se a nossa Promise demorar mais do que o tempo definido para a rejeição da outra Promise, automaticamente seremos direcionados para o catch encadeado.

Chegou a hora de implementarmos nossa promiseWithTimeout.

Criando uma Promise com timeout

Vamos alterar o arquivo js/promise-util.js e adicionar a função promiseWithTimeout. Ela recebe como primeiro parâmetro a Promise que desejamos executar e como segundo o timeout em milissegundos que desejamos definir como tempo limite da Promise:

// js/promise-util.js

export const fetchHandler = res => {    
    if(!res.ok) throw Error('Api error!');
    return res.json();
};

export const promiseWithTimeout = (promise, milliseconds) => {
    /* 
        Esta promise fará a rejeição da Promise dentro do tempo definido 
        em milliseconds 
    */
    const timeout =  new Promise((resolve, reject) =>
        setTimeout(() => 
            reject(`Promise timeout reached (limit: ${milliseconds} ms)`), 
                milliseconds));
    /* 
        Faz uma corrida entre a Promise que desejamos resolver com a promise 
        com o timeout programador
    */
    return Promise.race([
        timeout, 
        promise
    ]);
};

Agora, no módulo app.js, importamos a função promiseWithTimeout para que possamos utilizá-la:

// js/app.js

import { fetchHandler, promiseWithTimeout } from './promise-util.js';

const getNegociacoes = () =>  
    fetch ('http://localhost:3000/negociacoes/semana')
    .then(fetchHandler);
/* 
    promiseWithTimeout recebe a promise da Fetch API e
    define em 100ms (bem baixo) o limite máximo
*/
promiseWithTimeout(getNegociacoes(),100)
.then(negotiations => {
    console.log(negotiations);
    alert('Operation complete!');
})
.catch(err => {
    console.log(err);
    alert('Operation failed!');
}); 

Porém, como fazemos o teste? Precisamos simular uma conexão bem lenta! Isso é fácil de ser feito através do Chrome Dev Tools. Com a ferramenta aberta, na aba Network (Rede), há uma caixa de opção com o item online selecionado. Basta mudar para Slow 3G:

Tornando a conexão lenta propositalmente através do Chrome Dev Tools

Essa mudança já é suficiente para vermos nossa promise sendo cancelada através de uma rejeição.

Conclusão

Por mais que Promises não suportem timeout, nada impede que o programador implemente essa funcionalidade utilizando o conhecimento que já possui. Outra alternativa é apelar para bibliotecas como bluebird citadas neste artigo que já trazem a funcionalidade aqui implementada. E você? Já precisou realizar o timeout em um Promise antes? Conte-nos como foi sua experiência.