How it works

This page explains the internal architecture of behave-retry so you can understand exactly what happens when you call setup_retry.

Architecture overview

behave-retry works by monkey-patching behave.model.Scenario.run with a retry-aware wrapper. This is the same approach used by behave’s own plugin system and is the most reliable way to intercept scenario execution.

┌──────────────────────────────────────────────────┐
│                  behave runner                    │
│                                                   │
│  for each scenario:                               │
│    scenario.run(runner)  ← patched by behave-retry│
│      ┌─────────────────────────────────────────┐  │
│      │         patched_run wrapper             │  │
│      │                                          │  │
│      │  1. Load config & stats from context    │  │
│      │  2. Check retry eligibility (tags)      │  │
│      │  3. Call original_run(self, runner)     │  │
│      │  4. If failed → check retry conditions  │  │
│      │  5. If eligible → reset & retry         │  │
│      │  6. Record stats on final attempt       │  │
│      └─────────────────────────────────────────┘  │
└──────────────────────────────────────────────────┘

The patch lifecycle

1. setup_retry(context, ...)

Called in before_all. This function:

  • Creates a RetryConfig dataclass from the provided parameters.

  • Creates a RetryStats instance for tracking.

  • Stores both on the behave context object (_behave_retry_config, _behave_retry_stats, _behave_retry_attempts, _behave_retry_total).

  • Calls _patch_scenario_run(context) which:

    • Imports behave.model.Scenario.

    • Saves the original Scenario.run method.

    • Replaces it with patched_run (a closure over context).

    • Marks the patch with _behave_retry_patched = True to prevent double-patching.

2. patched_run(self, runner)

Called by behave for every scenario. This is where the retry loop lives.

Step-by-step flow

patched_run(scenario, runner)
│
├─ config or stats missing? → delegate to original_run
│
├─ max_for_scenario == 0? → delegate to original_run (no retry)
├─ should_retry_tag(tags) == False? → delegate to original_run
│
├─ attempt = 0
└─ loop:
     ├─ attempt += 1
     ├─ result = original_run(scenario, runner)
     ├─ store attempt count on context
     │
     ├─ passed? → record stats (if attempt > 1), return False
     │
     ├─ failed:
     │   ├─ check retry_on filter
     │   │   └─ exception doesn't match? → record stats, return True
     │   │
     │   ├─ check retry limit (attempt > max_for_scenario)
     │   │   └─ exhausted? → record stats, return True
     │   │
     │   ├─ check global budget (max_total_retries)
     │   │   └─ exhausted? → record stats, return True
     │   │
     │   ├─ increment global budget counter
     │   ├─ log retry attempt
     │   ├─ call on_retry callback (if set)
     │   ├─ sleep(delay)
     │   ├─ reset scenario state
     │   └─ continue loop

3. Scenario state reset

Before each retry, _reset_scenario_state is called. This:

  • Calls scenario.clear_status() (behave’s built-in reset) or sets scenario.status = None as fallback.

  • For each step in the scenario:

    • Calls step.reset() if available (behave’s built-in step reset).

    • Otherwise sets step.status = None.

    • Clears step.exception, step.error_message, and step.error if they exist.

This ensures the scenario starts fresh on each retry — behave won’t skip steps that were already “passed” on a previous attempt.

4. after_scenario_hook(context, scenario)

Called in after_scenario. With the patched Scenario.run, the retry loop is handled automatically. This hook is kept for backward compatibility and tracks the attempt count on the context for scenarios that were never retried (ensuring every scenario has an entry in _behave_retry_attempts).

5. retry_report(context)

Called in after_all. Reads _behave_retry_stats from the context and returns a formatted summary string. Also logs the summary at INFO level.

Scenario key generation

Each scenario is identified by a unique key stored in _behave_retry_attempts and used in RetryStats. The key is generated by _get_scenario_key:

Condition

Key format

Example

filename, line, and name available

filename:line:name

features/login.feature:5:Login with bob

filename and line available, no name

filename:line

features/login.feature:5

Only name available

name

Login with bob

Nothing available

str(scenario)

<Scenario object at 0x...>

Including the name prevents key collisions between Scenario Outline examples that share the same file and line but have different names after placeholder substitution.

Exception extraction

When a scenario fails, behave-retry needs to determine what exception caused the failure. Two helpers handle this:

  • _get_last_exception(scenario) — iterates steps in reverse, finds the last failed step with an exception, and returns the exception instance.

  • _get_last_exception_type(scenario) — returns type(exception) or None.

Behave assigns Status.failed to AssertionError and Status.error to other exceptions. Both are treated as failures by _step_failed.

Idempotency

_patch_scenario_run checks for the _behave_retry_patched attribute on the original Scenario.run. If already patched, it returns immediately. This means calling setup_retry multiple times is safe — the second call updates the config on the context but doesn’t re-patch.

However, the closure in patched_run reads config dynamically via getattr(context, "_behave_retry_config"), so the latest config is always used.

Thread safety

Behave runs scenarios sequentially in a single thread. behave-retry is not designed for concurrent scenario execution and does not use any locking. This is consistent with behave’s own execution model.