Skip to content

Fix frequent Retry later responses in production - #4402

Draft
ferblape wants to merge 7 commits into
masterfrom
ferblape/retry-later-produccion
Draft

Fix frequent Retry later responses in production#4402
ferblape wants to merge 7 commits into
masterfrom
ferblape/retry-later-produccion

Conversation

@ferblape

@ferblape ferblape commented Sep 1, 2026

Copy link
Copy Markdown
Member

✌️ What does this PR do?

Visitors were often getting rack-attack's default plain text Retry later page: the limits were 20 requests per 10 seconds and 1000 per hour per IP, with no exemptions, while a town hall office shares one NAT address and the search box sent one request per keystroke. The policy moves to lib/middlewares/rack_attack_rules.rb with a budget per area (240/min burst, 4000/h sustained, 90/min agendas, 60/min budgets execution) and every limit readable from the environment, so a limit can be fixed in production without a deploy; requests are keyed on the address ActionDispatch resolves and static files, private addresses and RACK_ATTACK_SAFELIST_IPS are exempt. It also fixes two bugs found on the way: Fail2Ban#fail! returns true unconditionally, so the first out-of-window agenda date already answered 403 and five of them banned the whole section for an hour (now a 10/h throttle on the bogus requests only), and the date filter called request.params without rescuing, so /agendas?date=1&date[x]=2 raised out of the middleware as a 500. Throttled clients now get a localized 429 with Retry-After, in HTML or JSON, and the log names which rule fired — the five rules used to write identical lines — with an AppSignal counter alongside. On the front end the autocomplete is debounced, the calendar is bounded to the window the server accepts and a failed fetch no longer leaves it blank.

🔍 How should this be manually tested?

On staging: type a full query in the search box and navigate months in a person's agenda (/agendas/<slug>), both with zero 429 in the network tab; curl -si "$STAGING/agendas?start_date=1900-01-01" repeated ~15 times should end in a localized 429 carrying Retry-After; and curl -s -o /dev/null -w '%{http_code}' "$STAGING/agendas?date=1&date%5Bx%5D=2" must not be a 500. After deploying, with ENABLE_LOG_RAGE on, grep '\[rack_attack\]' shows which rule fires and grep -o 'x_forwarded_for=[^ ]*' tells whether client addresses are the visitors' own or a shared proxy's.

👀 Screenshots

Before this PR

Retry later as text/plain, no Retry-After header.

After this PR

Localized 429 page (es/en/ca) with Retry-After, or a JSON body for XHR and .json requests.

:shipit: Does this PR changes any configuration file?

  • new environment variable in .env.example?
  • new entry in config/application.yml?
  • new entry in config/secrets.yml?

RACK_ATTACK_ENABLED, RACK_ATTACK_SAFELIST_IPS and one RACK_ATTACK_<RULE>_LIMIT / _PERIOD per rule. All optional — the defaults in RackAttackRules::DEFAULTS apply when unset — but the Ansible role may want them so limits can be tuned without a deploy.

📖 Does this PR require updating the documentation?

  • new site configuration variable?
  • new site template?
  • new module/submodule settings?
  • significant changes in some feature?

Rate limiting is now enabled in production and staging only; it was previously on in development too.

ferblape and others added 3 commits September 1, 2026 07:41
Visitors were seeing rack-attack's default plain text "Retry later" page
often. The limits were far below real usage: 20 requests per 10 seconds
and 1000 per hour, per IP, with no exemptions, while a town hall office
shares a single NAT address and a portal page fires several requests.

Rework the policy into RackAttackRules, with a budget per area and every
limit and period readable from the environment, so a limit can be fixed
in production without a deploy:

  requests by ip burst              240 / minute
  requests by ip sustained         4000 / hour
  agendas by ip                      90 / minute, GET and HEAD
  agendas with bogus dates by ip     10 / hour
  budgets execution by ip            60 / minute, GET and HEAD

Safelist static files, private addresses and a configurable list of
ranges. Key the rules on the address ActionDispatch resolves, the one
the audit trail records, instead of Rack's, rescuing the spoof check so
that crafted headers cannot turn into a 500 from a middleware.

Replace the Fail2Ban rule on out of window agenda dates. Fail2Ban#fail!
returns true unconditionally, so the first such request was already
answered with 403 and five of them banned the whole agenda section for
the address for an hour, which a visitor reaches by clicking through the
calendar. The date parsing also called request.params without rescuing:
"/agendas?date=1&date[x]=2" raised Rack::QueryParser::ParameterTypeError
out of the middleware. It is a throttle now, it counts only the bogus
requests, and it parses with the window and the parser the controller
already uses.

Answer with a localized 429 carrying Retry-After, in HTML or JSON
depending on what the client asked for, instead of a text/plain body
that breaks the calendar's fetch.

Name the matched rule in the log and count it in AppSignal: the five
rules used to write the same line, so there was no way to tell which
limit a client had hit. Fill the address fields lograge already reads
and adds nothing to today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… send

The search box sent one request per keystroke: devbridge-autocomplete
defaults deferRequestBy to 0 and the settings never set it, so typing a
single query was about twenty requests in a few seconds. Debounce them.

The calendar let the visitor navigate outside the window the server
accepts, which answers 422, and its fetch called r.json() on any
response, so an error left the calendar empty with no explanation. Bound
navigation with the window the controller enforces, passed in from the
view, and report a failed request through FullCalendar's failure
callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ferblape
ferblape marked this pull request as ready for review September 1, 2026 05:47
ferblape and others added 4 commits September 1, 2026 08:02
append_info_to_payload resolves the client address for every action, and
ActionDispatch raises IpSpoofAttackError when Client-IP and X-Forwarded-For
contradict each other. That error is not mapped in rescue_responses, and the
hook runs in the ensure of process_action, so a response that had already
been rendered was discarded and returned as a 500.

The other address fields still record the raw headers, so the spoofing
attempt remains visible in the log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FullCalendar writes event source failures to the console when no
eventSourceFailure handler is set, so the too_many_requests translation
never reached the visitor: a throttled calendar just stayed empty.

The message is now rendered next to the calendar and cleared when a fetch
starts, so a successful retry removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The source was assigned first, so a concurrent caller could take the early
return and read the ranges while they were still nil. safelisted_ip? then
raised NoMethodError, which its rescue clause does not cover, and the
safelist blew up as a 500 out of the middleware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The integration test could not pass: with lograge disabled, as it is outside
production and staging, Rails::Rack::Logger builds its "Started GET" line by
calling request.remote_ip, so the spoofing check fires before the app runs and
no controller change can prevent it.

Driving append_info_to_payload against a request annotated the way
ActionDispatch::RemoteIp annotates it exercises the call site this concern
actually owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ferblape
ferblape marked this pull request as draft September 3, 2026 13:20
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.

1 participant