[READ-ONLY] Mirror of https://github.com/vitest-dev/vitest. Next generation testing framework powered by Vite. vitest.dev
test testing-tools vite
12

Configure Feed

Select the types of activity you want to include in your feed.

feat(runner): enhance `retry` options (#9370)

Co-authored-by: Matan Shavit <71092861+matanshavit@users.noreply.github.com>
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
Co-authored-by: Vladimir <sleuths.slews0s@icloud.com>

authored by

MazenSamehR
Matan Shavit
Ari Perkkiö
Vladimir
and committed by
GitHub
(Jan 12, 2026, 7:42 PM +0100) 9e4cfd29 f5e68663

+483 -22
+137 -4
docs/config/retry.md
··· 5 5 6 6 # retry 7 7 8 - - **Type:** `number` 9 - - **Default:** `0` 10 - - **CLI:** `--retry=<value>` 11 - 12 8 Retry the test specific number of times if it fails. 9 + 10 + - **Type:** `number | { count?: number, delay?: number, condition?: RegExp }` 11 + - **Default:** `0` 12 + - **CLI:** `--retry <times>`, `--retry.count <times>`, `--retry.delay <ms>`, `--retry.condition <pattern>` 13 + 14 + ## Basic Usage 15 + 16 + Specify a number to retry failed tests: 17 + 18 + ```ts 19 + export default defineConfig({ 20 + test: { 21 + retry: 3, 22 + }, 23 + }) 24 + ``` 25 + 26 + ## CLI Usage 27 + 28 + You can also configure retry options from the command line: 29 + 30 + ```bash 31 + # Simple retry count 32 + vitest --retry 3 33 + 34 + # Advanced options using dot notation 35 + vitest --retry.count 3 --retry.delay 500 --retry.condition 'ECONNREFUSED|timeout' 36 + ``` 37 + 38 + ## Advanced Options <Version>4.1.0</Version> {#advanced-options} 39 + 40 + Use an object to configure retry behavior: 41 + 42 + ```ts 43 + export default defineConfig({ 44 + test: { 45 + retry: { 46 + count: 3, // Number of times to retry 47 + delay: 1000, // Delay in milliseconds between retries 48 + condition: /ECONNREFUSED|timeout/i, // RegExp to match errors that should trigger retry 49 + }, 50 + }, 51 + }) 52 + ``` 53 + 54 + ### count 55 + 56 + Number of times to retry a test if it fails. Default is `0`. 57 + 58 + ```ts 59 + export default defineConfig({ 60 + test: { 61 + retry: { 62 + count: 2, 63 + }, 64 + }, 65 + }) 66 + ``` 67 + 68 + ### delay 69 + 70 + Delay in milliseconds between retry attempts. Useful for tests that interact with rate-limited APIs or need time to recover. Default is `0`. 71 + 72 + ```ts 73 + export default defineConfig({ 74 + test: { 75 + retry: { 76 + count: 3, 77 + delay: 500, // Wait 500ms between retries 78 + }, 79 + }, 80 + }) 81 + ``` 82 + 83 + ### condition 84 + 85 + A RegExp pattern or a function to determine if a test should be retried based on the error. 86 + 87 + - When a **RegExp**, it's tested against the error message 88 + - When a **function**, it receives the error and returns a boolean 89 + 90 + ::: warning 91 + When defining `condition` as a function, it must be done in a test file directly, not in a configuration file (configurations are serialized for worker threads). 92 + ::: 93 + 94 + #### RegExp condition (in config file): 95 + 96 + ```ts 97 + export default defineConfig({ 98 + test: { 99 + retry: { 100 + count: 2, 101 + condition: /ECONNREFUSED|ETIMEDOUT/i, // Retry on connection/timeout errors 102 + }, 103 + }, 104 + }) 105 + ``` 106 + 107 + #### Function condition (in test file): 108 + 109 + ```ts 110 + import { describe, test } from 'vitest' 111 + 112 + describe('tests with advanced retry condition', () => { 113 + test('with function condition', { retry: { count: 2, condition: error => error.message.includes('Network') } }, () => { 114 + // test code 115 + }) 116 + }) 117 + ``` 118 + 119 + ## Test File Override 120 + 121 + You can also define retry options per test or suite in test files: 122 + 123 + ```ts 124 + import { describe, test } from 'vitest' 125 + 126 + describe('flaky tests', { 127 + retry: { 128 + count: 2, 129 + delay: 100, 130 + }, 131 + }, () => { 132 + test('network request', () => { 133 + // test code 134 + }) 135 + }) 136 + 137 + test('another test', { 138 + retry: { 139 + count: 3, 140 + condition: error => error.message.includes('timeout'), 141 + }, 142 + }, () => { 143 + // test code 144 + }) 145 + ```
+75 -3
packages/runner/src/run.ts
··· 1 - import type { Awaitable } from '@vitest/utils' 1 + import type { Awaitable, TestError } from '@vitest/utils' 2 2 import type { DiffOptions } from '@vitest/utils/diff' 3 3 import type { FileSpecification, VitestRunner } from './types/runner' 4 4 import type { ··· 33 33 const now = globalThis.performance ? globalThis.performance.now.bind(globalThis.performance) : Date.now 34 34 const unixNow = Date.now 35 35 const { clearTimeout, setTimeout } = getSafeTimers() 36 + 37 + /** 38 + * Normalizes retry configuration to extract individual values. 39 + * Handles both number and object forms. 40 + */ 41 + function getRetryCount(retry: number | { count?: number } | undefined): number { 42 + if (retry === undefined) { 43 + return 0 44 + } 45 + if (typeof retry === 'number') { 46 + return retry 47 + } 48 + return retry.count ?? 0 49 + } 50 + 51 + function getRetryDelay(retry: number | { delay?: number } | undefined): number { 52 + if (retry === undefined) { 53 + return 0 54 + } 55 + if (typeof retry === 'number') { 56 + return 0 57 + } 58 + return retry.delay ?? 0 59 + } 60 + 61 + function getRetryCondition( 62 + retry: number | { condition?: RegExp | ((error: TestError) => boolean) } | undefined, 63 + ): RegExp | ((error: TestError) => boolean) | undefined { 64 + if (retry === undefined) { 65 + return undefined 66 + } 67 + if (typeof retry === 'number') { 68 + return undefined 69 + } 70 + return retry.condition 71 + } 36 72 37 73 function updateSuiteHookState( 38 74 task: Task, ··· 266 302 } 267 303 } 268 304 305 + /** 306 + * Determines if a test should be retried based on its retryCondition configuration 307 + */ 308 + function passesRetryCondition(test: Test, errors: TestError[] | undefined): boolean { 309 + const condition = getRetryCondition(test.retry) 310 + 311 + if (!errors || errors.length === 0) { 312 + return false 313 + } 314 + 315 + if (!condition) { 316 + return true 317 + } 318 + 319 + const error = errors[errors.length - 1] 320 + 321 + if (condition instanceof RegExp) { 322 + return condition.test(error.message || '') 323 + } 324 + else if (typeof condition === 'function') { 325 + return condition(error) 326 + } 327 + 328 + return false 329 + } 330 + 269 331 export async function runTest(test: Test, runner: VitestRunner): Promise<void> { 270 332 await runner.onBeforeRunTask?.(test) 271 333 ··· 300 362 301 363 const repeats = test.repeats ?? 0 302 364 for (let repeatCount = 0; repeatCount <= repeats; repeatCount++) { 303 - const retry = test.retry ?? 0 365 + const retry = getRetryCount(test.retry) 304 366 for (let retryCount = 0; retryCount <= retry; retryCount++) { 305 367 let beforeEachCleanups: unknown[] = [] 306 368 try { ··· 412 474 } 413 475 414 476 if (retryCount < retry) { 415 - // reset state when retry test 477 + const shouldRetry = passesRetryCondition(test, test.result.errors) 478 + 479 + if (!shouldRetry) { 480 + break 481 + } 482 + 416 483 test.result.state = 'run' 417 484 test.result.retryCount = (test.result.retryCount ?? 0) + 1 485 + 486 + const delay = getRetryDelay(test.retry) 487 + if (delay > 0) { 488 + await new Promise(resolve => setTimeout(resolve, delay)) 489 + } 418 490 } 419 491 420 492 // update retry info
+2
packages/runner/src/types.ts
··· 20 20 InferFixturesTypes, 21 21 OnTestFailedHandler, 22 22 OnTestFinishedHandler, 23 + Retry, 23 24 RunMode, 24 25 RuntimeContext, 25 26 SequenceHooks, 26 27 SequenceSetupFiles, 28 + SerializableRetry, 27 29 Suite, 28 30 SuiteAPI, 29 31 SuiteCollector,
+8
test/cli/test/fails.test.ts
··· 161 161 ] 162 162 `) 163 163 }) 164 + 165 + it('should warn if retry.condition is a function in config', async () => { 166 + const { stderr } = await runVitest({ 167 + root: 'fixtures/retry-config', 168 + }) 169 + 170 + expect(stderr).toContain('Warning: retry.condition function cannot be used inside a config file.') 171 + })
+71
test/core/test/retry-condition.test.ts
··· 1 + import type { TestError } from 'vitest' 2 + import { describe, expect, it } from 'vitest' 3 + 4 + // Test with RegExp condition that eventually passes 5 + let matchingCount = 0 6 + it('retry with matching condition', { 7 + retry: { 8 + count: 5, 9 + condition: /retry/i, 10 + }, 11 + }, () => { 12 + matchingCount += 1 13 + if (matchingCount < 3) { 14 + throw new Error('Please retry this test') 15 + } 16 + // Third attempt succeeds 17 + }) 18 + 19 + it('verify matching condition retried', () => { 20 + expect(matchingCount).toBe(3) 21 + }) 22 + 23 + // Test with no condition (should retry all errors) 24 + let noConditionCount = 0 25 + it('retry without condition', { retry: 2 }, () => { 26 + noConditionCount += 1 27 + expect(noConditionCount).toBe(3) 28 + }) 29 + 30 + it('verify no condition retried all attempts', () => { 31 + expect(noConditionCount).toBe(3) 32 + }) 33 + 34 + // Test with function condition 35 + let functionCount = 0 36 + const condition = (error: TestError) => error.name === 'TimeoutError' 37 + 38 + it('retry with function condition', { 39 + retry: { 40 + count: 5, 41 + condition, 42 + }, 43 + }, () => { 44 + functionCount += 1 45 + const err: any = new Error('Test failed') 46 + err.name = 'TimeoutError' 47 + if (functionCount < 3) { 48 + throw err 49 + } 50 + // Third attempt succeeds 51 + }) 52 + 53 + it('verify function condition worked', () => { 54 + expect(functionCount).toBe(3) 55 + }) 56 + 57 + describe('retry condition with describe', { 58 + retry: { 59 + count: 2, 60 + condition: /flaky/i, 61 + }, 62 + }, () => { 63 + let describeCount = 0 64 + it('test should inherit retryCondition from describe block', () => { 65 + describeCount += 1 66 + if (describeCount === 1) { 67 + throw new Error('Flaky test error') 68 + } 69 + // Second attempt succeeds 70 + }) 71 + })
+53
test/core/test/retry-delay.test.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + 3 + let delayCount = 0 4 + let delayStart = 0 5 + 6 + it('retry with delay', { 7 + retry: { 8 + count: 2, 9 + delay: 100, 10 + }, 11 + }, () => { 12 + if (delayCount === 0) { 13 + delayStart = Date.now() 14 + } 15 + delayCount += 1 16 + expect(delayCount).toBe(3) 17 + }) 18 + 19 + it('verify delay was applied', () => { 20 + const duration = Date.now() - delayStart 21 + expect(delayCount).toBe(3) 22 + // With 2 retries and 100ms delay each, should take at least 200ms 23 + expect(duration).toBeGreaterThanOrEqual(200) 24 + }) 25 + 26 + let zeroDelayCount = 0 27 + 28 + it('retry with zero delay', { 29 + retry: { 30 + count: 2, 31 + delay: 0, 32 + }, 33 + }, () => { 34 + zeroDelayCount += 1 35 + expect(zeroDelayCount).toBe(3) 36 + }) 37 + 38 + it('verify zero delay test passed', () => { 39 + expect(zeroDelayCount).toBe(3) 40 + }) 41 + 42 + describe('retry delay with describe', { 43 + retry: { 44 + count: 2, 45 + delay: 50, 46 + }, 47 + }, () => { 48 + let describeCount = 0 49 + it('test should inherit retryDelay from describe block', () => { 50 + describeCount += 1 51 + expect(describeCount).toBe(2) 52 + }) 53 + })
+2 -1
packages/runner/src/types/runner.ts
··· 4 4 ImportDuration, 5 5 SequenceHooks, 6 6 SequenceSetupFiles, 7 + SerializableRetry, 7 8 Suite, 8 9 TaskEventPack, 9 10 TaskResultPack, ··· 36 37 maxConcurrency: number 37 38 testTimeout: number 38 39 hookTimeout: number 39 - retry: number 40 + retry: SerializableRetry 40 41 includeTaskLocation?: boolean 41 42 diffOptions?: DiffOptions 42 43 }
+60 -6
packages/runner/src/types/tasks.ts
··· 87 87 */ 88 88 result?: TaskResult 89 89 /** 90 - * The amount of times the task should be retried if it fails. 90 + * Retry configuration for the task. 91 + * - If a number, specifies how many times to retry 92 + * - If an object, allows fine-grained retry control 91 93 * @default 0 92 94 */ 93 - retry?: number 95 + retry?: Retry 94 96 /** 95 97 * The amount of times the task should be repeated after the successful run. 96 98 * If the task fails, it will not be retried unless `retry` is specified. ··· 461 463 462 464 type TestCollectorOptions = Omit<TestOptions, 'shuffle'> 463 465 466 + /** 467 + * Retry configuration for tests. 468 + * Can be a number for simple retry count, or an object for advanced retry control. 469 + */ 470 + export type Retry = number | { 471 + /** 472 + * The number of times to retry the test if it fails. 473 + * @default 0 474 + */ 475 + count?: number 476 + /** 477 + * Delay in milliseconds between retry attempts. 478 + * @default 0 479 + */ 480 + delay?: number 481 + /** 482 + * Condition to determine if a test should be retried based on the error. 483 + * - If a RegExp, it is tested against the error message 484 + * - If a function, called with the TestError object; return true to retry 485 + * 486 + * NOTE: Functions can only be used in test files, not in vitest.config.ts, 487 + * because the configuration is serialized when passed to worker threads. 488 + * 489 + * @default undefined (retry on all errors) 490 + */ 491 + condition?: RegExp | ((error: TestError) => boolean) 492 + } 493 + 494 + /** 495 + * Serializable retry configuration (used in config files). 496 + * Functions cannot be serialized, so only string conditions are allowed. 497 + */ 498 + export type SerializableRetry = number | { 499 + /** 500 + * The number of times to retry the test if it fails. 501 + * @default 0 502 + */ 503 + count?: number 504 + /** 505 + * Delay in milliseconds between retry attempts. 506 + * @default 0 507 + */ 508 + delay?: number 509 + /** 510 + * Condition to determine if a test should be retried based on the error. 511 + * Must be a RegExp tested against the error message. 512 + * 513 + * @default undefined (retry on all errors) 514 + */ 515 + condition?: RegExp 516 + } 517 + 464 518 export interface TestOptions { 465 519 /** 466 520 * Test timeout. 467 521 */ 468 522 timeout?: number 469 523 /** 470 - * Times to retry the test if fails. Useful for making flaky tests more stable. 471 - * When retries is up, the last test error will be thrown. 472 - * 524 + * Retry configuration for the test. 525 + * - If a number, specifies how many times to retry 526 + * - If an object, allows fine-grained retry control 473 527 * @default 0 474 528 */ 475 - retry?: number 529 + retry?: Retry 476 530 /** 477 531 * How many times the test will run again. 478 532 * Only inner tests will repeat if set on `describe()`, nested `describe()` will inherit parent's repeat by default.
+2 -2
packages/vitest/src/runtime/config.ts
··· 1 1 import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' 2 2 import type { PrettyFormatOptions } from '@vitest/pretty-format' 3 - import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' 3 + import type { SequenceHooks, SequenceSetupFiles, SerializableRetry } from '@vitest/runner' 4 4 import type { SnapshotEnvironment, SnapshotUpdateState } from '@vitest/snapshot' 5 5 import type { SerializedDiffOptions } from '@vitest/utils/diff' 6 6 ··· 77 77 truncateThreshold?: number 78 78 } | undefined 79 79 diff: string | SerializedDiffOptions | undefined 80 - retry: number 80 + retry: SerializableRetry 81 81 includeTaskLocation: boolean | undefined 82 82 inspect: boolean | string | undefined 83 83 inspectBrk: boolean | string | undefined
+11
test/cli/fixtures/retry-config/vitest.config.ts
··· 1 + import { defineConfig } from 'vitest/config' 2 + 3 + export default defineConfig({ 4 + test: { 5 + retry: { 6 + count: 3, 7 + // @ts-expect-error 8 + condition: () => true 9 + } 10 + } 11 + })
+23
packages/vitest/src/node/cli/cli-config.ts
··· 530 530 description: 531 531 'Retry the test specific number of times if it fails (default: `0`)', 532 532 argument: '<times>', 533 + subcommands: { 534 + count: { 535 + description: 536 + 'Number of times to retry a test if it fails (default: `0`)', 537 + argument: '<times>', 538 + }, 539 + delay: { 540 + description: 541 + 'Delay in milliseconds between retry attempts (default: `0`)', 542 + argument: '<ms>', 543 + }, 544 + condition: { 545 + description: 546 + 'Regex pattern to match error messages that should trigger a retry. Only errors matching this pattern will cause a retry (default: retry on all errors)', 547 + argument: '<pattern>', 548 + transform: (value) => { 549 + if (typeof value === 'string') { 550 + return new RegExp(value, 'i') 551 + } 552 + return value 553 + }, 554 + }, 555 + }, 533 556 }, 534 557 diff: { 535 558 description:
+12
packages/vitest/src/node/config/resolveConfig.ts
··· 142 142 mode, 143 143 } as any as ResolvedConfig 144 144 145 + if (resolved.retry && typeof resolved.retry === 'object' && typeof resolved.retry.condition === 'function') { 146 + logger.console.warn( 147 + c.yellow('Warning: retry.condition function cannot be used inside a config file. ' 148 + + 'Use a RegExp pattern instead, or define the function in your test file.'), 149 + ) 150 + 151 + resolved.retry = { 152 + ...resolved.retry, 153 + condition: undefined, 154 + } 155 + } 156 + 145 157 if (options.pool && typeof options.pool !== 'string') { 146 158 resolved.pool = options.pool.name 147 159 resolved.poolRunner = options.pool
+3 -2
packages/vitest/src/node/reporters/reported-tasks.ts
··· 4 4 Test as RunnerTestCase, 5 5 File as RunnerTestFile, 6 6 Suite as RunnerTestSuite, 7 + SerializableRetry, 7 8 TaskMeta, 8 9 TestAnnotation, 9 10 TestArtifact, ··· 524 525 readonly fails: boolean | undefined 525 526 readonly concurrent: boolean | undefined 526 527 readonly shuffle: boolean | undefined 527 - readonly retry: number | undefined 528 + readonly retry: SerializableRetry | undefined 528 529 readonly repeats: number | undefined 529 530 readonly mode: 'run' | 'only' | 'skip' | 'todo' 530 531 } ··· 537 538 fails: task.type === 'test' && task.fails, 538 539 concurrent: task.concurrent, 539 540 shuffle: task.shuffle, 540 - retry: task.retry, 541 + retry: task.retry as SerializableRetry | undefined, 541 542 repeats: task.repeats, 542 543 // runner types are too broad, but the public API should be more strict 543 544 // the queued state exists only on Files and this method is called
+10 -4
packages/vitest/src/node/types/config.ts
··· 1 1 import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' 2 2 import type { PrettyFormatOptions } from '@vitest/pretty-format' 3 - import type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' 3 + import type { SequenceHooks, SequenceSetupFiles, SerializableRetry } from '@vitest/runner' 4 4 import type { SnapshotStateOptions } from '@vitest/snapshot' 5 5 import type { Arrayable } from '@vitest/utils' 6 6 import type { SerializedDiffOptions } from '@vitest/utils/diff' ··· 783 783 bail?: number 784 784 785 785 /** 786 - * Retry the test specific number of times if it fails. 786 + * Retry configuration for tests. 787 + * - If a number, specifies how many times to retry failed tests 788 + * - If an object, allows fine-grained retry control 787 789 * 788 - * @default 0 790 + * ⚠️ WARNING: Function form is NOT supported in a config file 791 + * because configurations are serialized when passed to worker threads. 792 + * Use the function form only in test files directly. 793 + * 794 + * @default 0 // Don't retry 789 795 */ 790 - retry?: number 796 + retry?: SerializableRetry 791 797 792 798 /** 793 799 * Show full diff when snapshot fails instead of a patch.
+14
packages/vitest/src/runtime/runners/index.ts
··· 103 103 state.durations.prepare = 0 104 104 state.durations.environment = 0 105 105 }) 106 + 107 + // Strip function conditions from retry config before sending via RPC 108 + // Functions cannot be cloned by structured clone algorithm 109 + const sanitizeRetryConditions = (task: any) => { 110 + if (task.retry && typeof task.retry === 'object' && typeof task.retry.condition === 'function') { 111 + // Remove function condition - it can't be serialized 112 + task.retry = { ...task.retry, condition: undefined } 113 + } 114 + if (task.tasks) { 115 + task.tasks.forEach(sanitizeRetryConditions) 116 + } 117 + } 118 + files.forEach(sanitizeRetryConditions) 119 + 106 120 rpc().onCollected(files) 107 121 await originalOnCollected?.call(testRunner, files) 108 122 }