Skip to content

feat: Add Retry-After HTTP header to lockout responses#1401

Open
rodrigobnogueira wants to merge 8 commits intojazzband:masterfrom
rodrigobnogueira:feature/retry-after-header
Open

feat: Add Retry-After HTTP header to lockout responses#1401
rodrigobnogueira wants to merge 8 commits intojazzband:masterfrom
rodrigobnogueira:feature/retry-after-header

Conversation

@rodrigobnogueira
Copy link
Copy Markdown
Contributor

@rodrigobnogueira rodrigobnogueira commented Feb 21, 2026

What does this PR do?

When AXES_COOLOFF_TIME is configured, lockout responses now automatically include the Retry-After HTTP header (RFC 7231 §7.1.3) with the cool-off duration in seconds. This helps clients (browsers, API consumers, bots)
understand how long to wait before retrying.

Changes

axes/helpers.py

  • Added _set_retry_after_header() helper that converts AXES_COOLOFF_TIME
    to total seconds and sets the Retry-After header on the response.
  • Called from the three Axes-controlled branches of get_lockout_response():
    JSON (XHR), template-rendered, and plain HttpResponse.

tests/test_helpers.py

  • Added 5 tests covering:
    • Retry-After present with a valid cool-off time (plain response)
    • Retry-After absent when AXES_COOLOFF_TIME is None
    • Retry-After present on JSON (XHR) responses
    • Retry-After present on template-rendered responses
    • Retry-After absent on redirect responses (AXES_LOCKOUT_URL)

docs/4_configuration.rst

  • Added a .. note:: block after the settings table documenting the
    Retry-After header behavior and which response types include it.

Design Decisions

Scenario Header set? Rationale
Plain HttpResponse Standard lockout response
JsonResponse (XHR) API consumers benefit most
Template render Browser can still use it
AXES_LOCKOUT_URL Redirect — destination controls headers
AXES_LOCKOUT_CALLABLE User owns the response entirely
AXES_COOLOFF_TIME=None Permanent ban — no retry window to advertise

Testing

All 351 existing + new tests pass with zero failures.

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?

rodrigo.nogueira added 2 commits February 21, 2026 18:44
…OOLOFF_TIME` is configured, along with documentation and tests.
Comment thread axes/helpers.py Outdated
Comment thread axes/helpers.py Outdated
Comment thread axes/middleware.py
Comment on lines +42 to +57
@staticmethod
def _set_retry_after_header(
response: HttpResponse, request: HttpRequest
) -> HttpResponse:
if not settings.AXES_ENABLE_RETRY_AFTER_HEADER:
return response

if settings.AXES_LOCKOUT_CALLABLE or settings.AXES_LOCKOUT_URL:
return response

cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))

return response

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.

Wouldl it make sense to just write this as a simpler single if-then method? Why do we need to skip setting the header on callable and lockout URLs?

I think a better location for this function could be the axes.helpers module since it contains other similar implementations for getters and this can be just a generic function (although it is quite middleware-ish since it uses both response and request objects).

Since this method actually mutates the response object without copying it it should probably just set the header, no need to return a new object really:

Suggested change
@staticmethod
def _set_retry_after_header(
response: HttpResponse, request: HttpRequest
) -> HttpResponse:
if not settings.AXES_ENABLE_RETRY_AFTER_HEADER:
return response
if settings.AXES_LOCKOUT_CALLABLE or settings.AXES_LOCKOUT_URL:
return response
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
return response
def set_retry_after_header(
request: HttpRequest, response: HttpResponse
):
if settings.AXES_ENABLE_RETRY_AFTER_HEADER:
response["Retry-After"] = str(int(get_cool_off(request).total_seconds()))

Then it can just be simply invoked here or maybe even preferably in axes.helpers.get_lockout_response where it could live in one place instead of multiple:

Suggested change
@staticmethod
def _set_retry_after_header(
response: HttpResponse, request: HttpRequest
) -> HttpResponse:
if not settings.AXES_ENABLE_RETRY_AFTER_HEADER:
return response
if settings.AXES_LOCKOUT_CALLABLE or settings.AXES_LOCKOUT_URL:
return response
cool_off = get_cool_off(request)
if cool_off is not None:
response["Retry-After"] = str(int(cool_off.total_seconds()))
return response
set_retry_after_header(request, response)

This is easier to use and simple to test.

Copy link
Copy Markdown
Contributor Author

@rodrigobnogueira rodrigobnogueira Mar 21, 2026

Choose a reason for hiding this comment

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

done. It is simpler now. thanks

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.

Let me know if the implementation is better now.

@rodrigobnogueira rodrigobnogueira force-pushed the feature/retry-after-header branch 2 times, most recently from 349be0b to a4b34ac Compare March 21, 2026 05:45
@rodrigobnogueira rodrigobnogueira force-pushed the feature/retry-after-header branch from a4b34ac to fd78bdf Compare March 21, 2026 05:54
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.

2 participants