Automatic retry for failed Behave scenarios — tag overrides, exception filtering, flakiness stats
Project description
behave-retry
Automatic retry for failed Behave scenarios — real re-execution, tag overrides, exception filtering, and flakiness stats.
Why?
Behave has no built-in retry. When a scenario fails due to flakiness (timing, network, race conditions), there's no way to re-run it automatically. Cucumber has --retry natively. Behave doesn't.
behave-retry fills that gap by patching Behave's Scenario.run to re-execute failed scenarios automatically — with tag overrides, exception filtering, and flakiness stats.
Table of contents
Install
pip install behave-retry
For development:
pip install -e ".[dev]"
Quick start
# environment.py
from behave_retry import setup_retry, after_scenario_hook, retry_report
def before_all(context):
setup_retry(context, max_retries=3)
def after_scenario(context, scenario):
after_scenario_hook(context, scenario)
def after_all(context):
print(retry_report(context))
That's it. Failed scenarios will now be re-executed up to 3 times automatically.
Features
Global retry
setup_retry(context, max_retries=3)
Retry every failed scenario up to 3 times.
Tag-filtered retry
setup_retry(context, max_retries=3, retry_tags=["@flaky"])
Only retry scenarios tagged with @flaky.
@flaky
Scenario: Login with slow network
Given the server is slow
...
Exception-filtered retry
setup_retry(
context,
max_retries=3,
retry_on=[AssertionError, TimeoutError],
)
Only retry when the scenario fails with specific exception types. Subclasses of the listed exceptions also match.
Per-scenario override
Override the global retry count per scenario using the @retry:N tag:
@retry:5
Scenario: Very flaky test
...
@retry:0
Scenario: Never retry this
...
The first @retry:N tag wins. @retry:0 disables retry for that scenario.
You can also set @retry:N at the Feature level — scenarios without their own @retry:N tag inherit it from the Feature:
@retry:3
Feature: Flaky scenarios
Scenario: A # inherits retry:3
...
@retry:0
Scenario: B # override local, no retry
...
@retry:5
Scenario: C # override local, retry 5
...
Scenario-level tags always take precedence over Feature-level tags.
Global retry budget
Limit the total number of retries across all scenarios:
setup_retry(context, max_retries=5, max_total_retries=20)
Once the global budget is exhausted, no more retries are attempted — remaining scenarios run only once. None (default) means unlimited.
Logging
behave-retry logs via the standard logging module under the behave_retry logger:
- INFO on
setup_retry— configuration summary - INFO on each retry —
Retrying "scenario name" (attempt 1/3) after AssertionError - INFO on
retry_report— full summary
To enable logging, configure the logger in your environment.py:
import logging
def before_all(context):
logging.basicConfig(level=logging.INFO)
setup_retry(context, max_retries=3)
Or use a dedicated handler:
import logging
def before_all(context):
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[behave-retry] %(message)s"))
logging.getLogger("behave_retry").addHandler(handler)
logging.getLogger("behave_retry").setLevel(logging.INFO)
setup_retry(context, max_retries=3)
Retry stats
from behave_retry import retry_report
def after_all(context):
report = retry_report(context)
print(report)
Output:
Retry Summary:
Total retries: 5
Scenarios retried: 3
Passed on retry: 2
Failed after retry: 1
- "Login with invalid credentials" — 3 attempts, failed (AssertionError)
- "Search products" — 2 attempts, passed
- "Checkout flow" — 2 attempts, passed
Retry delay and backoff
Add a configurable delay between retries, with optional exponential backoff:
setup_retry(
context,
max_retries=3,
retry_delay=2.0,
backoff_factor=2.0,
)
With retry_delay=2.0 and backoff_factor=2.0, delays between retries are 2s, 4s, 8s. With backoff_factor=1.0 (default), the delay is fixed at retry_delay seconds.
On-retry callback
Run custom logic before each retry — clean up state, take screenshots, log, etc.:
def on_retry(context, scenario, attempt, exception):
print(f"Retrying {scenario.name} (attempt {attempt}): {exception}")
setup_retry(
context,
max_retries=3,
on_retry=on_retry,
)
The callback receives (context, scenario, attempt, exception) where attempt is the 1-based number of the failed attempt and exception is the exception that caused the failure (or None).
API reference
setup_retry(context, max_retries=0, retry_tags=None, retry_on=None, retry_delay=0.0, backoff_factor=1.0, on_retry=None, max_total_retries=None)
Configure retry on the behave context. Call this in before_all. This patches behave.model.Scenario.run to re-execute failed scenarios automatically.
| Parameter | Type | Default | Description |
|---|---|---|---|
context |
Any |
— | Behave context object |
max_retries |
int |
0 |
Maximum retries per scenario (0 = no retry) |
retry_tags |
list[str] | None |
None |
Only retry scenarios with these tags |
retry_on |
list[type[Exception] | str] | None |
None |
Only retry on these exception types or names (strings like "AssertionError" or "mymod.MyError") |
retry_delay |
float |
0.0 |
Seconds to wait before each retry (0 = no delay) |
backoff_factor |
float |
1.0 |
Multiplier applied to retry_delay after each retry (must be >= 1.0) |
on_retry |
Callable | None |
None |
Callback invoked before each retry with (context, scenario, attempt, exception) |
max_total_retries |
int | None |
None |
Global budget for total retries across all scenarios (None = unlimited) |
after_scenario_hook(context, scenario)
Track retry attempts. Call this in after_scenario. The actual retry loop is handled automatically by the Scenario.run patch installed by setup_retry.
retry_report(context) -> str
Get a human-readable retry summary. Call this in after_all.
RetryConfig
Frozen dataclass for configuration.
| Attribute | Type | Default | Description |
|---|---|---|---|
max_retries |
int |
0 |
Maximum retries per scenario (must be >= 0) |
retry_tags |
list[str] |
[] |
Tag filter (empty = retry all) |
retry_on |
list[type[Exception] | str] |
[] |
Exception filter — classes or string names (empty = retry on any) |
retry_delay |
float |
0.0 |
Seconds to wait before each retry (must be >= 0) |
backoff_factor |
float |
1.0 |
Multiplier applied to retry_delay (must be >= 1.0) |
on_retry |
Callable[[Any, Any, int, Exception | None], None] | None |
None |
Callback invoked before each retry |
max_total_retries |
int | None |
None |
Global budget for total retries (None = unlimited) |
Methods:
should_retry_tag(tags) -> bool— Check if scenario tags allow retryshould_retry_exception(exc) -> bool— Check if exception type allows retryget_scenario_retries(tags, feature_tags=None) -> int— Get max retries, checking@retry:Non scenario then feature tagsget_retry_delay(attempt) -> float— Calculate delay for a given retry attempt (1-based)
RetryCallback
Type alias for the on_retry callback: Callable[[Any, Any, int, Exception | None], None].
ExceptionFilter
Type alias for a single retry_on entry: type[Exception] | str. Accepts exception classes or string names (e.g. "AssertionError", "mymod.MyError").
RetryStats
Aggregate retry statistics.
| Property | Type | Description |
|---|---|---|
total_retries |
int |
Total retry attempts across all scenarios |
scenarios_retried |
list[ScenarioRetry] |
Per-scenario retry records |
scenarios_passed_on_retry |
int |
Scenarios that passed after retry |
scenarios_failed_after_retry |
int |
Scenarios that still failed after retry |
Methods:
add_retry(scenario, attempts, final_status, exceptions) -> None— Record a new retryupdate_retry(scenario, attempts, final_status, exceptions) -> None— Update existing or create newsummary() -> str— Human-readable summaryto_dict() -> dict— Serialize to a dictionary for CI/CD reporting
ScenarioRetry
Record of retry attempts for a single scenario.
| Attribute | Type | Description |
|---|---|---|
scenario |
str |
Scenario name |
attempts |
int |
Total attempts (including first run) |
final_status |
str |
"passed" or "failed" |
exceptions |
list[str] |
Exception types encountered |
| Property | Description |
|---|---|
was_retried |
True if retried at least once |
passed_on_retry |
True if passed after retry |
Methods:
to_dict() -> dict— Serialize to a dictionary for CI/CD reporting
parse_retry_tag(tags) -> int | None
Parse @retry:N tag from scenario tags. Returns N if found, None otherwise. Also available as behave_retry.parse_retry_tag.
Configuration
Python version
behave-retry requires Python 3.11+.
Dependencies
Zero required dependencies. behave is only needed as a dev dependency for running tests.
Linting and formatting
The project uses Ruff with the following rule sets: E, F, W, I, N, UP, B, SIM.
ruff check .
Testing
pytest tests/ -v --cov
Coverage threshold is 90%.
Examples
Full environment.py
from behave_retry import setup_retry, after_scenario_hook, retry_report
def before_all(context):
setup_retry(
context,
max_retries=3,
retry_tags=["@flaky"],
retry_on=[AssertionError, TimeoutError],
retry_delay=2.0,
backoff_factor=2.0,
on_retry=lambda ctx, sc, att, exc: print(f"Retry {sc.name} #{att}: {exc}"),
)
def after_scenario(context, scenario):
after_scenario_hook(context, scenario)
def after_all(context):
print(retry_report(context))
Feature file with retry tags
@flaky
@retry:5
Feature: Flaky scenarios
@retry:0
Scenario: Never retry this
Given a stable condition
Then it should pass
Scenario: Retry up to 5 times
Given a flaky condition
Then it might fail
Contributing
Contributions are welcome! See CONTRIBUTING.md for guidelines.
License
MIT — Copyright (c) 2026 Mathias Paulenko
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file behave_retry-1.8.1.tar.gz.
File metadata
- Download URL: behave_retry-1.8.1.tar.gz
- Upload date:
- Size: 36.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fed18645fa5384f2e3eb3b02033f1debc990ea4bb74827496d786c6caa4988ae
|
|
| MD5 |
f01c7d23f60aa0a16d562954c56e756d
|
|
| BLAKE2b-256 |
271bb8e02831da10569624ef3d518879c453713521728c8d9686b0d09e3a7533
|
Provenance
The following attestation bundles were made for behave_retry-1.8.1.tar.gz:
Publisher:
release.yml on MathiasPaulenko/behave-retry
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
behave_retry-1.8.1.tar.gz -
Subject digest:
fed18645fa5384f2e3eb3b02033f1debc990ea4bb74827496d786c6caa4988ae - Sigstore transparency entry: 2163737813
- Sigstore integration time:
-
Permalink:
MathiasPaulenko/behave-retry@a186ed6806181603b74492dfcc66fa5b8fcf7db6 -
Branch / Tag:
refs/tags/v1.8.1 - Owner: https://github.com/MathiasPaulenko
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a186ed6806181603b74492dfcc66fa5b8fcf7db6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file behave_retry-1.8.1-py3-none-any.whl.
File metadata
- Download URL: behave_retry-1.8.1-py3-none-any.whl
- Upload date:
- Size: 15.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e60985260a1e3b1fc9be8c535f1c675aca06a79bdfbc83b008cbe28dfec8951e
|
|
| MD5 |
9f73c46e65afc3ec1dcbc52c01faa33c
|
|
| BLAKE2b-256 |
42897942452135997d16bd8b20ffaf1e7fa8a650139d14e061efdd9585b0994d
|
Provenance
The following attestation bundles were made for behave_retry-1.8.1-py3-none-any.whl:
Publisher:
release.yml on MathiasPaulenko/behave-retry
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
behave_retry-1.8.1-py3-none-any.whl -
Subject digest:
e60985260a1e3b1fc9be8c535f1c675aca06a79bdfbc83b008cbe28dfec8951e - Sigstore transparency entry: 2163737818
- Sigstore integration time:
-
Permalink:
MathiasPaulenko/behave-retry@a186ed6806181603b74492dfcc66fa5b8fcf7db6 -
Branch / Tag:
refs/tags/v1.8.1 - Owner: https://github.com/MathiasPaulenko
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a186ed6806181603b74492dfcc66fa5b8fcf7db6 -
Trigger Event:
push
-
Statement type: