Retry Network Requests

Using "cypress-recurse" and "cy-spok" plugins to retry failing network requests

Imagine you are testing a page. The backend might take a little bit of time to respond. How do you ping the backend to know when it is ready? You want to retry cy.request network calls until it returns a success. But how would you check the response? When Murat Ozcan gets stomped by Cypress tests, he asks me:

Retrying network requests

Let's go.

📺 You can watch this blog post explained in the video Retry Network Requests.

To retry Cypress commands, I wrote cypress-recurse plugin. To validate objects, I wrote cy-spok plugin. This blog post shows how to use them together.

Making a network call from a Cypress test is simple:

1
cy.request('/greeting')

The server might not respond, or respond with an error HTTP code. We expect a possible failure:

1
cy.request({ url: '/greeting', failOnStatusCode: false, })

If the network call fails or the server responds with an error, we want to retry the call after a delay. Here is how we can do it using cypress-recurse

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { recurse } from 'cypress-recurse'

describe('waits for API', () => {
beforeEach(() => {
// this call to the API resets the counter
// the API endpoint /greeting will be available
// in a random period between 1 and 5 seconds
cy.request('POST', '/reset-api')
cy.visit('/')
})

it('checks API using cypress-recurse v1', () => {
recurse(
() => {
return cy.request({
url: '/greeting',
failOnStatusCode: false,
})
},
(res) => {
return (
res.isOkStatusCode &&
res.body === 'Hello!' &&
res.status === 200
)
},
{
timeout: 6000, // check API for up to 6 seconds
delay: 500, // half second pauses between retries
log: false, // do not log details
},
)
// now the API is ready and we can use the GUI
cy.get('#get-api-response').click()
cy.contains('#output', 'Hello!').should('be.visible')
})
})

Passing test

We validate the response using AND predicates:

1
2
3
4
5
6
7
(res) => {
return (
res.isOkStatusCode &&
res.body === 'Hello!' &&
res.status === 200
)
}

Sometimes validation rules are complex. This is where cy-spok shines. Let's validate the entire response object by writing predicates for each property.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import { recurse } from 'cypress-recurse'
import spok from 'cy-spok'

describe('waits for API', () => {
beforeEach(() => {
// this call to the API resets the counter
// the API endpoint /greeting will be available
// in a random period between 1 and 5 seconds
cy.request('POST', '/reset-api')
cy.visit('/')
})

it('checks API using cypress-recurse v2', () => {
recurse(
() => {
return cy.request({
url: '/greeting',
failOnStatusCode: false,
})
},
(res) => {
// use cy-spok to validate the response object
// if cy-spok throws an error,
// return false to retry the recursion
try {
spok({
body: 'Hello!',
status: 200,
isOkStatusCode: true,
})(res) // Run spok on the response
return true // If spok passes, return true
} catch (error) {
return false // If spok fails, continue recursion
}
},
{
timeout: 6000, // check API for up to 6 seconds
delay: 500, // half second pauses between retries
log: false, // do not log details
},
)
// now the API is ready and we can use the GUI
cy.get('#get-api-response').click()
cy.contains('#output', 'Hello!').should('be.visible')
})
})

Passing test showing the failing cy-spok check

The Command Log shows the failing cy-spok predicate body: 'Hello!'. It is nice, but the predicate checking became more verbose, I don't like it

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(res) => {
// use cy-spok to validate the response object
// if cy-spok throws an error,
// return false to retry the recursion
try {
spok({
body: 'Hello!',
status: 200,
isOkStatusCode: true,
})(res) // Run spok on the response
return true // If spok passes, return true
} catch (error) {
return false // If spok fails, continue recursion
}
},

We are running spok(predicates)(res) just to catch an error, then return true/false. Luckily cypress-recurse supports thrown exception directly in its synchronous predicate callback. We could simply write

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
recurse(
() => {
return cy.request({
url: '/greeting',
failOnStatusCode: false,
})
},
(res) => {
spok({
body: 'Hello!',
status: 200,
isOkStatusCode: true,
})(res) // Run spok on the response
},
{
timeout: 6000, // check API for up to 6 seconds
delay: 500, // half second pauses between retries
log: false, // do not log details
},
)

Hmm, we wrote a function just to pass the res argument to spok(...) function. Let's use point-free programming:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { recurse } from 'cypress-recurse'
import spok from 'cy-spok'

describe('waits for API', () => {
beforeEach(() => {
// this call to the API resets the counter
// the API endpoint /greeting will be available
// in a random period between 1 and 5 seconds
cy.request('POST', '/reset-api')
cy.visit('/')
})

it('checks API using cypress-recurse v3', () => {
recurse(
() => {
return cy.request({
url: '/greeting',
failOnStatusCode: false,
})
},
// spok returns a function that will be called with the response
// and will throw if the response does not pass the predicates
spok({
body: 'Hello!',
status: 200,
isOkStatusCode: true,
}),
{
timeout: 6000, // check API for up to 6 seconds
delay: 500, // half second pauses between retries
log: false, // do not log details
},
)
// now the API is ready and we can use the GUI
cy.get('#get-api-response').click()
cy.contains('#output', 'Hello!').should('be.visible')
})
})

Here is the test in action

Pretty slick.