Search code examples
javascriptxmlhttprequestcypress

cypress xhr request retry ontimeout


I am trying to make the following XHR request retry if it times out. If I remove the cy.wait('@formRequest'), it seems like the test doesn't stop to wait for the response properly. But if I leave the cy.wait in, the test will fail if the request times out. So I tried to add some callbacks (ontimeout) to the cy.request call (shown in the second code block), but that doesn't seem to work either. Any idea how I can make this cypress command retry if the response is not 200 OR if a response is not received at all??

I'm pretty new to this, so some explanation would be greatly appreciated.

Thanks in advance!

// this works, but doesn't handle timeout
Cypress.Commands.add('form_request', (url, formData) => {
  function recursive_request (url, formData, attempt) {
    let xhr
    const maxRetry = 3

    if (attempt > maxRetry) {
      throw new Error(`form_request() max retry reached, ${maxRetry}`)
    }

    if (attempt !== 0) {
      cy.log(`Backing off retry. Attempt: ${attempt}/${maxRetry}.`)
      cy.wait(30000)
    }

    return cy
      .server()
      .route('POST', url)
      .as('formRequest')
      .window()
      .then((win) => {
        xhr = new win.XMLHttpRequest()
        xhr.open('POST', url)
        xhr.send(formData)
      })
      .wait('@formRequest')
      .then((resp) => {
        if (xhr.status !== 200) {
          return recursive_request(url, formData, attempt + 1)
        }

        return resp
      })
  }

  return recursive_request(url, formData, 0)
})
Cypress.Commands.add('form_request', (url, formData) => {
  function recursive_request (url, formData, attempt) {
    let xhr
    const maxRetry = 3

    if (attempt > maxRetry) {
      throw new Error(`form_request() max retry reached, ${maxRetry}`)
    }

    if (attempt !== 0) {
      cy.log(`Backing off retry. Attempt: ${attempt}/${maxRetry}.`)
      cy.wait(30000)
    }

    return cy
      .server()
      // .route('POST', url)
      .route({
        'method': 'POST',
        'url': url
      }, {
        ontimeout: (xhr) => {
            return new Cypress.Promise  ((resolve) => {
              resolve(recursive_request(url, formData, attempt + 1))
            })
            
        },
        onreadystatechange: (xhr) => {
          if (xhr.status !== 200 && xhr.readyState === 4) {
            return new Cypress.Promise  ((resolve) => {
              resolve(recursive_request(url, formData, attempt + 1))
            })
          }
        }
      })
      .as('formRequest')
      .window()
      .then((win) => {
        xhr = new win.XMLHttpRequest()
        xhr.open('POST', url)
        xhr.send(formData)
      })
      .wait('@formRequest')
      .then((resp) => {
        if (xhr.status !== 200) {
          return recursive_request(url, formData, attempt + 1)
        }

        return resp
      })
  }

  return recursive_request(url, formData, 0)
})

edit: I tried to make the following custom cypress command. However, I am getting an illegal invocation error. Looking through the documentation on jQuery.ajax(), it's not clear to me what is illegal about it.

I also have a couple questions... #1. how does passing in the {timeout: 30000} object work? What is this called? My basic understanding of promise chaining is that you pass in some handler function to use whatever the previous call yielded. I have not seen the pattern .then(arg, function () {...}) before, what is this called?

#2. will cy.log(...) work here? I suspect that it won't because that would be mixing sync/async code, correct? (sorry, I would play with it myself right now but I can't get it to run past the illegal invocation error). Would const log = Cypress.log(...) work instead synchronously here?

  527 |       }
  528 | 
> 529 |       Cypress.$.ajax({
      |                 ^
  530 |         url,
  531 |         method: 'POST',
  532 |         data: formData,
Cypress.Commands.add('form_request', (url, formData) => {
  const waits = { request: 2000, retry: 10000 }
  const maxRetry = 3

  function recursive_request(url, formData, attempt = 0) {

    return new Cypress.Promise((resolve) => {
      if (attempt > maxRetry) {
        throw new Error(`form_request() max retry reached, ${maxRetry}`)
      }

      const retry = (reason) => {
        console.log(`${reason} - retrying in ${waits.retry}`) // #2
        return setTimeout(() => {
          recursive_request(url, formData, attempt + 1)
            .then(data => resolve(data)); // resolve from recursive result
        }, waits.retry)
      }

      Cypress.$.ajax({
        url,
        method: 'POST',
        data: formData,
        timeout: waits.request,
      })
        .done(function (data, textStatus, jqXHR) {
          if (textStatus !== 'success') {
            retry('Status not success')
          }
          resolve(data)
        })
        .catch(function (jqXHR, textStatus, errorThrown) {
          retry(errorThrown)
        })
    })
  }

  cy.wrap(null)
    .then({ timeout: 30000 }, // #1
      () => {
        return recursive_request(url, formData)
      })
    .then((result) => {
      return result
    })
})

Solution

  • Test-level retries

    I would take a look at test-retries (Cypress v 5.0+) to handle the timeout issue,

    const waitTime = 1000; // shortened for demo 
    
    Cypress.Commands.add('form_request', (url, formData) => {
    
      return cy
        .request({
          method: 'POST', 
          url,
          body: formData,
          timeout: waitTime
        })
        .then((resp) => {
          if (resp.status !== 200) {
            throw new Error(`Status not 200`)
          }
          return resp
        })
    })
    
    let attempts = 0;
    
    it('repeats', { retries: 3 }, () => {
    
      const wait = attempts ? waitTime : 0;  // initial wait = 0
      const url = attempts < 2
        ? 'http://nothing'                   // initially use a non-reachable url
        : 'http://example.com';              // 3rd attempt succeeds
      attempts++;
      cy.wait(wait).form_request(url, { name: 'Fred' })
    
    })
    

    Request-level retries

    If you want to retry at the request level (not the test level), then @Ackroydd is correct - you should ditch the cy.route() because, in short, Cypress commands are not built to catch fails.

    Here is a basic example using jquery.ajax. You may get a more succinct function using something like axios.retry.

    Refer to Waiting for promises for the Cypress pattern used.

    Recursive request

    const waits = { request: 2000, retry: 10000 } 
    const maxRetry = 3
    
    function recursive_request(url, formData, attempt = 0) {
    
      return new Cypress.Promise ((resolve) => {
        if (attempt > maxRetry) {
          throw new Error(`form_request() max retry reached, ${maxRetry}`)
        }
      
        const retry = (reason) => {
          console.log(`${reason} - retrying in ${waits.retry}`);
          return setTimeout(() => {
            recursive_request(url, formData, attempt + 1)
              .then(data => resolve(data)); // resolve from recursive result
          }, waits.retry)
        }
        
        Cypress.$.ajax({
          url,
          method: 'POST',
          data: formData,
          timeout: waits.request,
        })
        .done(function(data, textStatus, jqXHR) {
          if (textStatus !== 'success') {
            retry('Status not success')
          }
          resolve(data)
        })
        .catch(function(jqXHR, textStatus, errorThrown) {
          retry(errorThrown)
        })
      })
    }
    

    Test

    it('retries form post', () => {
    
      cy.wrap(null)
        .then(
          { timeout: 30000 },   // timeout must be above maximum expected, 
                                // i.e maxRetry * waits.retry
          () => recursive_request('http://localhost:3000', { name: 'Fred' })
        )
        .then(result => console.log(result))
    
    })
    

    Flaky server used to test

    const express = require("express");
    const app = express();
    const port = 3000;
    
    const obj = {
      name: 'Fred'
    };
    
    let attempt = 0;
    
    app.post("*", (req, res) => {
      if (attempt >= 3) {
        attempt = 0;   // reset to allow multiple test runs
        res.status(201).json(obj)
      }
    });
    
    app.listen(port, () => {
      console.log(`Example app listening at http://localhost:${port}`);
    });