nick-fields/retry: retrying a flaky step inside a GitHub Actions workflow
Retries a GitHub Action step on failure or timeout
At a glance
- What is it?
- retry is a GitHub Action that runs a command with a timeout and repeats it on failure or timeout, exposing attempt counts, the final exit code and the final error as step outputs so a workflow can assert on the result. It suits a pipeline with a step that fails intermittently, and it does not fix the underlying flake.
- Who is it for?
- retry fits a workflow whose flakiness is external to the step itself, such as a network-dependent install or a test against a shared service, since the action enforces a per-attempt timeout, can rerun only on timeout or only on error, can match a specific exit code and hands the final exit code and attempt count back as outputs.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 130 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 24, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What the action actually replaces
retry is described in one line as retrying an Action step on failure or timeout, and the readme adds a caveat that shapes the whole design: this is currently intended to replace the run step for moody commands.
Moody is the operative word. The action exists for the step that sometimes works and sometimes does not, where rerunning it is a reasonable response and where the underlying cause is not something the pipeline controls. A step that fails every time will fail every time here too, just more slowly.
The mechanism is a wrapper rather than a plugin. You give it a command, a timeout and a number of attempts, and it runs the command, watches for it to fail or exceed the timeout, waits, and runs it again. That means it works with any command, including multi-line scripts, and it works in a composite action as well as in a workflow, which matters because the continue-on-error semantics differ between the two.
The other half of the design is that it reports back. There are three outputs: total_attempts for the final number of attempts made, exit_code for the final exit code returned by the command, and exit_error for the final error. Those outputs are the reason this is more than a sleep-and-retry loop, because a downstream step can assert on the exit code and react to a partial failure.
The action is written in TypeScript, built with ncc into a dist/ directory that ships with the action, and it requires NodeJS to run. The readme says it runs without issue on all GitHub hosted runners and that if you hit problems on self-hosted runners you should ensure NodeJS is installed.
Timeouts, attempts and what counts as a failure
The inputs section is where the behaviour is defined, and a few of them interact in ways worth understanding.
timeout_minutes and timeout_seconds are both listed as required, with the constraint that you must specify either minutes or seconds. The distinction is that the timeout applies per attempt, not to the whole retry sequence, so with max_attempts of 3 and a fifteen second timeout the step can occupy up to forty-five seconds plus the waits between attempts.
max_attempts is the number of attempts before the step fails. It is the hard stop.
retry_on filters what triggers a retry. The readme says it supports any as the default, timeout, and error. So the default retries on both a command that exits non-zero and a command that hangs past its timeout, and the two narrower settings let you retry only on one of them. For a slow network call that sometimes times out but never exits badly, timeout is the setting that avoids pointless re-runs of a command that is going to fail fast.
retry_on_exit_code narrows it further. Set to a specific exit code, the action retries only for that code and fails immediately on any other exit code. That is the closest the action comes to a policy of retrying only known-transient failures, and it is the input to reach for when a command has a distinct exit code for a transient condition.
retry_wait_seconds is the pause between attempts and defaults to 10. polling_interval_seconds is how often the action checks the command's status while waiting for a result, and it defaults to 1. That second one matters for a fast command: a one second polling interval adds a second of latency to every attempt that finishes in under a second, which is why the timeout-in-seconds examples in the readme exist for typically fast scripts.
The shell input selects the interpreter, defaulting to powershell on Windows and bash otherwise, and the readme lists bash, python, pwsh, sh, cmd and powershell as supported, matching the GitHub Actions documentation on step shells.
Cleanup, replacement commands and the retry_on_error path
Three inputs deal with what happens between attempts, and they are the most useful part of the API for real pipelines.
on_retry_command runs a command before each retry, which the readme describes as a cleanup script. The readme also states that any error thrown from the retry command is caught and surfaced as a warning. That is a deliberate design choice rather than an omission: a failing cleanup step should not mask the original failure, so the error is reported and the retry proceeds.
new_command_on_retry replaces the command after the first attempt fails. The readme is precise about the semantics: this command is called on all subsequent attempts, not just the second one. The documented use case is running the same test suite with a narrower filter after a full run has already told you which tests failed, first with npx jest and then with npx jest --onlyFailures. That is a genuinely useful pattern, and it is the sort of thing a plain retry loop cannot do.
continue_on_error controls whether the step exits successfully even when an error occurs, and it defaults to false. The readme describes it as the same as the native continue-on-error behaviour, but for use in composite actions. The distinction it points at is the real one: in a composite action, a step that fails can abort the whole action, so the native workflow-level continue-on-error is not enough, and this input gives you the equivalent behaviour at the action level.
warning_on_retry controls whether a retry produces a warning or just informational output, and defaults to true. That is the right default for a CI log, where a retry is worth noticing.
The remaining input is nothing exotic: id is not an input here, but in the workflow examples the step is given an id so its outputs can be referenced.
Working with the outputs
The readme's examples show the intended way to use this action, and the two composite-action and workflow-action examples are deliberately paired.
In the composite action case, the step uses continue_on_error from the action's own input, and a later step asserts on the step outcome and the exit code:
- uses: nick-fields/retry@v3
id: retry
with:
timeout_seconds: 15
max_attempts: 3
continue_on_error: true
command: node -e 'process.exit(99);'
- name: Assert that step succeeded (despite failing command)
uses: nick-fields/assert-action@v1
with:
expected: success
actual: ${{ steps.retry.outcome }}
- name: Assert that action exited with expected exit code
uses: nick-fields/assert-action@v1
with:
expected: 99
actual: ${{ steps.retry.outputs.exit_code }}The workflow case does the same thing with the native continue-on-error on the step, and additionally asserts the attempt count:
- name: Assert that action made expected number of attempts
uses: nick-fields/assert-action@v1
with:
expected: 3
actual: ${{ steps.retry.outputs.total_attempts }}That third assertion is the one that catches a misconfiguration. A step that fails immediately with an exit code the action does not consider retryable, or a retry_on filter set to the wrong value, will produce fewer attempts than expected, and total_attempts makes that visible rather than leaving you to guess from the log.
The action used in the assertions is a separate one, nick-fields/assert-action, which is a separate repository and not part of this project, so it is worth knowing that the examples depend on it being available.
The simplest usage is much shorter, and the readme gives it as a plain step:
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
command: npm run some-typically-slow-scriptThe multi-line examples show that the command input accepts a script block, so the action is usable for a cleanup-and-run sequence as well as for a single command.
Building the action and what is in the repository
The repository layout describes the standard GitHub Actions JavaScript action structure, and a few details are worth noting for anyone forking it.
There is an action.yml at the root, which is the action manifest that declares the inputs and outputs to the runner. The TypeScript source is in src/, and the build output is in dist/, produced by ncc. The dist/ directory is committed, which is the convention for JavaScript actions because the runner does not install dependencies at run time; it executes the bundled file directly.
The build script confirms this: prepare runs ncc build src/index.ts and then husky install, so a checkout plus an npm install is enough to produce a working bundle. The prepare hook is also what makes the repository usable locally, since the local script runs npm run prepare before node -r dotenv/config ./dist/index.js, with dotenv picking up sample.env.
The runtime dependencies are three and they describe the implementation. @actions/core is the toolkit for reading inputs, writing outputs and setting outputs as workflow commands. tree-kill is there to kill a process tree, which is what makes the timeout work: killing only the shell process would leave its children running. milliseconds converts the timeout values into durations, and the presence of a dedicated package for it suggests the timeout is expressed in milliseconds internally.
Testing is jest with ts-jest, configured through ./.config/jest.config.js, and there is a test-data/ directory. Linting and formatting are eslint and prettier with the configuration in .config/, and there is a lint-staged configuration plus husky for commit hooks.
Releases are handled by semantic-release, configured through .releaserc.js, which is why the version field in package.json is the placeholder 0.0.0-managed-by-semantic-release. The commits follow conventional commits with a commitlint configuration, so the release notes are generated from the commit history.
There is also an .nvmrc, which pins the Node version the project builds against.
Where retry is the wrong tool
The first limitation is the obvious one and it is not the action's fault: retrying a command does not repair the reason it failed. If a test fails because a change broke it, the action will burn the full attempt budget and the pipeline will fail with the same error. The readme's own framing, that it is intended for moody commands, is the correct filter, and treating the action as a way to make a red pipeline green is the most likely misuse.
The second is that retries run against the same machine. There is no fresh runner, no clean workspace and no reset of the environment between attempts. A command that half-writes a file, leaves a port bound or corrupts a cache will hit the same problem on the second attempt, and in some cases a second attempt makes the state worse than the first. The on_retry_command input exists precisely to let you clean up between attempts, and treating it as optional is how people get flaky-in-a-new-way pipelines.
The third is the timeout granularity. polling_interval_seconds defaults to 1, so a command that would finish in milliseconds still costs a second per attempt. For a suite of fast steps that adds up, and it is the reason the readme documents a timeout in seconds with the note that it is for typically fast scripts.
The fourth is the version mismatch in the documentation. Every example in the readme uses nick-fields/retry@v3, while the newest published release is v4.0.0 from 2026-03-20, following v3.0.2 from 2025-02-25. The readme's changelog pointer is a link to CHANGELOG.md, and the documented inputs may not match v4 exactly. Anyone copying the examples verbatim should check what changed in the major version before trusting the input list.
The fifth is the dependency on a Node runtime on self-hosted runners, which the readme calls out explicitly, and the fact that this action is scoped to GitHub Actions. If your CI is GitLab CI, CircleCI or a self-run runner, none of this applies. There is also a licence inconsistency to check: the licence field on the repository records MIT, while the author field in the package manifest says ISC.
Editorial conclusion
retry fits a workflow whose flakiness is external to the step itself, such as a network-dependent install or a test against a shared service, since the action enforces a per-attempt timeout, can rerun only on timeout or only on error, can match a specific exit code and hands the final exit code and attempt count back as outputs. It is the wrong choice for a step that fails deterministically, and it is the wrong choice when the failure leaves the runner in a bad state, because a retried command runs against the same machine. Before adopting it, check the examples in the readme against v4, since the documented snippets still reference v3 while the newest release is v4.0.0 from 2026-03-20, and note that the last push was on 2026-06-16 and the package manifest declares ISC while the repository licence field records MIT.
Frequently asked questions
What does the nick-fields/retry GitHub Action do?
It runs a command with a per-attempt timeout and repeats it on failure or timeout. You give it timeout_minutes or timeout_seconds, max_attempts and the command, and it outputs total_attempts, exit_code and exit_error for downstream steps to assert on.
How do I only retry a step when it times out?
Set retry_on to timeout. The input accepts any as the default, plus timeout and error, so the narrow setting means the action retries only a command that exceeded its timeout and fails immediately on an ordinary error exit.
Can retry run a different command after the first failure?
Yes, with new_command_on_retry. The readme states this command is called on all subsequent attempts, not only the second, and gives the example of running npx jest first and npx jest --onlyFailures afterwards.
Does retry work in a composite action?
Yes. The continue_on_error input is described as the same as the native continue-on-error behaviour but for use in composite actions, where a failing step would otherwise abort the whole action, and it defaults to false.
What are the outputs of the retry action?
Three: total_attempts for the final number of attempts made, exit_code for the final exit code returned by the command, and exit_error for the final error. The readme's examples assert on them with a separate nick-fields/assert-action step.
What licence is the retry action released under?
The licence field on the repository records MIT, while the author field in the package manifest declares ISC, so the two disagree. The last push was on 2026-06-16 and the newest release is v4.0.0 from 2026-03-20, while the documented examples still reference v3.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/nick-fields-retry)Was this useful?