Mercurial > prosody-modules
changeset 6343:ee2ebdec1fae
mod_s2s_cache_failures: New module to cache failures to remote domains and control retries
| author | Matthew Wild <mwild1@gmail.com> |
|---|---|
| date | Thu, 16 Oct 2025 19:08:20 +0100 |
| parents | 4c98f4340bf1 |
| children | e407d9795b6a |
| files | mod_s2s_cache_failures/README.md mod_s2s_cache_failures/mod_s2s_cache_failures.lua |
| diffstat | 2 files changed, 180 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_s2s_cache_failures/README.md Thu Oct 16 19:08:20 2025 +0100 @@ -0,0 +1,38 @@ +--- +labels: +- Statistics +- Stage-Alpha +summary: Cache s2s failures and prevent excessive retries +... + +## Description + +This module essentially rate limits the number of outgoing connection attempts +to domains that Prosody hasn't been able to connect to. + +This can help with resource usage (no need to queue undelivered stanzas for +unreachable domains), and also reduces noise in the log files. + +The retry period is proportional to the amount of time since Prosody last had +a connection to the domain. It also includes some randomness to prevent +multiple servers unintentially flooding a domain with reconnection attempts at +the same time. On average a failed domain will be tried around 15 times in the +first hour of unavailability, decreasing in frequency to 1-2 attempts per hour +after that. + +## Configuration + +The only configuration option is: + +`s2s_failure_cache_size` +: The number of failed domains that will be remembered (overflows will simply +cause failures to be forgotten, and Prosody will retry those domains). + +## Compatibility + +Requires Prosody 13.0 or later. + +For users of Prosody trunk after 13.0, commit [4067a95336dd](https://hg.prosody.im/trunk/rev/4067a95336dd) +introduces a new `s2s_block_immediate_retries` option which you may want to +enable, to catch some cases of retries which this module cannot otherwise +prevent.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_s2s_cache_failures/mod_s2s_cache_failures.lua Thu Oct 16 19:08:20 2025 +0100 @@ -0,0 +1,142 @@ +module:set_global(); + +local cache = require "prosody.util.cache"; +local st = require "prosody.util.stanza"; +local time = require "prosody.util.time"; + +local measure_failed_domains = module:metric( + "gauge", "failed_domains", "", + "Number of cached domain failures" +); + +local measure_rejected_stanzas = module:metric( + "counter", "failed_stanzas", "", + "Number of rejected stanzas to failed domains" +); + +local failed_domains_cache_size = module:get_option_number("s2s_failure_cache_size", 128); + +local domains = cache.new(failed_domains_cache_size); + +-- This function returns an appropriate delay until we should retry connecting +-- It includes randomness, so that every Prosody server does not attempt +-- to reconnect to the target server at the same time (which may overload it +-- and cause further downtime). The logic and numbers here results in approximately +-- 15 retries per hour, with up to 30min between attempts. +local function get_holdoff_time(first_disconnect, last_disconnect) + -- Retry for a server that has been down for 2 hours will be the same + -- range as a server that has been down for 1 hour + local downtime = math.min(3600, last_disconnect - first_disconnect); + -- Range from at least 10-20s up to a range proportional to the downtime + return math.random(10 + downtime/4, 20 + downtime/2); +end + +module:hook("s2sout-established", function (event) + -- Successfully connected, so stop tracking this domain + local domain = event.session.to_host; + domains:set(domain, nil); +end); + +module:hook("s2sout-destroyed", function (event) + local domain = event.session.to_host; + if not event.reason or event.session.type ~= "s2sout_unauthed" then + return; + end + local current_time = time.now(); + local holdoff_time = get_holdoff_time(current_time, current_time); + local state = domains:get(domain) or { current_time, nil, -1, current_time + holdoff_time, event.reason }; + state[2], state[3] = current_time, state[3] + 1; + domains:set(domain, state); + module:log("info", "Preventing further connections to %s for %d seconds", holdoff_time); +end); + +module:hook("route/remote", function (event) + local origin, stanza, domain = event.origin, event.stanza, event.to_host; + local domain_info = domains:get(domain); + if domain_info then + measure_rejected_stanzas:add(1); + local holdoff_until = domain_info[4]; + if time.now() < holdoff_until then + return; + end + local reply = st.error_reply( + stanza, + "wait", + "remote-server-timeout", + domain_info[5] and ("Unreachable domain ("..domain_info[5]..")") or "Unreachable domain" + ); + return origin.send(reply); + end +end); + +module:hook("stats-update", function () + measure_failed_domains:set(domains:count()); +end); + +module:add_item("shell-command", { + section = "s2s"; + section_desc = "View and manage server-to-server connections"; + name = "reset_failures"; + desc = "Clear cache of connection failures"; + args = { + { name = "remote_host", type = "string" }; + }; + handler = function (_, remote_host) + if remote_host then + if domains:get(remote_host) == nil then + return false, "No failure cached for "..remote_host; + end + domains:set(remote_host, nil); + return true, "Reset failure cache for "..remote_host; + end + + -- No remote host specified, so clear all + domains:clear(); + return true, "Failure cache cleared"; + end; +}); + +module:add_item("shell-command", { + section = "s2s"; + section_desc = "View and manage server-to-server connections"; + name = "show_failures"; + desc = "Show cache of connection failures"; + args = { + { name = "remote_host", type = "string" }; + }; + handler = function (shell, remote_host) + local function show_domain_failures(domain, domain_info) + domain_info = domain_info or domains:get(domain); + if not domain_info then + return false; + end + local first_disconnect, last_disconnect = domain_info[1], domain_info[2]; + local attempts, holdoff, reason = domain_info[3], domain_info[4], domain_info[5]; + + shell.session.print(domain..":"); + shell.session.print(" First disconnected: "..os.date("%c", first_disconnect)); + shell.session.print(" Last disconnected: "..os.date("%c", last_disconnect)); + if reason then + shell.session.print(" because: "..reason); + end + shell.session.print(" Reconnection attempts: "..attempts); + shell.session.print(" No more attempts until: "..os.date("%c", last_disconnect + holdoff)); + + return true; + end + if remote_host then + if not show_domain_failures(remote_host) then + return false, "No failure cached for "..remote_host; + end + return true, "Showing failure record for "..remote_host; + end + + local c = 0; + for domain, domain_info in domains:items() do + c = c + 1; + show_domain_failures(domain, domain_info); + end + + return true, ("Showing %d failure records"):format(c); + end; +});
