Skip to content
GitHub Actions

Retry failed tests on GitHub Actions

GitHub Actions re-runs jobs. nick-fields/retry re-runs steps. Neither one re-runs a test, because GitHub Actions doesn't know what a test is. If one test out of four thousand fails on a race condition, both of them run all four thousand again. The retry that actually targets the failing test lives inside the job, in your test framework.

By Julien Danjou, Co-founder & CEO of MergifyUpdated

In one paragraph

"Re-run failed jobs" and nick-fields/retry both repeat the whole test command, which costs a full suite every time one test flakes. Retry the test instead, with your framework's own mechanism (pytest-rerunfailures, Playwright retries, Vitest retry, jest.retryTimes, rspec-retry), and only on the tests you already know are flaky. Then keep a record of what got retried. A retry that turns the job green also hides the flake, unless something remembers it happened.

What "Re-run failed jobs" does

It is GitHub's only built-in retry, and it is manual. From the run page, or from the terminal:

# Re-run only the failed jobs of a run, on the same commit
gh run rerun <run-id> --failed

The re-run uses the same commit (GITHUB_SHA does not change), is allowed up to 30 days after the original run, and a run can be re-run at most 50 times. The unit is the job: every step of every failed job runs again from checkout, including every test that passed the first time.

Nothing in GitHub Actions retries a job on its own. Teams that want that write a second workflow on workflow_run that calls the re-run API when the first one fails, or use a tool that does it for them. It works, and it is the most expensive way to get past one flaky test. We wrote about why that button is not flaky test management in a separate post.

What nick-fields/retry does

nick-fields/retry replaces a run step with one that runs again on failure or timeout. It is the most popular way to retry a step on GitHub Actions:

name: Tests

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt
      - name: Run tests, retry the step
        uses: nick-fields/retry@v4
        with:
          timeout_minutes: 10
          max_attempts: 3
          command: pytest

Here is what it printed in our sandbox, where one test of four fails about 40% of the time (trimmed to the lines that matter):

Attempt 1
FAILED tests/test_calc.py::test_flaky - assert 0.16118732441770278 > 0.4
==================== 1 failed, 3 passed, 1 warning in 0.59s ====================
Warning: Attempt 1 failed. Reason: Child_process exited with error code 1
Attempt 2
========================= 4 passed, 1 warning in 1.90s =========================
Command completed after 2 attempt(s).

Attempt 2 ran all four tests again to get one of them to pass. On a four-test suite that is half a second. On a real suite it is the whole suite, every time any test flakes, and the chance that something else flakes on the second attempt grows with the size of the suite.

The action is good at what it was built for: commands that fail for reasons outside your code, like a package registry timing out or a service container that is slow to start. retry_on: timeout and retry_on_exit_code let you narrow it to those cases. Wrapped around a test runner, it is "Re-run failed jobs" with fewer clicks.

Retry the test, inside the job

Your test framework is the only thing in the pipeline that knows which test failed, so it is the only place a retry can target one test. In pytest, mark the test you know is flaky:

import random

import pytest


@pytest.mark.flaky(reruns=2)
def test_flaky():
    assert random.random() > 0.4

and install the plugin in the workflow. Nothing else changes:

name: Tests

on:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
      - run: pip install -r requirements.txt pytest-rerunfailures
      - name: Run tests
        run: pytest

pytest prints an R for each rerun. The other three tests ran once:

tests/test_calc.py ...R.                                                 [100%]
========================== 4 passed, 1 rerun in 0.38s ==========================

Every major framework has the same lever. Scope it to the tests you know are flaky rather than the whole suite: a blanket retry gives a real regression two more chances to pass on luck.

FrameworkRetry one test
pytestpytest-rerunfailures: @pytest.mark.flaky(reruns=2) on one test, or --reruns 2 for all
Playwrightretries in playwright.config, or test.describe.configure({ retries: 2 }) for one group
Vitesttest.retry in vitest.config, or { retry: 2 } on one test
Jestjest.retryTimes(2) at the top of a file (jest-circus)
RSpecrspec-retry: retry: 2 metadata on one example

What a retry hides

A retry that works is invisible. The job is green, the pull request merges, and the only trace that a test failed is an R in a log nobody opens. Six months later the marker list has thirty entries, nobody remembers why half of them are there, and the suite's real failure rate is a mystery.

"Known flaky" also needs someone to know. On GitHub Actions that knowledge lives in whoever last clicked "Re-run failed jobs" on that test, because Actions keeps logs per run and has no notion of a test across runs. A test report action makes each run's failures readable, and still forgets them when the run ends. The retry decision ends up being made from memory, which is how a test goes from flaking 5% of the time to 40% without anyone noticing.

Retry with a memory: Mergify Test Engine

Test Engine is the test layer GitHub Actions is missing. Its plugins run inside your test framework, so they work at the level a retry should: the test.

name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
      # requirements.txt lists pytest and pytest-mergify
      - run: pip install -r requirements.txt
      - name: Run tests
        env:
          MERGIFY_TOKEN: ${{ secrets.MERGIFY_TOKEN }}
        run: pytest
  • Every attempt is recorded. Your framework's own retries count. In our sandbox, the runs where pytest-rerunfailures rescued the test were green on GitHub, and Test Engine recorded each of them with one failed test. The list of known flaky tests stops depending on anyone's memory.
  • Pull requests: quarantine instead of retry. A test Test Engine knows is flaky can be quarantined: it still runs and reports, but its failure no longer fails the job. With a detection budget set, a test the pull request adds is re-run to check it is stable before it merges.
  • Default branch: test retry. On pushes to main, a failed test is run again inside the job, up to the retry count you set for the repository and within a time budget for the run. Broken tests and quarantined ones are left alone.

pytest, Vitest, Playwright, Jest and RSpec have native plugins (pytest-mergify above); other runners upload JUnit XML with the Mergify CI action. The retry count and budgets are set per repository in the Test Engine settings of the dashboard.

If a retry is the right call for a whole job (a runner that died, an image pull that timed out), that is a CI problem rather than a test problem, and CI auto-retry covers it. GitHub Actions still sees jobs, not tests. Test Engine is the test layer, and the job layer is next.

FAQ

Can GitHub Actions retry a failed job automatically?

Not natively. GitHub Actions lets you re-run a whole workflow or its failed jobs by hand, from the UI or with gh run rerun --failed, up to 30 days after the run and at most 50 times. Anything automatic is an action or a workflow you add yourself.

How do I retry only the failed tests on GitHub Actions?

Inside the test step, with your test framework's own retry: pytest-rerunfailures, Playwright's retries, Vitest's retry, jest.retryTimes, rspec-retry. GitHub Actions re-runs jobs and nick-fields/retry re-runs a step, and both run the whole suite again. Only the framework knows which test failed.

What does nick-fields/retry do?

It wraps a shell command and runs it again when it fails or times out, up to max_attempts, with optional wait between attempts and a retry_on_exit_code filter. It is good for commands that fail for outside reasons, like a package install or a flaky network call. For tests it reruns the full command, so a 20-minute suite costs 40 minutes when one test flakes.

Should I add --reruns to my whole test suite?

Usually not. A blanket retry also gives real failures extra chances to pass on luck, and it hides new flakiness as it appears. Retry the specific tests you already know are flaky, and keep a record of which tests needed a retry so the list does not grow silently.

Does a retried test still count as flaky in Mergify Test Engine?

Yes. With a native Mergify plugin, a test's record counts every attempt, the framework's own retries included, and a test that both failed and passed is flaky. The retry turns the job green; the history still says the test is unreliable.

Does Mergify retry failed tests on pull requests?

No. Test Engine's test retry runs on pushes to the default branch, up to the retry count you set for the repository. On pull requests it protects you another way: known flaky tests can be quarantined so their failures do not fail the job, and, with a detection budget set, new tests are re-run to catch flakiness before it merges.

Last updated:

Retry the test, keep the record

Test Engine runs inside your test framework on GitHub Actions: every attempt recorded, flaky tests quarantined on pull requests, failures retried on main. Free for up to 5 users.