Skip to content

Retries and Recovery

Retries and Recovery

Stepwright provides multiple levels of retry and recovery to handle flaky tests and transient failures.

Step-Level Retries

Basic Retry Configuration

Configure retries for individual steps:

.step('Click flaky button', async (ctx) => {
await ctx.page.click('#sometimes-hidden-button');
}, {
retry: {
times: 3, // Retry up to 3 times
delay: 1000, // Wait 1 second between retries
},
})

Backoff Strategies

Choose how delay increases between retries:

.step('Linear backoff', async (ctx) => {
await ctx.page.click('button');
}, {
retry: {
times: 3,
delay: 1000,
backoff: 'linear', // 1s, 2s, 3s
},
})

When to Use Step Retries

Step retries are ideal for:

  • Flaky element interactions - Buttons that occasionally miss clicks
  • Network timing issues - Waiting for slow responses
  • Race conditions - Element appears then disappears
// Good use case: flaky button
.step('Submit payment', async (ctx) => {
await ctx.page.click('#pay-button');
}, {
retry: { times: 2, delay: 500 },
})
// Bad use case: stateful operations
.step('Create user', async (ctx) => {
await api.createUser(); // Might create duplicate users on retry!
}, {
retry: { times: 2 }, // Dangerous!
})

Checkpoint-Level Retries

Basic Checkpoint Retry

Retry an entire group of steps:

.checkpoint('Login', { maxRetries: 3 })
.step('Enter email', async (ctx) => {
await ctx.page.fill('#email', ctx.data.email);
})
.step('Enter password', async (ctx) => {
await ctx.page.fill('#password', ctx.data.password);
})
.step('Submit', async (ctx) => {
await ctx.page.click('button[type="submit"]');
await ctx.page.waitForURL('**/dashboard');
})
.endCheckpoint()

If any step in the checkpoint fails, all steps restart from the beginning.

Setup and Teardown

Run code before retries and after completion:

.checkpoint('Checkout', {
maxRetries: 2,
// Runs before each attempt (including retries)
setup: async () => {
console.log('Starting checkout attempt');
},
// Runs after checkpoint (success or final failure)
teardown: async () => {
console.log('Checkout completed');
},
})

When to Use Checkpoint Retries

Checkpoint retries are ideal for:

  • Multi-step flows that must complete together
  • State-dependent sequences where partial completion is invalid
  • Environment flakiness where the whole page might be unstable
// Good: Payment flow must complete atomically
.checkpoint('Payment', { maxRetries: 2 })
.step('Enter card', ...)
.step('Enter CVV', ...)
.step('Submit payment', ...)
.endCheckpoint()
// If CVV entry fails, we restart from entering the card
// This ensures we don't submit partial payment info

Required vs Optional Checkpoints

Required Checkpoints

By default, checkpoints are required. If they fail, the script fails:

.checkpoint('Login', { required: true }) // Default
.step('Authenticate', ...)
.endCheckpoint()
// Script stops if login fails

Optional Checkpoints

Optional checkpoints don’t fail the script:

.checkpoint('Analytics', { required: false })
.step('Send tracking event', async (ctx) => {
// If this fails, script continues
await sendAnalytics(ctx.data);
})
.endCheckpoint()
.checkpoint('Core Flow', { required: true })
.step('Main business logic', ...)
.endCheckpoint()

Error Handling

Step-Level Error Handling

Handle errors within a step:

.step('Risky operation', async (ctx) => {
try {
await ctx.page.click('#optional-element');
} catch (error) {
ctx.log('Optional element not found, continuing...');
}
}, {
// Also fires on unhandled errors
onError: async (error, ctx) => {
ctx.log('Step failed:', error.message);
await ctx.screenshot('error-state');
},
})

Global Configuration

Set default behavior for all steps:

.config({
stopOnFailure: true, // Stop script on first failure
screenshotOnFailure: true, // Capture screenshot on failure
domOnFailure: true, // Capture DOM on failure
})

Recovery Strategies

Skip Failing Steps

Mark steps as non-critical:

.step('Optional enhancement', async (ctx) => {
await ctx.page.click('#premium-feature');
}, {
critical: false, // Script continues if this fails
})

Conditional Execution

Skip steps based on previous results:

.step('Retry alternative', async (ctx) => {
// Check if previous step failed
const lastResult = ctx.results[ctx.results.length - 1];
if (lastResult?.status === 'failed') {
await ctx.page.click('#alternative-button');
}
})

State Snapshots

Save and restore state for recovery:

.step('Save state before risky operation', async (ctx) => {
ctx.snapshot();
})
.step('Risky operation', async (ctx) => {
// If this fails, we can restore
await ctx.page.click('#dangerous-button');
})
.step('Recover if needed', async (ctx) => {
const lastResult = ctx.results[ctx.results.length - 1];
if (lastResult?.status === 'failed') {
ctx.restoreLastSnapshot();
await ctx.page.goto(ctx.data.fallbackUrl);
}
})

Timeout Configuration

Step Timeout

.step('Long operation', async (ctx) => {
await ctx.page.waitForResponse('**/slow-api');
}, {
timeout: 60000, // 60 seconds
})

Default Timeout

.config({
defaultTimeout: 30000, // 30 seconds for all steps
})

Result Inspection

Check retry attempts in results:

const result = await script.run();
for (const step of result.steps) {
if (step.retryAttempts > 0) {
console.log(`${step.name} required ${step.retryAttempts} retries`);
}
}

Best Practices

Start with Checkpoint Retries

Prefer checkpoint retries over step retries for related operations:

// Better: Atomic retry of related steps
.checkpoint('Form Submission', { maxRetries: 2 })
.step('Fill form', ...)
.step('Submit', ...)
.endCheckpoint()
// Worse: Individual retries leave inconsistent state
.step('Fill form', ..., { retry: { times: 2 } })
.step('Submit', ..., { retry: { times: 2 } })

Use Reasonable Limits

// Good: Limited retries with delays
.checkpoint('Login', { maxRetries: 3 })
// Bad: Too many retries, will take forever
.checkpoint('Login', { maxRetries: 10 })

Log Retry Attempts

.step('Retry with logging', async (ctx) => {
ctx.log('Attempting action...');
await ctx.page.click('button');
}, {
retry: {
times: 3,
delay: 1000,
},
beforeRun: (ctx) => {
ctx.log('Starting attempt');
},
onError: (error, ctx) => {
ctx.log('Attempt failed:', error.message);
},
})

Next Steps