Skip to content

Feat: Progressive/tiered lockouts based on failure count#1402

Open
rodrigobnogueira wants to merge 6 commits intojazzband:masterfrom
rodrigobnogueira:feature/tiered-lockouts
Open

Feat: Progressive/tiered lockouts based on failure count#1402
rodrigobnogueira wants to merge 6 commits intojazzband:masterfrom
rodrigobnogueira:feature/tiered-lockouts

Conversation

@rodrigobnogueira
Copy link
Copy Markdown
Contributor

What does this PR do?

This PR introduces the ability to configure progressive (tiered) lockouts in django-axes. Instead of a single fixed cool-off time, the system now supports escalating lockout durations based on the number of failed attempts.

Changes

  • Added LockoutTier dataclass and the AXES_LOCKOUT_TIERS setting to configure the tiers in axes.conf.
  • Modified core helpers (get_cool_off, get_failure_limit, get_lockout_message, get_lockout_response) in axes.helpers to resolve and apply the appropriate tier based on a request's axes_failures_since_start count.
  • Added system checks (W007, W008) in axes.checks to warn users if AXES_LOCKOUT_TIERS is misconfigured or used alongside AXES_COOLOFF_TIME.
  • Wrote comprehensive unit tests and check tests for the tiered lockout logic.
  • Added documentation for AXES_LOCKOUT_TIERS to docs/4_configuration.rst.

How it works

Users can configure AXES_LOCKOUT_TIERS as a list of LockoutTier instances. For example:

from datetime import timedelta
from axes.conf import LockoutTier

AXES_LOCKOUT_TIERS = [
    LockoutTier(failures=3,  cooloff=timedelta(minutes=15)),
    LockoutTier(failures=6,  cooloff=timedelta(hours=2)),
    LockoutTier(failures=10, cooloff=timedelta(days=1)),
]

When AXES_LOCKOUT_TIERS is defined, it overrides both AXES_FAILURE_LIMIT (which becomes the threshold of the lowest tier) and AXES_COOLOFF_TIME. Lockouts are subsequently applied dynamically according to the rules defined in the tiers.

All existing behavior remains completely unchanged if AXES_LOCKOUT_TIERS is not set.

Before submitting

  • This PR fixes a typo or improves the docs (you can dismiss the other checks if that's the case).
  • Did you make sure to update the documentation with your changes?
  • Did you write any new necessary tests?

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.90%. Comparing base (c3dcd1b) to head (004b05d).
⚠️ Report is 12 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1402      +/-   ##
==========================================
+ Coverage   91.56%   91.90%   +0.34%     
==========================================
  Files          37       37              
  Lines        1268     1322      +54     
  Branches      172      183      +11     
==========================================
+ Hits         1161     1215      +54     
  Misses         83       83              
  Partials       24       24              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@aleksihakli
Copy link
Copy Markdown
Member

Hey, looks good, thanks, but this is a handful to review and a brand-new feature at that. I'll try to set some time aside later on for checking this out.

@hirotasoshu
Copy link
Copy Markdown
Contributor

Idea looks good to me, but what if instead of tiers we use exponential? For example, after 3 failures coloff time will be multiplied by some value?

Copy link
Copy Markdown
Member

@aleksihakli aleksihakli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good @rodrigobnogueira! Would it be possible to rename the functions with underscore prefixes? I'll merge this afterwards.

Comment thread axes/helpers.py Outdated
return matched


def _resolve_tier_from_request(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, I think we don't need to prefix things with underscore here since that has only been done for the built-ins such as __init__. It's a valid approach but since it would introduce another habit into the codebase I'd keep it simple and just use one plain naming convention for all functions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, absolutely agreed on keeping one naming convention.

I’ve updated the new helpers to use plain names (no leading underscore) and adjusted call sites accordingly.

Please let me know if you’d like any further tweaks.

@rodrigobnogueira
Copy link
Copy Markdown
Contributor Author

Idea looks good to me, but what if instead of tiers we use exponential? For example, after 3 failures coloff time will be multiplied by some value?

Thanks for the feedback!
I think the tiered approach is more flexible than a fixed exponential formula. You can already achieve classic exponential backoff just by setting the cooloff values to multiply (e.g. 5 min → 10 min → 20 min → 40 min …).

That gives admins full control over the curve (exponential, linear, custom steps, whatever fits their threat model) without needing a new setting.

If you'd prefer a built-in exponential formula (e.g. base_cooloff * multiplier ** failures with a configurable multiplier), I'm happy to add it as an optional alternative. But I think the current tiers already cover the use case very well. What specific exponential behaviour did you have in mind? We can adjust the example in the docs or add a helper if that would make it clearer.

Example:

from datetime import timedelta
from axes.conf import LockoutTier

AXES_LOCKOUT_TIERS = [
    LockoutTier(failures=3,  cooloff=timedelta(minutes=5)),   # base
    LockoutTier(failures=4,  cooloff=timedelta(minutes=10)),  # ×2
    LockoutTier(failures=5,  cooloff=timedelta(minutes=20)),  # ×2
    LockoutTier(failures=6,  cooloff=timedelta(minutes=40)),  # ×2
    # ... keep going or cap it
]

@rodrigobnogueira
Copy link
Copy Markdown
Contributor Author

I pushed a small CI hardening update for Codecov upload reliability.

The failure we hit (getaddrinfo EAI_AGAIN uploader.codecov.io) is a transient DNS/network issue on the GitHub runner side, not a test or coverage regression. To avoid flaky red builds from external network hiccups, I updated:

  • codecov/codecov-action from @v3 to @v5
  • fail_ci_if_error: false for the upload step

Now CI is passing. Let me know if you want I rollback this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants